diff --git a/CHANGES.md b/CHANGES.md index 0125fb3d6..253228f4e 100644 --- a/CHANGES.md +++ b/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) diff --git a/karma.conf.js b/karma.conf.js index 05c3026df..9c6252b63 100644 --- a/karma.conf.js +++ b/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' }, diff --git a/src/headless/headless.js b/src/headless/headless.js index 7e9cc74dd..cd89a308c 100644 --- a/src/headless/headless.js +++ b/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"; diff --git a/src/headless/plugins/caps/index.js b/src/headless/plugins/caps/index.js index 4004e35ce..28c32ba0f 100644 --- a/src/headless/plugins/caps/index.js +++ b/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); } }); diff --git a/src/headless/plugins/caps/tests/caps.js b/src/headless/plugins/caps/tests/caps.js index f17402144..41eb98b37 100644 --- a/src/headless/plugins/caps/tests/caps.js +++ b/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( ``+ `0`+ @@ -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( ``+ `Hello world`+ @@ -40,8 +40,9 @@ describe("A sent presence stanza", function () { ``+ `` ); + 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( ``+ `away`+ @@ -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( ``+ `dnd`+ diff --git a/src/headless/plugins/caps/utils.js b/src/headless/plugins/caps/utils.js index 20670ed4e..081e02d3b 100644 --- a/src/headless/plugins/caps/utils.js +++ b/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; } diff --git a/src/headless/plugins/smacks/tests/smacks.js b/src/headless/plugins/smacks/tests/smacks.js index 8fd47a5f5..69dc38e86 100644 --- a/src/headless/plugins/smacks/tests/smacks.js +++ b/src/headless/plugins/smacks/tests/smacks.js @@ -48,6 +48,8 @@ describe("XEP-0198 Stream Management", function () { ``+ ``]); + 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(); diff --git a/src/headless/plugins/status.js b/src/headless/plugins/status.js deleted file mode 100644 index ee7166178..000000000 --- a/src/headless/plugins/status.js +++ /dev/null @@ -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 }); - } - } - } - }); - } -}); diff --git a/src/headless/plugins/status/api.js b/src/headless/plugins/status/api.js new file mode 100644 index 000000000..c640867f2 --- /dev/null +++ b/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 }); + } + } + } +} diff --git a/src/headless/plugins/status/index.js b/src/headless/plugins/status/index.js new file mode 100644 index 000000000..57f51d487 --- /dev/null +++ b/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)); + } +}); diff --git a/src/headless/plugins/status/status.js b/src/headless/plugins/status/status.js new file mode 100644 index 000000000..854037b11 --- /dev/null +++ b/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; diff --git a/spec/xmppstatus.js b/src/headless/plugins/status/tests/status.js similarity index 100% rename from spec/xmppstatus.js rename to src/headless/plugins/status/tests/status.js diff --git a/src/headless/plugins/status/utils.js b/src/headless/plugins/status/utils.js new file mode 100644 index 000000000..5dc3442cf --- /dev/null +++ b/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); +} diff --git a/src/plugins/muc-views/heading.js b/src/plugins/muc-views/heading.js index 1f91b7e6f..a7d5fc350 100644 --- a/src/plugins/muc-views/heading.js +++ b/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'; diff --git a/src/plugins/muc-views/utils.js b/src/plugins/muc-views/utils.js index 54cfd7c78..64b214d16 100644 --- a/src/plugins/muc-views/utils.js +++ b/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; }