From 16ca8044f814299b1b39d682678978f05d92b975 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Wed, 3 Jun 2020 17:59:41 +0200 Subject: [PATCH] Add experimental support for running the XMPP conneciton inside a shared worker Still lacks inter-tab communication to update state across tabs, i.e. when sending a 1-on-1 message in one tab, it doesn't appear in another, because that information is not available via the websocket connection. - Create a new `Connection` class that extends Strophe.Connection and move related code from `converse-core.js` into this class. - Store the session in localStorage when using a worker - Move XEP-0156 code to connection.js This allows us to initialize the connection without needing to know the domain. --- docs/source/configuration.rst | 25 +- package-lock.json | 119 ++++------ spec/bookmarks.js | 2 +- spec/mock.js | 68 ------ spec/register.js | 12 +- spec/smacks.js | 9 +- src/converse-register.js | 4 +- src/headless/connection.js | 407 ++++++++++++++++++++++++++++++++ src/headless/converse-core.js | 404 +++++-------------------------- src/headless/converse-mam.js | 4 +- src/headless/converse-roster.js | 2 +- src/headless/package-lock.json | 98 ++++---- src/headless/package.json | 2 +- src/templates/spinner.js | 2 +- webpack.common.js | 2 +- webpack.html | 1 + webpack.prod.js | 1 + 17 files changed, 605 insertions(+), 557 deletions(-) create mode 100644 src/headless/connection.js diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 9063f1808..eb3be75b7 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -578,6 +578,9 @@ For documentation on the configuration options that ``Strophe.Connection`` accepts, refer to the `Strophe.Connection documentation `_. +Restricting the supported authentication mechanisms: +**************************************************** + As an example, suppose you want to restrict the supported SASL authentication mechanisms, then you'd pass in the ``mechanisms`` as a ``connection_options`` ``key:value`` pair: @@ -589,9 +592,29 @@ mechanisms, then you'd pass in the ``mechanisms`` as a ``connection_options`` 'mechanisms': [ converse.env.Strophe.SASLMD5, ] - }, + } }); +Running the XMPP Connection inside a shared worker +************************************************** + +Newer versions of Strophe.js, support the ability to run the XMPP Connection +inside a `shared worker `_ that's shared +between open tabs in the browser in which Converse is running (and which have the same domain). + +*Note:* This feature is experimental and there currently is no way to +synchronize actions between tabs. For example, sent 1-on-1 messages aren't +reflected by the server, so you if you send such a message in one tab, it won't +appear in another. + + +.. code-block:: javascript + + converse.initialize({ + connection_options: { 'worker': true } + }); + + .. _`credentials_url`: credentials_url diff --git a/package-lock.json b/package-lock.json index e08d8f4e4..18cc0a90b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3283,9 +3283,12 @@ } }, "strophe.js": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/strophe.js/-/strophe.js-1.3.4.tgz", - "integrity": "sha512-jSLDG8jolhAwGOSgiJ7DTMSYK3wVoEJHKtpVRyEacQZ6CWA6z2WRPJpcFMjsIweq5aP9/XIvKUQqHBu/ZhvESA==" + "version": "github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f", + "from": "strophe.js@github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f", + "requires": { + "abab": "^2.0.3", + "xmldom": "^0.1.27" + } }, "twemoji": { "version": "12.1.5", @@ -4901,30 +4904,27 @@ } }, "@octokit/endpoint": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.3.tgz", - "integrity": "sha512-Y900+r0gIz+cWp6ytnkibbD95ucEzDSKzlEnaWS52hbCDNcCJYO5mRmWW7HRAnDc7am+N/5Lnd8MppSaTYx1Yg==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.5.tgz", + "integrity": "sha512-70K5u6zd45ItOny6aHQAsea8HHQjlQq85yqOMe+Aj8dkhN2qSJ9T+Q3YjUjEYfPRBcuUWNgMn62DQnP/4LAIiQ==", "dev": true, "requires": { "@octokit/types": "^5.0.0", - "is-plain-object": "^3.0.0", - "universal-user-agent": "^5.0.0" + "is-plain-object": "^4.0.0", + "universal-user-agent": "^6.0.0" }, "dependencies": { "is-plain-object": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz", - "integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-4.1.1.tgz", + "integrity": "sha512-5Aw8LLVsDlZsETVMhoMXzqsXwQqr/0vlnBYzIXJbYo2F4yYlhLHs+Ez7Bod7IIQKWkJbJfxrWD7pA1Dw1TKrwA==", "dev": true }, "universal-user-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-5.0.0.tgz", - "integrity": "sha512-B5TPtzZleXyPrUMKCpEHFmVhMN6EhmJYjG5PQna9s7mXeSqGTLap4OpqLl5FCEFUI3UBmllkETwKf/db66Y54Q==", - "dev": true, - "requires": { - "os-name": "^3.1.0" - } + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz", + "integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==", + "dev": true } } }, @@ -4982,19 +4982,19 @@ } }, "@octokit/request": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.4.5.tgz", - "integrity": "sha512-atAs5GAGbZedvJXXdjtKljin+e2SltEs48B3naJjqWupYl2IUBbB/CJisyjbNHcKpHzb3E+OYEZ46G8eakXgQg==", + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.4.7.tgz", + "integrity": "sha512-FN22xUDP0i0uF38YMbOfx6TotpcENP5W8yJM1e/LieGXn6IoRxDMnBf7tx5RKSW4xuUZ/1P04NFZy5iY3Rax1A==", "dev": true, "requires": { "@octokit/endpoint": "^6.0.1", "@octokit/request-error": "^2.0.0", "@octokit/types": "^5.0.0", "deprecation": "^2.0.0", - "is-plain-object": "^3.0.0", + "is-plain-object": "^4.0.0", "node-fetch": "^2.3.0", "once": "^1.4.0", - "universal-user-agent": "^5.0.0" + "universal-user-agent": "^6.0.0" }, "dependencies": { "@octokit/request-error": { @@ -5009,19 +5009,16 @@ } }, "is-plain-object": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz", - "integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-4.1.1.tgz", + "integrity": "sha512-5Aw8LLVsDlZsETVMhoMXzqsXwQqr/0vlnBYzIXJbYo2F4yYlhLHs+Ez7Bod7IIQKWkJbJfxrWD7pA1Dw1TKrwA==", "dev": true }, "universal-user-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-5.0.0.tgz", - "integrity": "sha512-B5TPtzZleXyPrUMKCpEHFmVhMN6EhmJYjG5PQna9s7mXeSqGTLap4OpqLl5FCEFUI3UBmllkETwKf/db66Y54Q==", - "dev": true, - "requires": { - "os-name": "^3.1.0" - } + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz", + "integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==", + "dev": true } } }, @@ -5072,9 +5069,9 @@ } }, "@octokit/types": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-5.0.1.tgz", - "integrity": "sha512-GorvORVwp244fGKEt3cgt/P+M0MGy4xEDbckw+K5ojEezxyMDgCaYPKVct+/eWQfZXOT7uq0xRpmrl/+hliabA==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-5.1.2.tgz", + "integrity": "sha512-+zuMnja97vuZmWa+HdUY+0KB9MLwcEHueSSyKu0G/HqZaFYCVdLpBkavb0xyDlH7eoBdvAvSX/+Y8+4FOEZkrQ==", "dev": true, "requires": { "@types/node": ">= 8" @@ -7558,19 +7555,6 @@ "through2": "^3.0.0" }, "dependencies": { - "handlebars": { - "version": "4.7.6", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.6.tgz", - "integrity": "sha512-1f2BACcBfiwAfStCKZNrUCgqNZkGsAT7UM3kkYtXuLo0KnaVfjKOyf7PRzB6++aK9STyT1Pd2ZCPe3EGOXleXA==", - "dev": true, - "requires": { - "minimist": "^1.2.5", - "neo-async": "^2.6.0", - "source-map": "^0.6.1", - "uglify-js": "^3.1.4", - "wordwrap": "^1.0.0" - } - }, "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -7583,12 +7567,6 @@ "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", "dev": true }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - }, "through2": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.2.tgz", @@ -9197,9 +9175,9 @@ "dev": true }, "envinfo": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.5.1.tgz", - "integrity": "sha512-hQBkDf2iO4Nv0CNHpCuSBeaSrveU6nThVxFGTrq/eDlV716UQk09zChaJae4mZRsos1x4YLY2TaH3LHUae3ZmQ==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.7.2.tgz", + "integrity": "sha512-k3Eh5bKuQnZjm49/L7H4cHzs2FlL5QjbTB3JrPxoTI8aJG7hVMe4uKyJxSYH4ahseby2waUwk5OaKX/nAsaYgg==", "dev": true }, "err-code": { @@ -13757,9 +13735,9 @@ } }, "localforage": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.7.4.tgz", - "integrity": "sha512-3EmVZatmNVeCo/t6Te7P06h2alGwbq8wXlSkcSXMvDE2/edPmsVqTPlzGnZaqwZZDBs6v+kxWpqjVsqsNJT8jA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.8.1.tgz", + "integrity": "sha512-azSSJJfc7h4bVpi0PGi+SmLQKJl2/8NErI+LhJsrORNikMZnhaQ7rv9fHj+ofwgSHrKRlsDCL/639a6nECIKuQ==", "requires": { "lie": "3.1.1" } @@ -13965,9 +13943,9 @@ } }, "macos-release": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.4.0.tgz", - "integrity": "sha512-ko6deozZYiAkqa/0gmcsz+p4jSy3gY7/ZsCEokPaYd8k+6/aXGkiTgr61+Owup7Sf+xjqW8u2ElhoM9SEcEfuA==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.4.1.tgz", + "integrity": "sha512-H/QHeBIN1fIGJX517pvK8IEK53yQOW7YcEI55oYtgjDdoCQQz7eJS94qt5kNrscReEyuD/JcdFCm2XBEcGOITg==", "dev": true }, "make-dir": { @@ -14195,9 +14173,9 @@ } }, "parse-json": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.0.0.tgz", - "integrity": "sha512-OOY5b7PAEFV0E2Fir1KOkxchnZNCdowAJgQ5NuxjpBKTRP3pQhwkrkxqQjeoKJ+fO7bCpmIZaogI4eZGDMEGOw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.0.1.tgz", + "integrity": "sha512-ztoZ4/DYeXQq4E21v169sC8qWINGpcosGv9XhTDvg9/hWvx/zrFkc9BiWxR58OJLHGk28j5BL0SDLeV2WmFZlQ==", "dev": true, "requires": { "@babel/code-frame": "^7.0.0", @@ -22874,11 +22852,10 @@ } }, "strophe.js": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/strophe.js/-/strophe.js-1.3.6.tgz", - "integrity": "sha512-kTFdf6ziHqlp2PCr7Z7D/lhO+Hd0FIhzwXXlAIQNOqCWwnnTEor9folIUCVoXgZRMYPQ9BTXI2wBv88RG8mgAA==", + "version": "github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f", + "from": "github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f", "requires": { - "abab": "^2.0.0", + "abab": "^2.0.3", "ws": "^7.0.0", "xmldom": "^0.1.27" }, diff --git a/spec/bookmarks.js b/spec/bookmarks.js index 3dc93aea3..69d54c3b2 100644 --- a/spec/bookmarks.js +++ b/spec/bookmarks.js @@ -1,4 +1,4 @@ -/* global mock */ +/* global mock, converse */ describe("A chat room", function () { diff --git a/spec/mock.js b/spec/mock.js index 91be5fd21..860227bf0 100644 --- a/spec/mock.js +++ b/spec/mock.js @@ -599,74 +599,6 @@ window.addEventListener('converse-loaded', () => { 'preventDefault': function () {} }; - const OriginalConnection = Strophe.Connection; - - function MockConnection (service, options) { - OriginalConnection.call(this, service, options); - - Strophe.Bosh.prototype._processRequest = function () {}; // Don't attempt to send out stanzas - const sendIQ = this.sendIQ; - - this.IQ_stanzas = []; - this.IQ_ids = []; - this.sendIQ = function (iq, callback, errback) { - if (!_.isElement(iq)) { - iq = iq.nodeTree; - } - this.IQ_stanzas.push(iq); - const id = sendIQ.bind(this)(iq, callback, errback); - this.IQ_ids.push(id); - return id; - } - - const send = this.send; - this.sent_stanzas = []; - this.send = function (stanza) { - if (_.isElement(stanza)) { - this.sent_stanzas.push(stanza); - } else { - this.sent_stanzas.push(stanza.nodeTree); - } - return send.apply(this, arguments); - } - - this.features = Strophe.xmlHtmlNode( - ''+ - ''+ - ''+ - ''+ - ''+ - ''+ - ''+ - ``+ - ''+ - ''+ - ''+ - '').firstChild; - - this._proto._connect = () => { - this.connected = true; - this.mock = true; - this.jid = 'romeo@montague.lit/orchard'; - this._changeConnectStatus(Strophe.Status.BINDREQUIRED); - }; - - this.bind = () => { - this.authenticated = true; - this.authenticated = true; - if (!_converse.no_connection_on_bind) { - this._changeConnectStatus(Strophe.Status.CONNECTED); - } - }; - - this._proto._disconnect = () => this._onDisconnectTimeout(); - this._proto._onDisconnectTimeout = _.noop; - } - - MockConnection.prototype = Object.create(OriginalConnection.prototype); - Strophe.Connection = MockConnection; - - function clearIndexedDB () { const promise = u.getResolveablePromise(); const db_request = window.indexedDB.open("converse-test-persistent"); diff --git a/spec/register.js b/spec/register.js index 32e9f9569..405609dd9 100644 --- a/spec/register.js +++ b/spec/register.js @@ -1,4 +1,4 @@ -/*global mock */ +/*global mock, converse */ const Strophe = converse.env.Strophe; const $iq = converse.env.$iq; @@ -56,7 +56,6 @@ describe("The Registration Panel", function () { allow_registration: true }, async function (done, _converse) { - spyOn(Strophe.Connection.prototype, 'connect'); await u.waitUntil(() => _.get(_converse.chatboxviews.get('controlbox'), 'registerpanel')); const toggle = document.querySelector(".toggle-controlbox"); @@ -66,6 +65,7 @@ describe("The Registration Panel", function () { await u.waitUntil(() => u.isVisible(cbview.el)); const registerview = cbview.registerpanel; spyOn(registerview, 'onProviderChosen').and.callThrough(); + spyOn(registerview, 'fetchRegistrationForm').and.callThrough(); registerview.delegateEvents(); // We need to rebind all events otherwise our spy won't be called // Open the register panel @@ -85,7 +85,8 @@ describe("The Registration Panel", function () { form.querySelector('input[name=domain]').value = 'conversejs.org'; submit_button.click(); expect(registerview.onProviderChosen).toHaveBeenCalled(); - await u.waitUntil(() => _converse.connection.connect.calls.count()); + expect(registerview.fetchRegistrationForm).toHaveBeenCalled(); + delete _converse.connection; done(); })); @@ -97,12 +98,12 @@ describe("The Registration Panel", function () { allow_registration: true }, async function (done, _converse) { - spyOn(Strophe.Connection.prototype, 'connect'); await u.waitUntil(() => _.get(_converse.chatboxviews.get('controlbox'), 'registerpanel')); const cbview = _converse.api.controlbox.get(); cbview.el.querySelector('.toggle-register-login').click(); const registerview = _converse.chatboxviews.get('controlbox').registerpanel; + spyOn(registerview, 'fetchRegistrationForm').and.callThrough(); spyOn(registerview, 'onProviderChosen').and.callThrough(); spyOn(registerview, 'getRegistrationFields').and.callThrough(); spyOn(registerview, 'onRegistrationFields').and.callThrough(); @@ -115,7 +116,7 @@ describe("The Registration Panel", function () { registerview.el.querySelector('input[type=submit]').click(); expect(registerview.onProviderChosen).toHaveBeenCalled(); expect(registerview._registering).toBeTruthy(); - await u.waitUntil(() => _converse.connection.connect.calls.count()); + await u.waitUntil(() => registerview.fetchRegistrationForm.calls.count()); let stanza = new Strophe.Builder("stream:features", { 'xmlns:stream': "http://etherx.jabber.org/streams", @@ -294,7 +295,6 @@ describe("The Registration Panel", function () { mock.initConverse( ['chatBoxesInitialized'], { auto_login: false, - view_mode: 'fullscreen', discover_connection_methods: false, allow_registration: true }, async function (done, _converse) { diff --git a/spec/smacks.js b/spec/smacks.js index 53720e79d..5e761be1d 100644 --- a/spec/smacks.js +++ b/spec/smacks.js @@ -1,4 +1,4 @@ -/*global mock */ +/*global mock, converse */ const $iq = converse.env.$iq; const $msg = converse.env.$msg; @@ -23,8 +23,7 @@ describe("XEP-0198 Stream Management", function () { await _converse.api.user.login('romeo@montague.lit/orchard', 'secret'); const sent_stanzas = _converse.connection.sent_stanzas; - let stanza = await u.waitUntil(() => - sent_stanzas.filter(s => (s.tagName === 'enable')).pop()); + let stanza = await u.waitUntil(() => sent_stanzas.filter(s => (s.tagName === 'enable'), 1000).pop()); expect(_converse.session.get('smacks_enabled')).toBe(false); expect(Strophe.serialize(stanza)).toEqual(''); @@ -33,7 +32,7 @@ describe("XEP-0198 Stream Management", function () { _converse.connection._dataRecv(mock.createRequest(result)); expect(_converse.session.get('smacks_enabled')).toBe(true); - await u.waitUntil(() => view.renderControlBoxPane.calls.count()); + await u.waitUntil(() => view.renderControlBoxPane.calls?.count()); let IQ_stanzas = _converse.connection.IQ_stanzas; await u.waitUntil(() => IQ_stanzas.length === 4); @@ -105,7 +104,7 @@ describe("XEP-0198 Stream Management", function () { _converse.connection.IQ_stanzas = []; IQ_stanzas = _converse.connection.IQ_stanzas; await _converse.api.connection.reconnect(); - stanza = await u.waitUntil(() => sent_stanzas.filter(s => (s.tagName === 'resume')).pop()); + stanza = await u.waitUntil(() => sent_stanzas.filter(s => (s.tagName === 'resume')).pop(), 1000); expect(Strophe.serialize(stanza)).toEqual(''); result = u.toStanza(``); diff --git a/src/converse-register.js b/src/converse-register.js index f1a3b1ec4..c4d71e3b9 100644 --- a/src/converse-register.js +++ b/src/converse-register.js @@ -310,7 +310,9 @@ converse.plugins.add('converse-register', { '_registering': true }); await _converse.initConnection(this.domain); - _converse.connection.connect(this.domain, "", status => this.onConnectStatusChanged(status)); + // When testing, the test tears down before the async function + // above finishes. So we use optional chaining here + _converse.connection?.connect(this.domain, "", status => this.onConnectStatusChanged(status)); return false; }, diff --git a/src/headless/connection.js b/src/headless/connection.js new file mode 100644 index 000000000..fe2c211c9 --- /dev/null +++ b/src/headless/connection.js @@ -0,0 +1,407 @@ +import log from "./log"; +import sizzle from 'sizzle'; +import u from '@converse/headless/utils/core'; +import { Strophe } from 'strophe.js/src/core'; +import { __ } from './i18n'; +import { _converse, api, clearSession, tearDown } from "./converse-core"; +import { isElement, noop } from 'lodash'; + + +const BOSH_WAIT = 59; + + +/** + * The Connection class manages the connection to the XMPP server. It's + * agnostic concerning the underlying protocol (i.e. websocket, long-polling + * via BOSH or websocket inside a shared worker). + */ +export class Connection extends Strophe.Connection { + + static generateResource () { + return `/converse.js-${Math.floor(Math.random()*139749528).toString()}`; + } + + async bind () { + /** + * Synchronous event triggered before we send an IQ to bind the user's + * JID resource for this session. + * @event _converse#beforeResourceBinding + */ + await api.trigger('beforeResourceBinding', {'synchronous': true}); + super.bind(); + } + + + async onDomainDiscovered (response) { + const text = await response.text(); + const xrd = (new window.DOMParser()).parseFromString(text, "text/xml").firstElementChild; + if (xrd.nodeName != "XRD" || xrd.namespaceURI != "http://docs.oasis-open.org/ns/xri/xrd-1.0") { + return log.warn("Could not discover XEP-0156 connection methods"); + } + const bosh_links = sizzle(`Link[rel="urn:xmpp:alt-connections:xbosh"]`, xrd); + const ws_links = sizzle(`Link[rel="urn:xmpp:alt-connections:websocket"]`, xrd); + const bosh_methods = bosh_links.map(el => el.getAttribute('href')); + const ws_methods = ws_links.map(el => el.getAttribute('href')); + if (bosh_methods.length === 0 && ws_methods.length === 0) { + log.warn("Neither BOSH nor WebSocket connection methods have been specified with XEP-0156."); + } else { + // TODO: support multiple endpoints + api.settings.set("websocket_url", ws_methods.pop()); + api.settings.set('bosh_service_url', bosh_methods.pop()); + this.service = api.settings.get("websocket_url") || api.settings.get('bosh_service_url'); + } + } + + /** + * Adds support for XEP-0156 by quering the XMPP server for alternate + * connection methods. This allows users to use the websocket or BOSH + * connection of their own XMPP server instead of a proxy provided by the + * host of Converse.js. + * @method Connnection.discoverConnectionMethods + */ + async discoverConnectionMethods (domain) { + // Use XEP-0156 to check whether this host advertises websocket or BOSH connection methods. + const options = { + 'mode': 'cors', + 'headers': { + 'Accept': 'application/xrd+xml, text/xml' + } + }; + const url = `https://${domain}/.well-known/host-meta`; + let response; + try { + response = await fetch(url, options); + } catch (e) { + log.error(`Failed to discover alternative connection methods at ${url}`); + log.error(e); + return; + } + if (response.status >= 200 && response.status < 400) { + await this.onDomainDiscovered(response); + } else { + log.warn("Could not discover XEP-0156 connection methods"); + } + } + + /** + * Establish a new XMPP session by logging in with the supplied JID and + * password. + * @method Connnection.connect + * @param { String } jid + * @param { String } password + * @param { Funtion } callback + */ + async connect (jid, password, callback) { + if (api.settings.get("discover_connection_methods")) { + const domain = Strophe.getDomainFromJid(jid); + await this.discoverConnectionMethods(domain); + } + super.connect(jid, password, callback || this.onConnectStatusChanged, BOSH_WAIT); + } + + async reconnect () { + log.debug('RECONNECTING: the connection has dropped, attempting to reconnect.'); + this.setConnectionStatus( + Strophe.Status.RECONNECTING, + __('The connection has dropped, attempting to reconnect.') + ); + /** + * Triggered when the connection has dropped, but Converse will attempt + * to reconnect again. + * + * @event _converse#will-reconnect + */ + api.trigger('will-reconnect'); + + this.reconnecting = true; + await tearDown(); + return api.user.login(); + } + + /** + * Called as soon as a new connection has been established, either + * by logging in or by attaching to an existing BOSH session. + * @method Connection.onConnected + * @param { Boolean } reconnecting - Whether Converse.js reconnected from an earlier dropped session. + */ + async onConnected (reconnecting) { + delete this.reconnecting; + this.flush(); // Solves problem of returned PubSub BOSH response not received by browser + await _converse.setUserJID(this.jid); + + /** + * Synchronous event triggered after we've sent an IQ to bind the + * user's JID resource for this session. + * @event _converse#afterResourceBinding + */ + await api.trigger('afterResourceBinding', reconnecting, {'synchronous': true}); + + if (reconnecting) { + /** + * After the connection has dropped and converse.js has reconnected. + * Any Strophe stanza handlers (as registered via `converse.listen.stanza`) will + * have to be registered anew. + * @event _converse#reconnected + * @example _converse.api.listen.on('reconnected', () => { ... }); + */ + api.trigger('reconnected'); + } else { + /** + * Triggered once converse.js has been initialized. + * See also {@link _converse#event:pluginsInitialized}. + * @event _converse#initialized + */ + api.trigger('initialized'); + /** + * Triggered after the connection has been established and Converse + * has got all its ducks in a row. + * @event _converse#initialized + */ + api.trigger('connected'); + } + } + + /** + * Used to keep track of why we got disconnected, so that we can + * decide on what the next appropriate action is (in onDisconnected) + * @method Connection.setDisconnectionCause + * @param { Number } cause - The status number as received from Strophe. + * @param { String } [reason] - An optional user-facing message as to why + * there was a disconnection. + * @param { Boolean } [override] - An optional flag to replace any previous + * disconnection cause and reason. + */ + setDisconnectionCause (cause, reason, override) { + if (cause === undefined) { + delete this.disconnection_cause; + delete this.disconnection_reason; + } else if (this.disconnection_cause === undefined || override) { + this.disconnection_cause = cause; + this.disconnection_reason = reason; + } + } + + setConnectionStatus (status, message) { + this.status = status; + _converse.connfeedback.set({'connection_status': status, message }); + } + + async finishDisconnection () { + // Properly tear down the session so that it's possible to manually connect again. + log.debug('DISCONNECTED'); + delete this.reconnecting; + this.reset(); + tearDown(); + await clearSession(); + delete _converse.connection; + /** + * Triggered after converse.js has disconnected from the XMPP server. + * @event _converse#disconnected + * @memberOf _converse + * @example _converse.api.listen.on('disconnected', () => { ... }); + */ + api.trigger('disconnected'); + } + + /** + * Gets called once strophe's status reaches Strophe.Status.DISCONNECTED. + * Will either start a teardown process for converse.js or attempt + * to reconnect. + * @method onDisconnected + */ + onDisconnected () { + if (api.settings.get("auto_reconnect")) { + const reason = this.disconnection_reason; + if (this.disconnection_cause === Strophe.Status.AUTHFAIL) { + if (api.settings.get("credentials_url") || api.settings.get("authentication") === _converse.ANONYMOUS) { + // If `credentials_url` is set, we reconnect, because we might + // be receiving expirable tokens from the credentials_url. + // + // If `authentication` is anonymous, we reconnect because we + // might have tried to attach with stale BOSH session tokens + // or with a cached JID and password + return api.connection.reconnect(); + } else { + return this.finishDisconnection(); + } + } else if ( + this.disconnection_cause === _converse.LOGOUT || + reason === Strophe.ErrorCondition.NO_AUTH_MECH || + reason === "host-unknown" || + reason === "remote-connection-failed" + ) { + return this.finishDisconnection(); + } + api.connection.reconnect(); + } else { + return this.finishDisconnection(); + } + } + + /** + * Callback method called by Strophe as the Connection goes + * through various states while establishing or tearing down a + * connection. + * @param { Number } status + * @param { String } message + */ + onConnectStatusChanged (status, message) { + log.debug(`Status changed to: ${_converse.CONNECTION_STATUS[status]}`); + if (status === Strophe.Status.ATTACHFAIL) { + this.setConnectionStatus(status); + this.worker_attach_promise?.resolve(false); + + } else if (status === Strophe.Status.CONNECTED || status === Strophe.Status.ATTACHED) { + if (this.worker_attach_promise?.isResolved && this.status === Strophe.Status.ATTACHED) { + // A different tab must have attached, so nothing to do for us here. + return; + } + this.setConnectionStatus(status); + this.worker_attach_promise?.resolve(true); + + // By default we always want to send out an initial presence stanza. + _converse.send_initial_presence = true; + this.setDisconnectionCause(); + if (this.reconnecting) { + log.debug(status === Strophe.Status.CONNECTED ? 'Reconnected' : 'Reattached'); + this.onConnected(true); + } else { + log.debug(status === Strophe.Status.CONNECTED ? 'Connected' : 'Attached'); + if (this.restored) { + // No need to send an initial presence stanza when + // we're restoring an existing session. + _converse.send_initial_presence = false; + } + this.onConnected(); + } + } else if (status === Strophe.Status.DISCONNECTED) { + this.setDisconnectionCause(status, message); + this.onDisconnected(); + } else if (status === Strophe.Status.BINDREQUIRED) { + this.bind(); + } else if (status === Strophe.Status.ERROR) { + this.setConnectionStatus( + status, + __('An error occurred while connecting to the chat server.') + ); + } else if (status === Strophe.Status.CONNECTING) { + this.setConnectionStatus(status); + } else if (status === Strophe.Status.AUTHENTICATING) { + this.setConnectionStatus(status); + } else if (status === Strophe.Status.AUTHFAIL) { + if (!message) { + message = __('Your XMPP address and/or password is incorrect. Please try again.'); + } + this.setConnectionStatus(status, message); + this.setDisconnectionCause(status, message, true); + this.onDisconnected(); + } else if (status === Strophe.Status.CONNFAIL) { + let feedback = message; + if (message === "host-unknown" || message == "remote-connection-failed") { + feedback = __("Sorry, we could not connect to the XMPP host with domain: %1$s", + `\"${Strophe.getDomainFromJid(this.jid)}\"`); + } else if (message !== undefined && message === Strophe?.ErrorCondition?.NO_AUTH_MECH) { + feedback = __("The XMPP server did not offer a supported authentication mechanism"); + } + this.setConnectionStatus(status, feedback); + this.setDisconnectionCause(status, message); + } else if (status === Strophe.Status.DISCONNECTING) { + this.setDisconnectionCause(status, message); + } + } + + isType (type) { + if (type.toLowerCase() === 'websocket') { + return this._proto instanceof Strophe.Websocket; + } else if (type.toLowerCase() === 'bosh') { + return Strophe.BOSH && this._proto instanceof Strophe.Bosh; + } + } + + hasResumed () { + if (api.settings.get("connection_options")?.worker || this.isType('bosh')) { + return _converse.connfeedback.get('connection_status') === Strophe.Status.ATTACHED; + } else { + // Not binding means that the session was resumed. + return !this.do_bind; + } + } + + restoreWorkerSession () { + this.attach(this.onConnectStatusChanged); + this.worker_attach_promise = u.getResolveablePromise(); + return this.worker_attach_promise; + } +} + + +/** + * The MockConnection class is used during testing, to mock an XMPP connection. + * @class + */ +export class MockConnection extends Connection { + + constructor (service, options) { + super(service, options); + + this.sent_stanzas = []; + this.IQ_stanzas = []; + this.IQ_ids = []; + + this.features = Strophe.xmlHtmlNode( + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ``+ + ''+ + ''+ + ''+ + '').firstChild; + + this._proto._processRequest = noop; + this._proto._disconnect = () => this._onDisconnectTimeout(); + this._proto._onDisconnectTimeout = noop; + this._proto._connect = () => { + this.connected = true; + this.mock = true; + this.jid = 'romeo@montague.lit/orchard'; + this._changeConnectStatus(Strophe.Status.BINDREQUIRED); + } + } + + _processRequest () { // eslint-disable-line class-methods-use-this + // Don't attempt to send out stanzas + } + + sendIQ (iq, callback, errback) { + if (!isElement(iq)) { + iq = iq.nodeTree; + } + this.IQ_stanzas.push(iq); + const id = super.sendIQ(iq, callback, errback); + this.IQ_ids.push(id); + return id; + } + + send (stanza) { + if (isElement(stanza)) { + this.sent_stanzas.push(stanza); + } else { + this.sent_stanzas.push(stanza.nodeTree); + } + return super.send(stanza); + } + + async bind () { + await api.trigger('beforeResourceBinding', {'synchronous': true}); + this.authenticated = true; + if (!_converse.no_connection_on_bind) { + this._changeConnectStatus(Strophe.Status.CONNECTED); + } + } +} + diff --git a/src/headless/converse-core.js b/src/headless/converse-core.js index 2c29c9b09..cc1ddb317 100644 --- a/src/headless/converse-core.js +++ b/src/headless/converse-core.js @@ -5,7 +5,8 @@ */ import './polyfill'; import 'strophe.js/src/websocket'; -import * as strophe from 'strophe.js/src/core'; +import { Strophe, $build, $iq, $msg, $pres } from 'strophe.js/src/strophe'; +import { Connection, MockConnection } from '@converse/headless/connection.js'; import Storage from '@converse/skeletor/src/storage.js'; import _ from './lodash.noconflict'; import advancedFormat from 'dayjs/plugin/advancedFormat'; @@ -22,11 +23,6 @@ import { Router } from '@converse/skeletor/src/router.js'; import { __, i18n } from './i18n'; import { assignIn, debounce, invoke, isFunction, isObject, isString, pick } from 'lodash-es'; -const Strophe = strophe.default.Strophe; -const $build = strophe.default.$build; -const $iq = strophe.default.$iq; -const $msg = strophe.default.$msg; -const $pres = strophe.default.$pres; dayjs.extend(advancedFormat); @@ -82,8 +78,6 @@ class IllegalMessage extends Error {} // Setting wait to 59 instead of 60 to avoid timing conflicts with the // webserver, which is often also set to 60 and might therefore sometimes // return a 504 error page instead of passing through to the BOSH proxy. -const BOSH_WAIT = 59; - const PROMISES = [ 'afterResourceBinding', 'connectionInitialized', @@ -233,7 +227,9 @@ export const _converse = { TimeoutError: TimeoutError, IllegalMessage: IllegalMessage, - isTestEnv: () => (Strophe.Connection.name === 'MockConnection'), + isTestEnv: () => { + return initialization_settings.bosh_service_url === 'montague.lit/http-bind'; + }, /** * Translate the given string based on the current locale. @@ -296,7 +292,7 @@ function initUserSettings () { if (!user_settings?.fetched) { const id = `converse.user-settings.${_converse.bare_jid}`; user_settings = new Model({id}); - user_settings.browserStorage = _converse.createStore(id); + user_settings.browserStorage = createStore(id); user_settings.fetched = user_settings.fetch({'promise': true}); } return user_settings.fetched; @@ -394,9 +390,9 @@ export const api = _converse.api = { } if (_converse.connection.reconnecting) { - debouncedReconnect(); + _converse.connection.debouncedReconnect(); } else { - return reconnect(); + return _converse.connection.reconnect(); } }, @@ -407,11 +403,7 @@ export const api = _converse.api = { * @returns {boolean} */ isType (type) { - if (type.toLowerCase() === 'websocket') { - return _converse.connection._proto instanceof Strophe.Websocket; - } else if (type.toLowerCase() === 'bosh') { - return Strophe.BOSH && _converse.connection._proto instanceof Strophe.Bosh; - } + return _converse.connection.isType(type); } }, @@ -508,8 +500,15 @@ export const api = _converse.api = { * fails to restore a previous auth'd session. */ async login (jid, password, automatic=false) { - if (jid || _converse.jid) { - jid = await _converse.setUserJID(jid || _converse.jid); + jid = jid || _converse.jid; + if (!_converse.connection?.jid || (jid && !u.isSameDomain(_converse.connection.jid, jid))) { + await _converse.initConnection(); + } + if (api.settings.get("connection_options")?.worker && (await _converse.connection.restoreWorkerSession())) { + return; + } + if (jid) { + jid = await _converse.setUserJID(jid); } // See whether there is a BOSH session to re-attach to @@ -521,7 +520,6 @@ export const api = _converse.api = { return _converse.startNewPreboundBOSHSession(); } } - password = password || api.settings.get("password"); const credentials = (jid && password) ? { jid, password } : null; attemptNonPreboundSession(credentials, automatic); @@ -546,7 +544,7 @@ export const api = _converse.api = { promise.resolve(); } - _converse.setDisconnectionCause(_converse.LOGOUT, undefined, true); + _converse.connection.setDisconnectionCause(_converse.LOGOUT, undefined, true); if (_converse.connection !== undefined) { api.listen.once('disconnected', () => complete()); _converse.connection.disconnect(); @@ -933,16 +931,6 @@ function replacePromise (name) { } } -_converse.haveResumed = function () { - if (_converse.api.connection.isType('bosh')) { - return _converse.connfeedback.get('connection_status') === Strophe.Status.ATTACHED; - } else { - // XXX: Not binding means that the session was resumed. - // This seems very fragile. Perhaps a better way is possible. - return !_converse.connection.do_bind; - } -} - _converse.isUniView = function () { /* We distinguish between UniView and MultiView instances. * @@ -984,11 +972,13 @@ function initPersistentStorage () { } -_converse.createStore = function (id, storage) { +function createStore (id, storage) { const s = _converse.storage[storage ? storage : _converse.config.get('storage')]; return new Storage(id, s); } +_converse.createStore = createStore; + function initPlugins () { // If initialize gets called a second time (e.g. during tests), then we @@ -1046,7 +1036,7 @@ function initClientConfig () { 'trusted': _converse.api.settings.get("trusted") && true || false, 'storage': _converse.api.settings.get("trusted") ? 'persistent' : 'session' }); - _converse.config.browserStorage = _converse.createStore(id, "session"); + _converse.config.browserStorage = createStore(id, "session"); _converse.config.fetch(); /** * Triggered once the XMPP-client configuration has been initialized. @@ -1061,7 +1051,7 @@ function initClientConfig () { } -async function tearDown () { +export async function tearDown () { await _converse.api.trigger('beforeTearDown', {'synchronous': true}); window.removeEventListener('click', _converse.onUserActivity); window.removeEventListener('focus', _converse.onUserActivity); @@ -1075,7 +1065,8 @@ async function tearDown () { async function attemptNonPreboundSession (credentials, automatic) { - if (_converse.api.settings.get("authentication") === _converse.LOGIN) { + const { api } = _converse; + if (api.settings.get("authentication") === _converse.LOGIN) { // XXX: If EITHER ``keepalive`` or ``auto_login`` is ``true`` and // ``authentication`` is set to ``login``, then Converse will try to log the user in, // since we don't have a way to distinguish between wether we're @@ -1112,12 +1103,7 @@ function connect (credentials) { if (!_converse.connection.reconnecting) { _converse.connection.reset(); } - _converse.connection.connect( - _converse.jid.toLowerCase(), - null, - _converse.onConnectStatusChanged, - BOSH_WAIT - ); + _converse.connection.connect(_converse.jid.toLowerCase()); } else if (_converse.api.settings.get("authentication") === _converse.LOGIN) { const password = credentials ? credentials.password : (_converse.connection?.pass || _converse.api.settings.get("password")); if (!password) { @@ -1125,51 +1111,25 @@ function connect (credentials) { throw new Error("autoLogin: If you use auto_login and "+ "authentication='login' then you also need to provide a password."); } - _converse.setDisconnectionCause(Strophe.Status.AUTHFAIL, undefined, true); + _converse.connection.setDisconnectionCause(Strophe.Status.AUTHFAIL, undefined, true); _converse.api.connection.disconnect(); return; } if (!_converse.connection.reconnecting) { _converse.connection.reset(); } - _converse.connection.connect(_converse.jid, password, _converse.onConnectStatusChanged, BOSH_WAIT); + _converse.connection.connect(_converse.jid, password); } } -async function reconnect () { - log.debug('RECONNECTING: the connection has dropped, attempting to reconnect.'); - _converse.setConnectionStatus( - Strophe.Status.RECONNECTING, - __('The connection has dropped, attempting to reconnect.') - ); - /** - * Triggered when the connection has dropped, but Converse will attempt - * to reconnect again. - * - * @event _converse#will-reconnect - */ - _converse.api.trigger('will-reconnect'); - - _converse.connection.reconnecting = true; - await tearDown(); - return _converse.api.user.login(); -} - -const debouncedReconnect = debounce(reconnect, 2000); - - _converse.shouldClearCache = () => (!_converse.config.get('trusted') || _converse.isTestEnv()); -function clearSession () { - if (_converse.session !== undefined) { - _converse.session.destroy(); - delete _converse.session; - } - if (_converse.shouldClearCache()) { - _converse.api.user.settings.clear(); - } +export function clearSession () { + _converse.session?.destroy(); + delete _converse.session; + _converse.shouldClearCache() && _converse.api.user.settings.clear(); /** * Synchronouse event triggered once the user session has been cleared, * for example when the user has logged out or when Converse has @@ -1180,77 +1140,31 @@ function clearSession () { } -async function onDomainDiscovered (response) { - const text = await response.text(); - const xrd = (new window.DOMParser()).parseFromString(text, "text/xml").firstElementChild; - if (xrd.nodeName != "XRD" || xrd.namespaceURI != "http://docs.oasis-open.org/ns/xri/xrd-1.0") { - return log.warn("Could not discover XEP-0156 connection methods"); - } - const bosh_links = sizzle(`Link[rel="urn:xmpp:alt-connections:xbosh"]`, xrd); - const ws_links = sizzle(`Link[rel="urn:xmpp:alt-connections:websocket"]`, xrd); - const bosh_methods = bosh_links.map(el => el.getAttribute('href')); - const ws_methods = ws_links.map(el => el.getAttribute('href')); - // TODO: support multiple endpoints - _converse.api.settings.set("websocket_url", ws_methods.pop()); - _converse.api.settings.set('bosh_service_url', bosh_methods.pop()); - if (bosh_methods.length === 0 && ws_methods.length === 0) { - log.warn( - "onDomainDiscovered: neither BOSH nor WebSocket connection methods have been specified with XEP-0156." - ); - } -} +_converse.initConnection = function () { + const api = _converse.api; - -async function discoverConnectionMethods (domain) { - // Use XEP-0156 to check whether this host advertises websocket or BOSH connection methods. - const options = { - 'mode': 'cors', - 'headers': { - 'Accept': 'application/xrd+xml, text/xml' - } - }; - const url = `https://${domain}/.well-known/host-meta`; - let response; - try { - response = await fetch(url, options); - } catch (e) { - log.error(`Failed to discover alternative connection methods at ${url}`); - log.error(e); - return; - } - if (response.status >= 200 && response.status < 400) { - await onDomainDiscovered(response); - } else { - log.warn("Could not discover XEP-0156 connection methods"); - } -} - - -_converse.initConnection = async function (domain) { - if (_converse.api.settings.get("discover_connection_methods")) { - await discoverConnectionMethods(domain); - } - if (! _converse.api.settings.get('bosh_service_url')) { - if (_converse.api.settings.get("authentication") === _converse.PREBIND) { + if (! api.settings.get('bosh_service_url')) { + if (api.settings.get("authentication") === _converse.PREBIND) { throw new Error("authentication is set to 'prebind' but we don't have a BOSH connection"); } - if (! _converse.api.settings.get("websocket_url")) { + if (! api.settings.get("websocket_url")) { throw new Error("initConnection: you must supply a value for either the bosh_service_url or websocket_url or both."); } } - if (('WebSocket' in window || 'MozWebSocket' in window) && _converse.api.settings.get("websocket_url")) { - _converse.connection = new Strophe.Connection( - _converse.api.settings.get("websocket_url"), - Object.assign(_converse.default_connection_options, _converse.api.settings.get("connection_options")) + const XMPPConnection = _converse.isTestEnv() ? MockConnection : Connection; + if (('WebSocket' in window || 'MozWebSocket' in window) && api.settings.get("websocket_url")) { + _converse.connection = new XMPPConnection( + api.settings.get("websocket_url"), + Object.assign(_converse.default_connection_options, api.settings.get("connection_options")) ); - } else if (_converse.api.settings.get('bosh_service_url')) { - _converse.connection = new Strophe.Connection( - _converse.api.settings.get('bosh_service_url'), + } else if (api.settings.get('bosh_service_url')) { + _converse.connection = new XMPPConnection( + api.settings.get('bosh_service_url'), Object.assign( _converse.default_connection_options, - _converse.api.settings.get("connection_options"), - {'keepalive': _converse.api.settings.get("keepalive")} + api.settings.get("connection_options"), + {'keepalive': api.settings.get("keepalive")} ) ); } else { @@ -1259,23 +1173,28 @@ _converse.initConnection = async function (domain) { } setUpXMLLogging(); /** - * Triggered once the `Strophe.Connection` constructor has been initialized, which + * Triggered once the `Connection` constructor has been initialized, which * will be responsible for managing the connection to the XMPP server. * * @event _converse#connectionInitialized */ - _converse.api.trigger('connectionInitialized'); + api.trigger('connectionInitialized'); } async function initSession (jid) { + const is_shared_session = api.settings.get('connection_options').worker; + const bare_jid = Strophe.getBareJidFromJid(jid).toLowerCase(); const id = `converse.session-${bare_jid}`; - if (!_converse.session || _converse.session.get('id') !== id) { + if (_converse.session?.get('id') !== id) { _converse.session = new Model({id}); - _converse.session.browserStorage = _converse.createStore(id, "session"); + _converse.session.browserStorage = createStore(id, is_shared_session ? "persistent" : "session"); await new Promise(r => _converse.session.fetch({'success': r, 'error': r})); - if (_converse.session.get('active')) { + + if (!is_shared_session && _converse.session.get('active')) { + // If the `active` flag is set, it means this tab was cloned from + // another (e.g. via middle-click), and its session data was copied over. _converse.session.clear(); _converse.session.save({id}); } @@ -1297,7 +1216,7 @@ async function initSession (jid) { function saveJIDtoSession (jid) { jid = _converse.session.get('jid') || jid; if (_converse.api.settings.get("authentication") !== _converse.ANONYMOUS && !Strophe.getResourceFromJid(jid)) { - jid = jid.toLowerCase() + _converse.generateResource(); + jid = jid.toLowerCase() + Connection.generateResource(); } _converse.jid = jid; _converse.bare_jid = Strophe.getBareJidFromJid(jid); @@ -1308,6 +1227,9 @@ function saveJIDtoSession (jid) { 'bare_jid': _converse.bare_jid, 'resource': _converse.resource, 'domain': _converse.domain, + // We use the `active` flag to determine whether we should use the values from sessionStorage. + // When "cloning" a tab (e.g. via middle-click), the `active` flag will be set and we'll create + // a new empty user session, otherwise it'll be false and we can re-use the user session. 'active': true }); // Set JID on the connection object so that when we call `connection.bind` @@ -1330,10 +1252,6 @@ function saveJIDtoSession (jid) { * @params { String } jid */ _converse.setUserJID = async function (jid) { - if (!_converse.connection || !u.isSameDomain(_converse.connection.jid, jid)) { - const domain = Strophe.getDomainFromJid(jid) - await _converse.initConnection(domain); - } await initSession(jid); /** * Triggered whenever the user's JID has been updated @@ -1405,95 +1323,6 @@ function cleanup () { _converse.off(); } -_converse.generateResource = () => `/converse.js-${Math.floor(Math.random()*139749528).toString()}`; - - -/** - * Callback method called by Strophe as the Strophe.Connection goes - * through various states while establishing or tearing down a - * connection. - * @method _converse#onConnectStatusChanged - * @private - * @memberOf _converse - */ -_converse.onConnectStatusChanged = function (status, message) { - log.debug(`Status changed to: ${_converse.CONNECTION_STATUS[status]}`); - if (status === Strophe.Status.CONNECTED || status === Strophe.Status.ATTACHED) { - _converse.setConnectionStatus(status); - // By default we always want to send out an initial presence stanza. - _converse.send_initial_presence = true; - _converse.setDisconnectionCause(); - if (_converse.connection.reconnecting) { - log.debug(status === Strophe.Status.CONNECTED ? 'Reconnected' : 'Reattached'); - onConnected(true); - } else { - log.debug(status === Strophe.Status.CONNECTED ? 'Connected' : 'Attached'); - if (_converse.connection.restored) { - // No need to send an initial presence stanza when - // we're restoring an existing session. - _converse.send_initial_presence = false; - } - onConnected(); - } - } else if (status === Strophe.Status.DISCONNECTED) { - _converse.setDisconnectionCause(status, message); - _converse.onDisconnected(); - } else if (status === Strophe.Status.BINDREQUIRED) { - _converse.bindResource(); - } else if (status === Strophe.Status.ERROR) { - _converse.setConnectionStatus( - status, - __('An error occurred while connecting to the chat server.') - ); - } else if (status === Strophe.Status.CONNECTING) { - _converse.setConnectionStatus(status); - } else if (status === Strophe.Status.AUTHENTICATING) { - _converse.setConnectionStatus(status); - } else if (status === Strophe.Status.AUTHFAIL) { - if (!message) { - message = __('Your XMPP address and/or password is incorrect. Please try again.'); - } - _converse.setConnectionStatus(status, message); - _converse.setDisconnectionCause(status, message, true); - _converse.onDisconnected(); - } else if (status === Strophe.Status.CONNFAIL) { - let feedback = message; - if (message === "host-unknown" || message == "remote-connection-failed") { - feedback = __("Sorry, we could not connect to the XMPP host with domain: %1$s", - `\"${Strophe.getDomainFromJid(_converse.connection.jid)}\"`); - } else if (message !== undefined && message === Strophe?.ErrorCondition?.NO_AUTH_MECH) { - feedback = __("The XMPP server did not offer a supported authentication mechanism"); - } - _converse.setConnectionStatus(status, feedback); - _converse.setDisconnectionCause(status, message); - } else if (status === Strophe.Status.DISCONNECTING) { - _converse.setDisconnectionCause(status, message); - } -}; - - -_converse.setConnectionStatus = function (connection_status, message) { - _converse.connfeedback.set({ - 'connection_status': connection_status, - 'message': message - }); -}; - - -/** - * Used to keep track of why we got disconnected, so that we can - * decide on what the next appropriate action is (in onDisconnected) - */ -_converse.setDisconnectionCause = function (cause, reason, override) { - if (cause === undefined) { - delete _converse.disconnection_cause; - delete _converse.disconnection_reason; - } else if (_converse.disconnection_cause === undefined || override) { - _converse.disconnection_cause = cause; - _converse.disconnection_reason = reason; - } -}; - function enableCarbons () { /* Ask the XMPP server to enable Message Carbons @@ -1519,65 +1348,7 @@ function enableCarbons () { _converse.connection.send(carbons_iq); } - -async function onConnected (reconnecting) { - /* Called as soon as a new connection has been established, either - * by logging in or by attaching to an existing BOSH session. - */ - delete _converse.connection.reconnecting; - _converse.connection.flush(); // Solves problem of returned PubSub BOSH response not received by browser - await _converse.setUserJID(_converse.connection.jid); - - /** - * Synchronous event triggered after we've sent an IQ to bind the - * user's JID resource for this session. - * @event _converse#afterResourceBinding - */ - await api.trigger('afterResourceBinding', reconnecting, {'synchronous': true}); - enableCarbons(); - - if (reconnecting) { - /** - * After the connection has dropped and converse.js has reconnected. - * Any Strophe stanza handlers (as registered via `converse.listen.stanza`) will - * have to be registered anew. - * @event _converse#reconnected - * @example _converse.api.listen.on('reconnected', () => { ... }); - */ - api.trigger('reconnected'); - } else { - /** - * Triggered once converse.js has been initialized. - * See also {@link _converse#event:pluginsInitialized}. - * @event _converse#initialized - */ - api.trigger('initialized'); - /** - * Triggered after the connection has been established and Converse - * has got all its ducks in a row. - * @event _converse#initialized - */ - api.trigger('connected'); - } -} - - -async function finishDisconnection () { - // Properly tear down the session so that it's possible to manually connect again. - log.debug('DISCONNECTED'); - delete _converse.connection.reconnecting; - _converse.connection.reset(); - tearDown(); - await clearSession(); - delete _converse.connection; - /** - * Triggered after converse.js has disconnected from the XMPP server. - * @event _converse#disconnected - * @memberOf _converse - * @example _converse.api.listen.on('disconnected', () => { ... }); - */ - api.trigger('disconnected'); -} +api.listen.on('afterResourceBinding', () => enableCarbons()); function fetchLoginCredentials (wait=0) { @@ -1659,55 +1430,6 @@ function unregisterGlobalEventHandlers () { } -/** - * Gets called once strophe's status reaches Strophe.Status.DISCONNECTED. - * Will either start a teardown process for converse.js or attempt - * to reconnect. - * @method onDisconnected - * @private - * @memberOf _converse - */ -_converse.onDisconnected = function () { - if (api.settings.get("auto_reconnect")) { - const reason = _converse.disconnection_reason; - if (_converse.disconnection_cause === Strophe.Status.AUTHFAIL) { - if (api.settings.get("credentials_url") || api.settings.get("authentication") === _converse.ANONYMOUS) { - // If `credentials_url` is set, we reconnect, because we might - // be receiving expirable tokens from the credentials_url. - // - // If `authentication` is anonymous, we reconnect because we - // might have tried to attach with stale BOSH session tokens - // or with a cached JID and password - return api.connection.reconnect(); - } else { - return finishDisconnection(); - } - } else if ( - _converse.disconnection_cause === _converse.LOGOUT || - reason === Strophe.ErrorCondition.NO_AUTH_MECH || - reason === "host-unknown" || - reason === "remote-connection-failed" - ) { - return finishDisconnection(); - } - api.connection.reconnect(); - } else { - return finishDisconnection(); - } -}; - - -_converse.bindResource = async function () { - /** - * Synchronous event triggered before we send an IQ to bind the user's - * JID resource for this session. - * @event _converse#beforeResourceBinding - */ - await api.trigger('beforeResourceBinding', {'synchronous': true}); - _converse.connection.bind(); -}; - - _converse.ConnectionFeedback = Model.extend({ defaults: { 'connection_status': Strophe.Status.DISCONNECTED, diff --git a/src/headless/converse-mam.js b/src/headless/converse-mam.js index 012013efc..ebb978da2 100644 --- a/src/headless/converse-mam.js +++ b/src/headless/converse-mam.js @@ -146,8 +146,8 @@ converse.plugins.add('converse-mam', { _converse.onMAMError = function (iq) { - if (iq && iq.querySelectorAll('feature-not-implemented').length) { - log.warn(`Message Archive Management (XEP-0313) not supported by ${iq.getAttribute('from')}`); + if (iq?.querySelectorAll('feature-not-implemented').length) { + log.warn(`Message Archive Management (XEP-0313) not supported by ${iq.getAttribute('from')}`); } else { log.error(`Error while trying to set archiving preferences for ${iq.getAttribute('from')}.`); log.error(iq); diff --git a/src/headless/converse-roster.js b/src/headless/converse-roster.js index e5e08d5ce..56808434a 100644 --- a/src/headless/converse-roster.js +++ b/src/headless/converse-roster.js @@ -960,7 +960,7 @@ converse.plugins.add('converse-roster', { // When reconnecting and not resuming a previous session, // we clear all cached presence data, since it might be stale // and we'll receive new presence updates - !_converse.haveResumed() && await clearPresences(); + !_converse.connection.hasResumed() && await clearPresences(); } else { _converse.presences = new _converse.Presences(); const id = `converse.presences-${_converse.bare_jid}`; diff --git a/src/headless/package-lock.json b/src/headless/package-lock.json index ef97c4466..c70d29717 100644 --- a/src/headless/package-lock.json +++ b/src/headless/package-lock.json @@ -4,40 +4,27 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@converse/skeletor": { + "version": "github:conversejs/skeletor#b260c554f4ce961c29deea4740083e58a489aa9b", + "from": "github:conversejs/skeletor#b260c554f4ce961c29deea4740083e58a489aa9b", + "dev": true, + "requires": { + "lit-html": "^1.2.1", + "lodash-es": "^4.17.14" + } + }, + "abab": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.3.tgz", + "integrity": "sha512-tsFzPpcttalNjFBCFMqsKYQcWxxen1pgJR56by//QwvJc4/OUS3kPOOttx2tSIfjsylB0pYu7f5D3K1RCxUnUg==", + "dev": true + }, "filesize": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz", "integrity": "sha512-LpCHtPQ3sFx67z+uh2HnSyWSLLu5Jxo21795uRDuar/EOuYWXib5EmPaGIBuSnRqH2IODiKA2k5re/K9OnN/Yg==", "dev": true }, - "fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "requires": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "dependencies": { - "jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", - "dev": true, - "requires": { - "graceful-fs": "^4.1.6" - } - } - } - }, - "graceful-fs": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", - "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==", - "dev": true - }, "immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -50,16 +37,6 @@ "integrity": "sha1-elSbvZ/+FYWwzQoZHiAwVb7ldLQ=", "dev": true }, - "jsonfile": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz", - "integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==", - "dev": true, - "requires": { - "graceful-fs": "^4.1.6", - "universalify": "^0.1.2" - } - }, "lie": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", @@ -69,6 +46,12 @@ "immediate": "~3.0.5" } }, + "lit-html": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-1.2.1.tgz", + "integrity": "sha512-GSJHHXMGLZDzTRq59IUfL9FCdAlGfqNp/dEa7k7aBaaWD+JKaCjsAk9KYm2V12ItonVaYx2dprN66Zdm1AuBTQ==", + "dev": true + }, "localforage": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.7.3.tgz", @@ -81,6 +64,7 @@ "lodash": { "version": "4.17.19", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", + "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", "dev": true }, "lodash-es": { @@ -95,32 +79,32 @@ "integrity": "sha512-SBt6v6Tbp20Jf8hU0cpcc/+HBHGMY8/Q+yA6Ih0tBQE8tfdZ6U4PRG0iNvUUjLx/hVyOP53n0UfGBymlfaaXCg==", "dev": true, "requires": { - "lodash": "^4.17.19" + "lodash": "^4.17.11" } }, - "twemoji": { - "version": "12.1.5", - "resolved": "https://registry.npmjs.org/twemoji/-/twemoji-12.1.5.tgz", - "integrity": "sha512-B0PBVy5xomwb1M/WZxf/IqPZfnoIYy1skXnlHjMwLwTNfZ9ljh8VgWQktAPcJXu8080WoEh6YwQGPVhDVqvrVQ==", + "strophe.js": { + "version": "github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f", + "from": "github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f", "dev": true, "requires": { - "fs-extra": "^8.0.1", - "jsonfile": "^5.0.0", - "twemoji-parser": "12.1.3", - "universalify": "^0.1.2" + "abab": "^2.0.3", + "ws": "^7.0.0", + "xmldom": "^0.1.27" } }, - "twemoji-parser": { - "version": "12.1.3", - "resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-12.1.3.tgz", - "integrity": "sha512-ND4LZXF4X92/PFrzSgGkq6KPPg8swy/U0yRw1k/+izWRVmq1HYi3khPwV3XIB6FRudgVICAaBhJfW8e8G3HC7Q==", - "dev": true + "ws": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.3.1.tgz", + "integrity": "sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA==", + "dev": true, + "optional": true }, - "universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true + "xmldom": { + "version": "0.1.31", + "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz", + "integrity": "sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==", + "dev": true, + "optional": true } } } diff --git a/src/headless/package.json b/src/headless/package.json index 70fb4a312..8299b028d 100644 --- a/src/headless/package.json +++ b/src/headless/package.json @@ -42,6 +42,6 @@ "localforage": "^1.7.3", "lodash-es": "^4.17.15", "pluggable.js": "2.0.1", - "strophe.js": "1.3.6" + "strophe.js": "strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f" } } diff --git a/src/templates/spinner.js b/src/templates/spinner.js index f9c26155f..310d93736 100644 --- a/src/templates/spinner.js +++ b/src/templates/spinner.js @@ -1,3 +1,3 @@ import { html } from "lit-html"; -export default (o) => html`` +export default (o={}) => html`` diff --git a/webpack.common.js b/webpack.common.js index 049f3b983..c0b860eaf 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -89,7 +89,7 @@ module.exports = { ] }, { test: /\.js$/, - exclude: /(node_modules|spec|mockup)/, + include: /src/, use: { loader: 'babel-loader', options: { diff --git a/webpack.html b/webpack.html index a413cf5f0..fde6657f6 100644 --- a/webpack.html +++ b/webpack.html @@ -28,6 +28,7 @@ modtools_disable_query: ['moderator', 'participant', 'visitor'], enable_smacks: true, i18n: 'en', + // connection_options: { 'worker': '/dist/shared-connection-worker.js' }, message_archiving: 'always', muc_domain: 'conference.chat.example.org', muc_respect_autojoin: true, diff --git a/webpack.prod.js b/webpack.prod.js index c93ce729d..e6ba66d9b 100644 --- a/webpack.prod.js +++ b/webpack.prod.js @@ -20,6 +20,7 @@ module.exports = merge(common, { new MiniCssExtractPlugin({filename: '../dist/converse.min.css'}), new CopyWebpackPlugin({ patterns: [ + {from: 'src/headless/node_modules/strophe.js/src/shared-connection-worker.js', to: 'shared-connection-worker.js'}, {from: 'sounds', to: 'sounds'}, {from: 'images/favicon.ico', to: 'images/favicon.ico'}, {from: 'images/custom_emojis', to: 'images/custom_emojis'},