Explorar o código

Add support for XEP-0198 Stream Management

- New plugin `converse-smacks`
- New config option `enable_smacks`
- Rename session cache id from `converse.bosh-session` to `converse.session`
- Refactor logout and login as consistently used api methods
- Refactor session cache to store per JID

Fixes #316
JC Brand %!s(int64=7) %!d(string=hai) anos
pai
achega
7b11d85503

+ 1 - 1
.travis.yml

@@ -4,7 +4,7 @@ cache:
     directories:
         - node_modules
 addons:
-  chrome: unstable
+  chrome: stable
 node_js:
  - "10"
 install: make stamp-npm

+ 6 - 5
CHANGES.md

@@ -15,15 +15,15 @@
 - Message deduplication bugfixes and improvements
 - Continuously retry (in 2s intervals) to fetch login credentials (via [credentials_url](https://conversejs.org/docs/html/configuration.html#credentials-url)) in case of failure
 - Replace `moment` with [DayJS](https://github.com/iamkun/dayjs).
-- New API method [\_converse.api.disco.features.get](https://conversejs.org/docs/html/api/-_converse.api.disco.features.html#.get)
-- New config setting [muc_show_join_leave_status](https://conversejs.org/docs/html/configuration.html#muc-show-join-leave-status)
+- New config option [enable_smacks](https://conversejs.org/docs/html/configuration.html#enable-smacks).
+- New config option [muc_show_join_leave_status](https://conversejs.org/docs/html/configuration.html#muc-show-join-leave-status)
 - New config option [singleton](https://conversejs.org/docs/html/configuration.html#singleton).
   By setting this option to `false` and `view_mode` to `'embedded'`, it's now possible to
   "embed" the full app and not just a single chat. To embed just a single chat, it's now
   necessary to explicitly set `singleton` to `true`.
-- New event: `chatBoxBlurred`.
 - New event: [chatBoxBlurred](https://conversejs.org/docs/html/api/-_converse.html#event:chatBoxBlurred)
 - New event: [chatReconnected](https://conversejs.org/docs/html/api/-_converse.html#event:chatReconnected)
+- #316: Add support for XEP-0198 Stream Management
 - #1296: `embedded` view mode shows `chatbox-navback` arrow in header
 - #1465: When highlighting a roster contact, they're incorrectly shown as online
 - #1532: Converse reloads on enter pressed in the filter box
@@ -34,14 +34,14 @@
 - #1576: Converse gets stuck with spinner when logging out with `auto_login` set to `true`
 - #1586: Not possible to kick someone with a space in their nickname
 
-- **Breaking changes**:
+### Breaking changes
+
 - Rename `muc_disable_moderator_commands` to [muc_disable_slash_commands](https://conversejs.org/docs/html/configuration.html#muc-disable-slash-commands).
 - `_converse.api.archive.query` now returns a Promise instead of accepting a callback functions.
 - `_converse.api.disco.supports` now returns a Promise which resolves to a Boolean instead of an Array.
 - The `forward_messages` config option (which was set to `false` by default) has been removed.
   Use [message_carbons](https://conversejs.org/docs/html/configuration.html#message-carbons) instead.
 
-
 ### API changes
 
 - `_converse.chats.open` and `_converse.rooms.open` now take a `force`
@@ -51,6 +51,7 @@
 - `_converse.api.emit` has been removed in favor of [\_converse.api.trigger](https://conversejs.org/docs/html/api/-_converse.api.html#.trigger)
 - `_converse.updateSettings` has been removed in favor of [\_converse.api.settings.update](https://conversejs.org/docs/html/api/-_converse.api.settings.html#.update)
 - `_converse.api.roster.get` now returns a promise.
+- New API method [\_converse.api.disco.features.get](https://conversejs.org/docs/html/api/-_converse.api.disco.features.html#.get)
 
 ## 4.2.0 (2019-04-04)
 

+ 4 - 3
dev.html

@@ -25,14 +25,15 @@
         //     'prosody@conference.prosody.im',
         //     'jdev@conference.jabber.org'
         // ],
-        // websocket_url: 'ws://chat.example.org:5280/xmpp-websocket',
+        // bosh_service_url: 'http://chat.example.org:5280/http-bind/',
+        websocket_url: 'wss://conversejs.org/xmpp-websocket',
+        bosh_service_url: 'https://conversejs.org/http-bind/', // Please use this connection manager only for testing purposes
         view_mode: 'fullscreen',
         notify_all_room_messages: [
             'discuss@conference.conversejs.org'
         ],
+        enable_smacks: true,
         muc_respect_autojoin: false,
-        // bosh_service_url: 'http://chat.example.org:5280/http-bind/',
-        bosh_service_url: 'https://conversejs.org/http-bind/', // Please use this connection manager only for testing purposes
         message_archiving: 'always',
         debug: true
     });

+ 19 - 0
docs/source/configuration.rst

@@ -635,6 +635,15 @@ The app servers are specified with the `push_app_servers`_ option.
     Registering a push app server against a MUC domain is not (yet) standardized
     and this feature should be considered experimental.
 
+enable_smacks
+-------------
+
+* Default: ``false``
+
+Determines whether `XEP-0198 Stream Management <https://xmpp.org/extensions/xep-0198.html>`_
+support is turned on or not.
+
+
 expose_rid_and_sid
 ------------------
 
@@ -1376,6 +1385,16 @@ want to embed a chat into the page.
 Alternatively you could use it with `view_mode`_ set to ``overlayed`` to create
 a single helpdesk-type chat.
 
+
+smacks_max_unacked_stanzas
+--------------------------
+
+* Default: ``5``
+
+This setting relates to `XEP-0198 <https://xmpp.org/extensions/xep-0198.html>`_
+and determines the number of stanzas to be sent before Converse will ask the
+server for acknowledgement of those stanzas.
+
 sounds_path
 -----------
 

+ 75 - 29
docs/source/setup.rst

@@ -71,8 +71,8 @@ and a list of servers that you can set up yourself on `xmpp.org <https://xmpp.or
 
 .. _`BOSH-section`:
 
-BOSH
-====
+BOSH (XMPP-over-HTTP)
+=====================
 
 Web-browsers do not allow the persistent, direct TCP socket connections used by
 desktop XMPP clients to communicate with XMPP servers.
@@ -113,26 +113,8 @@ use it in production.
 Refer to the :ref:`bosh-service-url` configuration setting for information on
 how to configure Converse to connect to a BOSH URL.
 
-
-.. _`websocket-section`:
-
-Websocket
-=========
-
-Websockets provide an alternative means of connection to an XMPP server from
-your browser.
-
-Websockets provide long-lived, bidirectional connections which do not rely on
-HTTP. Therefore BOSH, which operates over HTTP, doesn't apply to websockets.
-
-`Prosody <http://prosody.im>`_ (from version 0.10) and `Ejabberd <http://www.ejabberd.im>`_ support websocket connections, as
-does the node-xmpp-bosh connection manager.
-
-Refer to the :ref:`websocket-url` configuration setting for information on how to
-configure Converse to connect to a websocket URL.
-
-The Webserver
-=============
+Configuring your webserver for BOSH
+-----------------------------------
 
 Lets say the domain under which you host Converse is *example.org:80*,
 but the domain of your connection manager or the domain of
@@ -149,7 +131,7 @@ There are two ways in which you can solve this problem.
 .. _CORS:
 
 1. Cross-Origin Resource Sharing (CORS)
----------------------------------------
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 CORS is a technique for overcoming browser restrictions related to the
 `same-origin security policy <https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy>`_.
@@ -158,8 +140,8 @@ CORS is enabled by adding an ``Access-Control-Allow-Origin`` header. Where this
 is configured depends on what webserver is used for your file upload server.
 
 
-2. Reverse-proxy 
-----------------
+2. Reverse-proxy
+~~~~~~~~~~~~~~~~
 
 Another possible solution is to add a reverse proxy to a webserver such as Nginx or Apache to ensure that
 all services you use are hosted under the same domain name and port.
@@ -177,7 +159,7 @@ the cross-domain restriction is ``mysite.com/http-bind`` and not
 Your ``nginx`` or ``apache`` configuration will look as follows:
 
 Nginx
-~~~~~
+^^^^^
 
 .. code-block:: nginx
 
@@ -202,7 +184,7 @@ Nginx
     }
 
 Apache
-~~~~~~
+^^^^^^
 
 .. code-block:: apache
 
@@ -227,7 +209,7 @@ Apache
     the above example).
 
     This might be because your webserver and BOSH proxy have the same timeout
-    for BOSH requests. Because the webserver receives the request slightly earlier, 
+    for BOSH requests. Because the webserver receives the request slightly earlier,
     it gives up a few microseconds before the XMPP server’s empty result and thus returns a
     504 error page containing HTML to browser, which then gets parsed as if its
     XML.
@@ -239,6 +221,70 @@ Apache
     this problem.
 
 
+
+.. _`websocket-section`:
+
+Websocket
+=========
+
+Websockets provide an alternative means of connection to an XMPP server from
+your browser.
+
+Websockets provide long-lived, bidirectional connections which do not rely on
+HTTP. Therefore BOSH, which operates over HTTP, doesn't apply to websockets.
+
+`Prosody <http://prosody.im>`_ (from version 0.10) and `Ejabberd <http://www.ejabberd.im>`_ support websocket connections, as
+does the node-xmpp-bosh connection manager.
+
+Refer to the :ref:`websocket-url` configuration setting for information on how to
+configure Converse to connect to a websocket URL.
+
+
+Reverse-proxy for a websocket connection
+----------------------------------------
+
+Assuming your website is accessible on port ``443`` on the domain ``mysite.com``
+and your XMPP server's websocket server is running at ``localhost:5280/xmpp-websocket``.
+
+You can then set up your webserver as an SSL enabled reverse proxy  in front of
+your websocket endpoint.
+
+The :ref:`websocket-url` value you'll want to pass in to ``converse.initialize`` is ``wss://mysite.com/xmpp-websocket``.
+
+Your ``nginx`` will look as follows:
+
+.. code-block:: nginx
+
+    http {
+        server {
+            listen       443
+            server_name  mysite.com;
+            ssl on;
+            ssl_certificate /path/to/fullchain.pem;    # Properly set the path here
+            ssl_certificate_key /path/to/privkey.pem;    # Properly set the path here
+
+            location = / {
+                root    /path/to/converse.js/;  # Properly set the path here
+                index   index.html;
+            }
+            location /xmpp-websocket {
+                proxy_http_version 1.1;
+                proxy_pass http://127.0.0.1:5280;
+                proxy_buffering off;
+                proxy_set_header Host $host;
+                proxy_set_header Upgrade $http_upgrade;
+                proxy_set_header Connection "upgrade";
+                proxy_read_timeout 86400;
+            }
+            # CORS
+            location ~ .(ttf|ttc|otf|eot|woff|woff2|font.css|css|js)$ {
+                add_header Access-Control-Allow-Origin "*"; # Decide here whether you want to allow all or only a particular domain
+                root   /path/to/converse.js/;  # Properly set the path here
+            }
+        }
+    }
+
+
 .. _`session-support`:
 
 Single Session Support
@@ -353,7 +399,7 @@ If your web-application has access to the same credentials, it can send those
 credentials to Converse so that user's are automatically logged in when the
 page loads.
 
-This is can be done by setting :ref:`auto_login` to true and configuring the 
+This is can be done by setting :ref:`auto_login` to true and configuring the
 the :ref:`credentials_url` setting.
 
 Option 3). Temporary authentication tokens

+ 2 - 2
package-lock.json

@@ -13702,8 +13702,8 @@
       }
     },
     "strophe.js": {
-      "version": "github:strophe/strophejs#44da5faca8baa61c691739d63af8b1dea1d2436c",
-      "from": "github:strophe/strophejs#44da5faca8baa61c691739d63af8b1dea1d2436c"
+      "version": "github:strophe/strophejs#f52f26e8cc23f738b7b39180a7ee4511ccd41526",
+      "from": "github:strophe/strophejs#f52f26e8cc23f738b7b39180a7ee4511ccd41526"
     },
     "style-loader": {
       "version": "0.23.1",

+ 2 - 2
spec/converse.js

@@ -32,7 +32,7 @@
                     delete _converse.jid;
                     _converse.keepalive = true;
                     _converse.authentication = "prebind";
-                    expect(_converse.logIn.bind(_converse)).toThrow(
+                    expect(_converse.api.user.login.bind(_converse)).toThrow(
                         new Error(
                             "restoreBOSHSession: tried to restore a \"keepalive\" session "+
                             "but we don't have the JID for the user!"));
@@ -47,7 +47,7 @@
                     delete _converse.jid;
                     _converse.keepalive = false;
                     _converse.authentication = "prebind";
-                    expect(_converse.logIn.bind(_converse)).toThrow(
+                    expect(_converse.api.user.login.bind(_converse)).toThrow(
                         new Error("attemptPreboundSession: If you use prebind and not keepalive, then you MUST supply JID, RID and SID values or a prebind_url."));
                     _converse.bosh_service_url = undefined;
                     _converse.jid = jid;

+ 21 - 25
spec/login.js

@@ -9,37 +9,33 @@
                 null, ['connectionInitialized', 'chatBoxesInitialized'],
                 { auto_login: false,
                   allow_registration: false },
-                function (done, _converse) {
+                async function (done, _converse) {
 
-            test_utils.waitUntil(() => _converse.chatboxviews.get('controlbox'))
-            .then(function () {
-                var cbview = _converse.chatboxviews.get('controlbox');
-                test_utils.openControlBox();
-                const checkboxes = cbview.el.querySelectorAll('input[type="checkbox"]');
-                expect(checkboxes.length).toBe(1);
+            test_utils.openControlBox();
+            const cbview = await test_utils.waitUntil(() => _converse.chatboxviews.get('controlbox'));
+            const checkboxes = cbview.el.querySelectorAll('input[type="checkbox"]');
+            expect(checkboxes.length).toBe(1);
 
-                const checkbox = checkboxes[0];
-                const label = cbview.el.querySelector(`label[for="${checkbox.getAttribute('id')}"]`);
-                expect(label.textContent).toBe('This is a trusted device');
-                expect(checkbox.checked).toBe(true);
-
-                cbview.el.querySelector('input[name="jid"]').value = 'dummy@localhost';
-                cbview.el.querySelector('input[name="password"]').value = 'secret';
+            const checkbox = checkboxes[0];
+            const label = cbview.el.querySelector(`label[for="${checkbox.getAttribute('id')}"]`);
+            expect(label.textContent).toBe('This is a trusted device');
+            expect(checkbox.checked).toBe(true);
 
-                spyOn(cbview.loginpanel, 'connect');
-                cbview.delegateEvents();
+            cbview.el.querySelector('input[name="jid"]').value = 'dummy@localhost';
+            cbview.el.querySelector('input[name="password"]').value = 'secret';
 
-                expect(_converse.config.get('storage')).toBe('local');
-                cbview.el.querySelector('input[type="submit"]').click();
-                expect(_converse.config.get('storage')).toBe('local');
-                expect(cbview.loginpanel.connect).toHaveBeenCalled();
+            spyOn(cbview.loginpanel, 'connect');
+            cbview.delegateEvents();
 
+            expect(_converse.config.get('storage')).toBe('local');
+            cbview.el.querySelector('input[type="submit"]').click();
+            expect(_converse.config.get('storage')).toBe('local');
+            expect(cbview.loginpanel.connect).toHaveBeenCalled();
 
-                checkbox.click();
-                cbview.el.querySelector('input[type="submit"]').click();
-                expect(_converse.config.get('storage')).toBe('session');
-                done();
-            });
+            checkbox.click();
+            cbview.el.querySelector('input[type="submit"]').click();
+            expect(_converse.config.get('storage')).toBe('session');
+            done();
         }));
 
         it("checkbox can be set to false by default",

+ 1 - 1
spec/protocol.js

@@ -273,7 +273,7 @@
                         'name': 'Nicky'});
                 _converse.connection._dataRecv(test_utils.createRequest(stanza));
                 // Check that the IQ set was acknowledged.
-                expect(sent_stanza.toLocaleString()).toBe( // Strophe adds the xmlns attr (although not in spec)
+                expect(Strophe.serialize(sent_stanza)).toBe( // Strophe adds the xmlns attr (although not in spec)
                     `<iq from="dummy@localhost/resource" id="${IQ_id}" type="result" xmlns="jabber:client"/>`
                 );
                 expect(_converse.roster.updateContact).toHaveBeenCalled();

+ 34 - 11
spec/push.js

@@ -5,6 +5,8 @@
     const $iq = converse.env.$iq;
     const Strophe = converse.env.Strophe;
     const _ = converse.env._;
+    const sizzle = converse.env.sizzle;
+    const u = converse.env.utils;
 
     describe("XEP-0357 Push Notifications", function () {
 
@@ -56,31 +58,52 @@
                     }]
                 }, async function (done, _converse) {
 
-            const IQ_stanzas = _converse.connection.IQ_stanzas,
-                  room_jid = 'coven@chat.shakespeare.lit';
-            expect(_converse.session.get('push_enabled')).toBeFalsy();
-
-            test_utils.openAndEnterChatRoom(_converse, 'coven', 'chat.shakespeare.lit', 'oldhag');
+            const IQ_stanzas = _converse.connection.IQ_stanzas;
+            const room_jid = 'coven@chat.shakespeare.lit';
             await test_utils.waitUntilDiscoConfirmed(
                 _converse, _converse.push_app_servers[0].jid,
                 [{'category': 'pubsub', 'type':'push'}],
                 ['urn:xmpp:push:0'], [], 'info');
+            await test_utils.waitUntilDiscoConfirmed(
+                _converse, _converse.bare_jid, [],
+                ['urn:xmpp:push:0']);
+
+            let iq = await test_utils.waitUntil(() => _.filter(
+                IQ_stanzas,
+                iq => sizzle(`iq[type="set"] enable[xmlns="${Strophe.NS.PUSH}"]`, iq).length
+            ).pop());
+
+            expect(Strophe.serialize(iq)).toBe(
+                `<iq id="${iq.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+                    `<enable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0"/>`+
+                `</iq>`
+            );
+            const result = u.toStanza(`<iq type="result" id="${iq.getAttribute('id')}" to="dummy@localhost" />`);
+            _converse.connection._dataRecv(test_utils.createRequest(result));
+
+            await test_utils.waitUntil(() => _converse.session.get('push_enabled'));
+            expect(_converse.session.get('push_enabled').length).toBe(1);
+            expect(_.includes(_converse.session.get('push_enabled'), 'dummy@localhost')).toBe(true);
+
+            test_utils.openAndEnterChatRoom(_converse, 'coven', 'chat.shakespeare.lit', 'oldhag');
             await test_utils.waitUntilDiscoConfirmed(
                 _converse, 'chat.shakespeare.lit',
                 [{'category': 'account', 'type':'registered'}],
                 ['urn:xmpp:push:0'], [], 'info');
-            const stanza = await test_utils.waitUntil(
-                () => _.filter(IQ_stanzas, (iq) => iq.querySelector('iq[type="set"] enable[xmlns="urn:xmpp:push:0"]')).pop()
-            );
-            expect(Strophe.serialize(stanza)).toEqual(
-                `<iq id="${stanza.getAttribute('id')}" to="chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
+            iq = await test_utils.waitUntil(() => _.filter(
+                IQ_stanzas,
+                iq => sizzle(`iq[type="set"][to="chat.shakespeare.lit"] enable[xmlns="${Strophe.NS.PUSH}"]`, iq).length
+            ).pop());
+
+            expect(Strophe.serialize(iq)).toEqual(
+                `<iq id="${iq.getAttribute('id')}" to="chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
                     '<enable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0"/>'+
                 '</iq>'
             );
             _converse.connection._dataRecv(test_utils.createRequest($iq({
                 'to': _converse.connection.jid,
                 'type': 'result',
-                'id': stanza.getAttribute('id')
+                'id': iq.getAttribute('id')
             })));
             await test_utils.waitUntil(() => _.includes(_converse.session.get('push_enabled'), 'chat.shakespeare.lit'));
             done();

+ 128 - 0
spec/smacks.js

@@ -0,0 +1,128 @@
+(function (root, factory) {
+    define(["jasmine", "mock", "test-utils"], factory);
+} (this, function (jasmine, mock, test_utils) {
+    "use strict";
+    const $iq = converse.env.$iq;
+    const Strophe = converse.env.Strophe;
+    const sizzle = converse.env.sizzle;
+    const u = converse.env.utils;
+
+    describe("XEP-0198 Stream Management", function () {
+
+        it("gets enabled with an <enable> stanza and resumed with a <resume> stanza",
+            mock.initConverse(
+                null, ['connectionInitialized', 'chatBoxesInitialized'],
+                { 'auto_login': false,
+                  'enable_smacks': true,
+                  'show_controlbox_by_default': true,
+                  'smacks_max_unacked_stanzas': 2
+                },
+                async function (done, _converse) {
+
+            const view = _converse.chatboxviews.get('controlbox');
+            spyOn(view, 'renderControlBoxPane').and.callThrough();
+
+            _converse.api.user.login('dummy@localhost', 'secret');
+            const sent_stanzas = _converse.connection.sent_stanzas;
+            let stanza = await test_utils.waitUntil(() =>
+                sent_stanzas.filter(s => (s.tagName === 'enable')).pop());
+
+            expect(_converse.session.get('smacks_enabled')).toBe(false);
+            expect(Strophe.serialize(stanza)).toEqual('<enable resume="true" xmlns="urn:xmpp:sm:3"/>');
+
+            let result = u.toStanza(`<enabled xmlns="urn:xmpp:sm:3" id="some-long-sm-id" resume="true"/>`);
+            _converse.connection._dataRecv(test_utils.createRequest(result));
+            expect(_converse.session.get('smacks_enabled')).toBe(true);
+
+            await test_utils.waitUntil(() => view.renderControlBoxPane.calls.count());
+
+            let IQ_stanzas = _converse.connection.IQ_stanzas;
+            await test_utils.waitUntil(() => IQ_stanzas.length === 4);
+
+            let iq = IQ_stanzas.pop();
+            expect(Strophe.serialize(iq)).toBe(
+                `<iq from="dummy@localhost/resource" id="${iq.getAttribute('id')}" to="dummy@localhost" type="get" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/disco#info"/></iq>`);
+
+            iq = IQ_stanzas.pop();
+            expect(Strophe.serialize(iq)).toBe(
+                `<iq id="${iq.getAttribute('id')}" type="get" xmlns="jabber:client"><query xmlns="jabber:iq:roster"/></iq>`);
+
+            iq = IQ_stanzas.pop();
+            expect(Strophe.serialize(iq)).toBe(
+                `<iq from="dummy@localhost/resource" id="${iq.getAttribute('id')}" to="localhost" type="get" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/disco#info"/></iq>`);
+
+            const disco_iq = IQ_stanzas.pop();
+            expect(Strophe.serialize(disco_iq)).toBe(
+                `<iq from="dummy@localhost" id="${disco_iq.getAttribute('id')}" to="dummy@localhost" type="get" xmlns="jabber:client">`+
+                    `<pubsub xmlns="http://jabber.org/protocol/pubsub"><items node="eu.siacs.conversations.axolotl.devicelist"/></pubsub></iq>`);
+
+            expect(sent_stanzas.filter(s => (s.nodeName === 'r')).length).toBe(2);
+            expect(_converse.session.get('unacked_stanzas').length).toBe(4);
+
+            // test handling of acks
+            let ack = u.toStanza(`<a xmlns="urn:xmpp:sm:3" h="1"/>`);
+            _converse.connection._dataRecv(test_utils.createRequest(ack));
+            expect(_converse.session.get('unacked_stanzas').length).toBe(3);
+
+            // test handling of ack requests
+            let r = u.toStanza(`<r xmlns="urn:xmpp:sm:3"/>`);
+            _converse.connection._dataRecv(test_utils.createRequest(r));
+            ack = await test_utils.waitUntil(() => sent_stanzas.filter(s => (s.nodeName === 'a')).pop());
+            expect(Strophe.serialize(ack)).toBe('<a h="0" xmlns="urn:xmpp:sm:3"/>');
+
+            const disco_result = $iq({
+                'type': 'result',
+                'from': 'localhost',
+                'to': 'dummy@localhost/resource',
+                'id': disco_iq.getAttribute('id'),
+            }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
+                .c('identity', {
+                    'category': 'server',
+                    'type': 'im'
+                }).up()
+                .c('feature', {'var': 'http://jabber.org/protocol/disco#info'}).up()
+                .c('feature', {'var': 'http://jabber.org/protocol/disco#items'});
+            _converse.connection._dataRecv(test_utils.createRequest(disco_result));
+
+            ack = u.toStanza(`<a xmlns="urn:xmpp:sm:3" h="2"/>`);
+            _converse.connection._dataRecv(test_utils.createRequest(ack));
+            expect(_converse.session.get('unacked_stanzas').length).toBe(2);
+
+            r = u.toStanza(`<r xmlns="urn:xmpp:sm:3"/>`);
+            _converse.connection._dataRecv(test_utils.createRequest(r));
+            ack = await test_utils.waitUntil(() => sent_stanzas.filter(s => (s.nodeName === 'a' && s.getAttribute('h') === '1')).pop());
+            expect(Strophe.serialize(ack)).toBe('<a h="1" xmlns="urn:xmpp:sm:3"/>');
+
+            // test session resumption
+            _converse.connection.IQ_stanzas = [];
+            IQ_stanzas = _converse.connection.IQ_stanzas;
+            _converse.api.connection.reconnect();
+            stanza = await test_utils.waitUntil(() =>
+                sent_stanzas.filter(s => (s.tagName === 'resume')).pop());
+            expect(Strophe.serialize(stanza)).toEqual('<resume h="2" previd="some-long-sm-id" xmlns="urn:xmpp:sm:3"/>');
+
+            result = u.toStanza(`<resumed xmlns="urn:xmpp:sm:3" h="another-sequence-number" previd="some-long-sm-id"/>`);
+            _converse.connection._dataRecv(test_utils.createRequest(result));
+
+            // Another <enable> stanza doesn't get sent out
+            expect(sizzle('enable', sent_stanzas).length).toBe(0);
+            expect(_converse.session.get('smacks_enabled')).toBe(true);
+
+            await test_utils.waitUntil(() => IQ_stanzas.length === 2);
+
+            // Test that unacked stanzas get resent out
+            iq = IQ_stanzas.pop();
+            expect(Strophe.serialize(iq)).toBe(
+                `<iq from="dummy@localhost/resource" id="${iq.getAttribute('id')}" to="dummy@localhost" type="get" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/disco#info"/></iq>`);
+
+            iq = IQ_stanzas.pop();
+            expect(Strophe.serialize(iq)).toBe(
+                `<iq id="${iq.getAttribute('id')}" type="get" xmlns="jabber:client"><query xmlns="jabber:iq:roster"/></iq>`);
+
+            done();
+        }));
+    });
+}));

+ 2 - 2
spec/spoilers.js

@@ -117,7 +117,7 @@
             });
             await new Promise((resolve, reject) => view.once('messageInserted', resolve));
 
-            /* Test the XML stanza 
+            /* Test the XML stanza
              *
              * <message from="dummy@localhost/resource"
              *          to="max.frankfurter@localhost"
@@ -194,7 +194,7 @@
             });
             await new Promise((resolve, reject) => view.once('messageInserted', resolve));
 
-            /* Test the XML stanza 
+            /* Test the XML stanza
              *
              * <message from="dummy@localhost/resource"
              *          to="max.frankfurter@localhost"

+ 3 - 12
src/converse-controlbox.js

@@ -438,8 +438,7 @@ converse.plugins.add('converse-controlbox', {
                  */
                 if (ev && ev.preventDefault) { ev.preventDefault(); }
                 if (_converse.authentication === _converse.ANONYMOUS) {
-                    this.connect(_converse.jid, null);
-                    return;
+                    return this.connect(_converse.jid, null);
                 }
                 if (!this.validate()) { return; }
 
@@ -467,24 +466,16 @@ converse.plugins.add('converse-controlbox', {
                 } else if (_converse.default_domain && !_.includes(jid, '@')) {
                     jid = jid + '@' + _converse.default_domain;
                 }
-                this.connect(jid, form_data.get('password'));
+               this.connect(jid, form_data.get('password'));
             },
 
             connect (jid, password) {
-                if (jid) {
-                    const resource = Strophe.getResourceFromJid(jid);
-                    if (!resource) {
-                        jid = jid.toLowerCase() + _converse.generateResource();
-                    } else {
-                        jid = Strophe.getBareJidFromJid(jid).toLowerCase()+'/'+resource;
-                    }
-                }
                 if (_.includes(["converse/login", "converse/register"],
                         Backbone.history.getFragment())) {
                     _converse.router.navigate('', {'replace': true});
                 }
                 _converse.connection.reset();
-                _converse.connection.connect(jid, password, _converse.onConnectStatusChanged);
+                _converse.api.user.login(jid, password);
             }
         });
 

+ 1 - 1
src/converse-profile.js

@@ -285,7 +285,7 @@ converse.plugins.add('converse-profile', {
                 ev.preventDefault();
                 const result = confirm(__("Are you sure you want to log out?"));
                 if (result === true) {
-                    _converse.logOut();
+                    _converse.api.user.logout();
                 }
             },
 

+ 2 - 3
src/converse-push.js

@@ -106,9 +106,9 @@ converse.plugins.add('converse-push', {
             }
             const enabled_services = _.reject(_converse.push_app_servers, 'disable');
             const disabled_services = _.filter(_converse.push_app_servers, 'disable');
+            const enabled = _.map(enabled_services, _.partial(enablePushAppServer, domain));
+            const disabled = _.map(disabled_services, _.partial(disablePushAppServer, domain));
             try {
-                const enabled = _.map(enabled_services, _.partial(enablePushAppServer, domain));
-                const disabled = _.map(disabled_services, _.partial(disablePushAppServer, domain));
                 await Promise.all(enabled.concat(disabled));
             } catch (e) {
                 _converse.log('Could not enable or disable push App Server', Strophe.LogLevel.ERROR);
@@ -118,7 +118,6 @@ converse.plugins.add('converse-push', {
             }
             _converse.session.save('push_enabled', push_enabled);
         }
-
         _converse.api.listen.on('statusInitialized', () => enablePush());
 
         function onChatBoxAdded (model) {

+ 155 - 130
src/headless/converse-core.js

@@ -102,6 +102,7 @@ _converse.core_plugins = [
     'converse-pubsub',
     'converse-roster',
     'converse-rsm',
+    'converse-smacks',
     'converse-vcard'
 ];
 
@@ -190,7 +191,7 @@ _converse.CHATROOMS_TYPE = 'chatroom';
 _converse.HEADLINES_TYPE = 'headline';
 _converse.CONTROLBOX_TYPE = 'controlbox';
 
-_converse.default_connection_options = {};
+_converse.default_connection_options = {'explicitResourceBinding': true};
 
 // Default configuration values
 // ----------------------------
@@ -304,8 +305,9 @@ _converse.__ = function (str) {
 const __ = _converse.__;
 
 const PROMISES = [
-    'initialized',
+    'afterResourceBinding',
     'connectionInitialized',
+    'initialized',
     'pluginsInitialized',
     'statusInitialized'
 ];
@@ -405,6 +407,34 @@ function initClientConfig () {
     _converse.api.trigger('clientConfigInitialized');
 }
 
+
+function clearSession  () {
+    if (!_.isUndefined(_converse.bosh_session)) {
+       _converse.bosh_session.destroy();
+       delete _converse.bosh_session;
+    }
+    if (!_.isUndefined(_converse.session)) {
+       _converse.session.destroy();
+       delete _converse.session;
+    }
+
+    // TODO: Refactor so that we don't clear
+    if (!_converse.config.get('trusted') || isTestEnv()) {
+        window.localStorage.clear();
+        window.sessionStorage.clear();
+    } else {
+        _.get(_converse, 'bosh_session.browserStorage', {'_clear': _.noop})._clear();
+        _.get(_converse, 'session.browserStorage', {'_clear': _.noop})._clear();
+    }
+    /**
+     * Triggered once the session information has been cleared,
+     * for example when the user has logged out or when Converse has
+     * disconnected for some other reason.
+     * @event _converse#clearSession
+     */
+    _converse.api.trigger('clearSession');
+}
+
 _converse.initConnection = function () {
     /* Creates a new Strophe.Connection instance if we don't already have one.
      */
@@ -437,28 +467,76 @@ _converse.initConnection = function () {
 }
 
 
-async function initSession () {
+async function initBOSHSession () {
    const id = 'converse.bosh-session';
-   _converse.session = new Backbone.Model({id});
-   _converse.session.browserStorage = new BrowserStorage.session(id);
+   _converse.bosh_session = new Backbone.Model({id});
+   _converse.bosh_session.browserStorage = new BrowserStorage.session(id);
    try {
-      await new Promise((success, error) => _converse.session.fetch({success, error}));
-      if (_converse.jid && !u.isSameBareJID(_converse.session.get('jid'), _converse.jid)) {
-         _converse.session.clear({'silent': true});
-         _converse.session.save({'jid': _converse.jid, id});
+      await new Promise((success, error) => _converse.bosh_session.fetch({success, error}));
+      if (_converse.jid && !u.isSameBareJID(_converse.bosh_session.get('jid'), _converse.jid)) {
+         _converse.bosh_session.clear({'silent': true});
+         _converse.bosh_session.save({'jid': _converse.jid, id});
       }
    } catch (e) {
       if (_converse.jid) {
-         _converse.session.save({'jid': _converse.jid});
+         _converse.bosh_session.save({'jid': _converse.jid});
       }
    }
    /**
     * Triggered once the session has been initialized. The session is a
     * persistent object which stores session information in the browser storage.
-    * @event _converse#sessionInitialized
+    * @event _converse#BOSHSessionInitialized
     * @memberOf _converse
     */
-   _converse.api.trigger('sessionInitialized');
+   _converse.api.trigger('BOSHSessionInitialized');
+}
+
+async function initUserSession (jid) {
+   const bare_jid = Strophe.getBareJidFromJid(jid);
+   const id = `converse.session-${bare_jid}`;
+   if (!_converse.session || _converse.session.get('id') !== id) {
+      _converse.session = new Backbone.Model({id});
+      _converse.session.browserStorage = new BrowserStorage.session(id);
+      await new Promise(r => _converse.session.fetch({'success': r, 'error': r}));
+      /**
+       * Triggered once the user's session has been initialized. The session is a
+       * cache which stores information about the user's current session.
+       * @event _converse#userSessionInitialized
+       * @memberOf _converse
+       */
+      _converse.api.trigger('userSessionInitialized');
+   }
+}
+
+function setUserJID (jid) {
+   initUserSession(jid);
+   _converse.jid = jid;
+   _converse.bare_jid = Strophe.getBareJidFromJid(jid);
+   _converse.resource = Strophe.getResourceFromJid(jid);
+   _converse.domain = Strophe.getDomainFromJid(jid);
+   _converse.session.save({
+      'jid': jid,
+      'bare_jid': _converse.bare_jid,
+      'resource': _converse.resource,
+      'domain': _converse.domain
+   });
+}
+
+
+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.
+   */
+   _converse.connection.flush(); // Solves problem of returned PubSub BOSH response not received by browser
+   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 _converse.api.trigger('afterResourceBinding', {'synchronous': true});
+   _converse.enableCarbons();
+   _converse.initStatus(reconnecting)
 }
 
 
@@ -483,8 +561,8 @@ async function finishInitialization () {
     initClientConfig();
     initPlugins();
     _converse.initConnection();
-    await initSession();
-    _converse.logIn();
+    await initBOSHSession();
+    _converse.api.user.login();
     _converse.registerGlobalEventHandlers();
     if (!Backbone.history.started) {
         Backbone.history.start();
@@ -758,7 +836,7 @@ _converse.initialize = async function (settings, callback) {
 
         _converse.connection.reconnecting = true;
         _converse.tearDown();
-        _converse.logIn(null, true);
+        _converse.api.user.login(null, null, true);
     }, 2000);
 
 
@@ -773,7 +851,7 @@ _converse.initialize = async function (settings, callback) {
         delete _converse.connection.reconnecting;
         _converse.connection.reset();
         _converse.tearDown();
-        _converse.clearSession();
+        clearSession();
         /**
          * Triggered after converse.js has disconnected from the XMPP server.
          * @event _converse#disconnected
@@ -840,7 +918,7 @@ _converse.initialize = async function (settings, callback) {
             _converse.setDisconnectionCause();
             if (_converse.connection.reconnecting) {
                 _converse.log(status === Strophe.Status.CONNECTED ? 'Reconnected' : 'Reattached');
-                _converse.onConnected(true);
+                onConnected(true);
             } else {
                 _converse.log(status === Strophe.Status.CONNECTED ? 'Connected' : 'Attached');
                 if (_converse.connection.restored) {
@@ -848,7 +926,7 @@ _converse.initialize = async function (settings, callback) {
                     // we're restoring an existing session.
                     _converse.send_initial_presence = false;
                 }
-                _converse.onConnected();
+                onConnected();
             }
         } else if (status === Strophe.Status.DISCONNECTED) {
             _converse.setDisconnectionCause(status, message);
@@ -928,39 +1006,6 @@ _converse.initialize = async function (settings, callback) {
         }
     }
 
-    this.clearSession = function () {
-        if (!_converse.config.get('trusted') || isTestEnv()) {
-            window.localStorage.clear();
-            window.sessionStorage.clear();
-        } else {
-            _.get(_converse, 'session.browserStorage', {'_clear': _.noop})._clear();
-        }
-        /**
-         * Triggered once the session information has been cleared,
-         * for example when the user has logged out or when Converse has
-         * disconnected for some other reason.
-         * @event _converse#clearSession
-         */
-        _converse.api.trigger('clearSession');
-    };
-
-    this.logOut = function () {
-        _converse.clearSession();
-        _converse.setDisconnectionCause(_converse.LOGOUT, undefined, true);
-        if (!_.isUndefined(_converse.connection)) {
-            _converse.connection.disconnect();
-        } else {
-            _converse.tearDown();
-        }
-        // Recreate all the promises
-        Object.keys(_converse.promises).forEach(addPromise);
-        /**
-         * Triggered once the user has logged out.
-         * @event _converse#logout
-         */
-        _converse.api.trigger('logout');
-    };
-
     this.saveWindowState = function (ev) {
         // XXX: eventually we should be able to just use
         // document.visibilityState (when we drop support for older
@@ -1013,7 +1058,7 @@ _converse.initialize = async function (settings, callback) {
         /* Ask the XMPP server to enable Message Carbons
          * See XEP-0280 https://xmpp.org/extensions/xep-0280.html#enabling
          */
-        if (!this.message_carbons || this.session.get('carbons_enabled')) {
+        if (!this.message_carbons || !this.session || !this.session.get('carbons_enabled')) {
             return;
         }
         const carbons_iq = new Strophe.Builder('iq', {
@@ -1076,25 +1121,6 @@ _converse.initialize = async function (settings, callback) {
         }
     };
 
-    this.setUserJID = function () {
-        _converse.jid = _converse.connection.jid;
-        _converse.bare_jid = Strophe.getBareJidFromJid(_converse.connection.jid);
-        _converse.resource = Strophe.getResourceFromJid(_converse.connection.jid);
-        _converse.domain = Strophe.getDomainFromJid(_converse.connection.jid);
-        _converse.session.save({
-            'jid': _converse.connection.jid,
-            'bare_jid': Strophe.getBareJidFromJid(_converse.connection.jid),
-            'resource': Strophe.getResourceFromJid(_converse.connection.jid),
-            'domain': Strophe.getDomainFromJid(_converse.connection.jid)
-        });
-        /**
-         * Triggered once we have the user's full JID and it's been save in the
-         * session.
-         * @event _converse#setUserJID
-         */
-        _converse.api.trigger('setUserJID');
-    };
-
     this.bindResource = async function () {
         /**
          * Synchronous event triggered before we send an IQ to bind the user's
@@ -1105,17 +1131,6 @@ _converse.initialize = async function (settings, callback) {
         _converse.connection.bind();
     };
 
-    this.onConnected = function (reconnecting) {
-        /* Called as soon as a new connection has been established, either
-         * by logging in or by attaching to an existing BOSH session.
-         */
-        _converse.connection.flush(); // Solves problem of returned PubSub BOSH response not received by browser
-        _converse.setUserJID();
-        _converse.enableCarbons();
-        _converse.initStatus(reconnecting)
-    };
-
-
     this.ConnectionFeedback = Backbone.Model.extend({
         defaults: {
             'connection_status': Strophe.Status.DISCONNECTED,
@@ -1130,12 +1145,8 @@ _converse.initialize = async function (settings, callback) {
 
 
     this.XMPPStatus = Backbone.Model.extend({
-
-        defaults () {
-            return {
-                "jid": _converse.bare_jid,
-                "status":  _converse.default_state
-            }
+        defaults: {
+            "status":  _converse.default_state
         },
 
         initialize () {
@@ -1237,7 +1248,7 @@ _converse.initialize = async function (settings, callback) {
             return false;
         }
         /* Tries to restore a cached BOSH session. */
-        const jid = _converse.session.get('jid');
+        const jid = _converse.bosh_session.get('jid');
         if (!jid) {
             const msg = "restoreBOSHSession: tried to restore a \"keepalive\" session "+
                 "but we don't have the JID for the user!";
@@ -1256,7 +1267,7 @@ _converse.initialize = async function (settings, callback) {
                 _converse.log(
                     "Could not restore session for jid: "+
                     jid+" Error message: "+e.message, Strophe.LogLevel.WARN);
-                this.clearSession(); // We want to clear presences (see #555)
+                clearSession(); // We want to clear presences (see #555)
                 return false;
             }
         }
@@ -1323,11 +1334,6 @@ _converse.initialize = async function (settings, callback) {
     };
 
     this.autoLogin = function (credentials) {
-        if (credentials) {
-            // If passed in, the credentials come from credentials_url,
-            // so we set them on the converse object.
-            this.jid = credentials.jid;
-        }
         if (this.authentication === _converse.ANONYMOUS || this.authentication === _converse.EXTERNAL) {
             if (!this.jid) {
                 throw new Error("Config Error: when using anonymous login " +
@@ -1350,12 +1356,6 @@ _converse.initialize = async function (settings, callback) {
                 _converse.api.connection.disconnect();
                 return;
             }
-            const resource = Strophe.getResourceFromJid(this.jid);
-            if (!resource) {
-                this.jid = this.jid.toLowerCase() + _converse.generateResource();
-            } else {
-                this.jid = Strophe.getBareJidFromJid(this.jid).toLowerCase()+'/'+resource;
-            }
             if (!this.connection.reconnecting) {
                 this.connection.reset();
             }
@@ -1363,20 +1363,10 @@ _converse.initialize = async function (settings, callback) {
         }
     };
 
-    this.logIn = function (credentials, reconnecting) {
-        // We now try to resume or automatically set up a new session.
-        // Otherwise the user will be shown a login form.
-        if (this.authentication === _converse.PREBIND) {
-            this.attemptPreboundSession(reconnecting);
-        } else {
-            this.attemptNonPreboundSession(credentials, reconnecting);
-        }
-    };
-
     this.tearDown = function () {
         _converse.api.trigger('beforeTearDown');
-        if (!_.isUndefined(_converse.session)) {
-            _converse.session.destroy();
+        if (!_.isUndefined(_converse.bosh_session)) {
+            _converse.bosh_session.destroy();
         }
         window.removeEventListener('click', _converse.onUserActivity);
         window.removeEventListener('focus', _converse.onUserActivity);
@@ -1451,7 +1441,7 @@ _converse.api = {
                _converse.connection.disconnect();
             } else {
                _converse.tearDown();
-               _converse.clearSession();
+               clearSession();
             }
         },
     },
@@ -1473,7 +1463,7 @@ _converse.api = {
          /* Event emitter and promise resolver */
          const args = Array.from(arguments);
          const options = args.pop();
-         if (options.synchronous) {
+         if (options && options.synchronous) {
             const events = _converse._events[name] || [];
             await Promise.all(events.map(e => e.callback.call(e.ctx, args)));
          } else {
@@ -1507,31 +1497,59 @@ _converse.api = {
          * to log the user in by calling the `prebind_url` or `credentials_url` depending
          * on whether prebinding is used or not.
          *
+         * Otherwise the user will be shown a login form.
+         *
          * @method _converse.api.user.login
-         * @param {object} [credentials] An object with the credentials.
+         * @param {string} [jid]
+         * @param {string} [password]
+         * @param {boolean} [reconnecting]
          * @example
          * converse.plugins.add('myplugin', {
          *     initialize: function () {
-         *
-         *         this._converse.api.user.login({
-         *             'jid': 'dummy@example.com',
-         *             'password': 'secret'
-         *         });
-         *
+         *         this._converse.api.user.login('dummy@example.com', 'secret');
          *     }
          * });
          */
-        'login' (credentials) {
-            _converse.logIn(credentials);
+        login (jid, password, reconnecting) {
+            if (_converse.authentication === _converse.PREBIND) {
+               _converse.attemptPreboundSession(reconnecting);
+            } else {
+               let credentials;
+               if (jid) {
+                  const resource = Strophe.getResourceFromJid(jid);
+                  if (!resource) {
+                     jid = jid.toLowerCase() + _converse.generateResource();
+                  } else {
+                     jid = Strophe.getBareJidFromJid(jid).toLowerCase()+'/'+resource;
+                  }
+                  setUserJID(jid);
+                  credentials = {'jid': jid, 'password': password};
+               }
+               _converse.attemptNonPreboundSession(credentials, reconnecting);
+            }
         },
+
         /**
          * Logs the user out of the current XMPP session.
          *
          * @method _converse.api.user.logout
          * @example _converse.api.user.logout();
          */
-        'logout' () {
-            _converse.logOut();
+        logout () {
+            clearSession();
+            _converse.setDisconnectionCause(_converse.LOGOUT, undefined, true);
+            if (!_.isUndefined(_converse.connection)) {
+                  _converse.connection.disconnect();
+            } else {
+                  _converse.tearDown();
+            }
+            // Recreate all the promises
+            Object.keys(_converse.promises).forEach(addPromise);
+            /**
+             * Triggered once the user has logged out.
+             * @event _converse#logout
+             */
+            _converse.api.trigger('logout');
         },
         /**
          * Set and get the user's chat status, also called their *availability*.
@@ -1841,9 +1859,16 @@ _converse.api = {
      * });
      * _converse.api.send(msg);
      */
-    'send' (stanza) {
-        _converse.connection.send(stanza);
-        _converse.api.trigger('send', stanza);
+    send (stanza) {
+        if (_.isString(stanza)) {
+            stanza = u.toStanza(stanza);
+        }
+        if (stanza.tagName === 'iq') {
+           return _converse.api.sendIQ(stanza);
+        } else {
+           _converse.connection.send(stanza);
+           _converse.api.trigger('send', stanza);
+        }
     },
 
     /**
@@ -1852,7 +1877,7 @@ _converse.api = {
      * @returns {Promise} A promise which resolves when we receive a `result` stanza
      * or is rejected when we receive an `error` stanza.
      */
-    'sendIQ' (stanza, timeout) {
+    sendIQ (stanza, timeout) {
         return new Promise((resolve, reject) => {
             _converse.connection.sendIQ(stanza, resolve, reject, timeout || _converse.IQ_TIMEOUT);
             _converse.api.trigger('send', stanza);

+ 35 - 26
src/headless/converse-disco.js

@@ -22,6 +22,7 @@ converse.plugins.add('converse-disco', {
 
         // Promises exposed by this plugin
         _converse.api.promises.add('discoInitialized');
+        _converse.api.promises.add('streamFeaturesAdded');
 
 
         /**
@@ -260,32 +261,33 @@ converse.plugins.add('converse-disco', {
         }
 
         function initStreamFeatures () {
-            _converse.stream_features = new Backbone.Collection();
-            _converse.stream_features.browserStorage = new BrowserStorage.session(
-                `converse.stream-features-${_converse.bare_jid}`
-            );
-            _converse.stream_features.fetch({
-                success (collection) {
-                    if (collection.length === 0 && _converse.connection.features) {
-                        _.forEach(
-                            _converse.connection.features.childNodes,
-                            (feature) => {
-                                _converse.stream_features.create({
-                                    'name': feature.nodeName,
-                                    'xmlns': feature.getAttribute('xmlns')
+            const bare_jid = Strophe.getBareJidFromJid(_converse.jid);
+            const id = `converse.stream-features-${bare_jid}`;
+            if (!_converse.stream_features || _converse.stream_features.browserStorage.id !== id) {
+                _converse.stream_features = new Backbone.Collection();
+                _converse.stream_features.browserStorage = new BrowserStorage.session(id);
+                _converse.stream_features.fetch({
+                    success (collection) {
+                        if (collection.length === 0 && _converse.connection.features) {
+                            Array.from(_converse.connection.features.childNodes)
+                                .forEach(feature => {
+                                    _converse.stream_features.create({
+                                        'name': feature.nodeName,
+                                        'xmlns': feature.getAttribute('xmlns')
+                                    });
                                 });
-                            });
+                        }
+                        /**
+                         * Triggered as soon as Converse has processed the stream features as advertised by
+                         * the server. If you want to check whether a stream feature is supported before
+                         * proceeding, then you'll first want to wait for this event.
+                         * @event _converse#streamFeaturesAdded
+                         * @example _converse.api.listen.on('streamFeaturesAdded', () => { ... });
+                         */
+                        _converse.api.trigger('streamFeaturesAdded');
                     }
-                }
-            });
-            /**
-             * Triggered as soon as Converse has processed the stream features as advertised by
-             * the server. If you want to check whether a stream feature is supported before
-             * proceeding, then you'll first want to wait for this event.
-             * @event _converse#streamFeaturesAdded
-             * @example _converse.api.listen.on('streamFeaturesAdded', () => { ... });
-             */
-            _converse.api.trigger('streamFeaturesAdded');
+                });
+            }
         }
 
         async function initializeDisco () {
@@ -313,7 +315,9 @@ converse.plugins.add('converse-disco', {
             _converse.api.trigger('discoInitialized');
         }
 
-        _converse.api.listen.on('setUserJID', initStreamFeatures);
+        _converse.api.listen.on('userSessionInitialized', initStreamFeatures);
+        _converse.api.listen.on('beforeResourceBinding', initStreamFeatures);
+
         _converse.api.listen.on('reconnected', initializeDisco);
         _converse.api.listen.on('connected', initializeDisco);
 
@@ -326,6 +330,10 @@ converse.plugins.add('converse-disco', {
                 _converse.disco_entities.reset();
                 _converse.disco_entities.browserStorage._clear();
             }
+            if (_converse.stream_features) {
+                _converse.stream_features.reset();
+                _converse.stream_features.browserStorage._clear();
+            }
         });
 
         const plugin = this;
@@ -386,7 +394,8 @@ converse.plugins.add('converse-disco', {
                      * @param {String} xmlns The XML namespace
                      * @example _converse.api.disco.stream.getFeature('ver', 'urn:xmpp:features:rosterver')
                      */
-                    'getFeature': function (name, xmlns) {
+                    'getFeature': async function (name, xmlns) {
+                        await _converse.api.waitUntil('streamFeaturesAdded');
                         if (_.isNil(name) || _.isNil(xmlns)) {
                             throw new Error("name and xmlns need to be provided when calling disco.stream.getFeature");
                         }

+ 242 - 0
src/headless/converse-smacks.js

@@ -0,0 +1,242 @@
+// Converse.js
+// http://conversejs.org
+//
+// Copyright (c) The Converse.js developers
+// Licensed under the Mozilla Public License (MPLv2)
+
+/* This is a Converse.js plugin which add support for XEP-0198: Stream Management */
+
+import converse from "./converse-core";
+
+const { Strophe, $build, _ } = converse.env;
+const u = converse.env.utils;
+
+Strophe.addNamespace('SM', 'urn:xmpp:sm:3');
+
+
+converse.plugins.add('converse-smacks', {
+
+    initialize () {
+        const { _converse } = this;
+
+        // Configuration values for this plugin
+        // ====================================
+        // Refer to docs/source/configuration.rst for explanations of these
+        // configuration settings.
+        _converse.api.settings.update({
+            'enable_smacks': false,
+            'smacks_max_unacked_stanzas': 5,
+        });
+
+        function isStreamManagementSupported () {
+            return _converse.api.disco.stream.getFeature('sm', Strophe.NS.SM);
+        }
+
+        function handleAck (el) {
+            if (!_converse.session.get('smacks_enabled')) {
+                return true;
+            }
+            const handled = parseInt(el.getAttribute('h'), 10);
+            const last_known_handled = _converse.session.get('num_stanzas_handled_by_server');
+            const delta = handled - last_known_handled;
+
+            if (delta < 0) {
+                const err_msg = `New reported stanza count lower than previous. `+
+                    `New: ${handled} - Previous: ${last_known_handled}`
+                _converse.log(err_msg, Strophe.LogLevel.ERROR);
+            }
+            const unacked_stanzas = _converse.session.get('unacked_stanzas');
+            if (delta > unacked_stanzas.length) {
+                const err_msg =
+                    `Higher reported acknowledge count than unacknowledged stanzas. `+
+                    `Reported Acknowledged Count: ${delta} -`+
+                    `Unacknowledged Stanza Count: ${unacked_stanzas.length} -`+
+                    `New: ${handled} - Previous: ${last_known_handled}`
+                _converse.log(err_msg, Strophe.LogLevel.ERROR);
+            }
+            _converse.session.save({
+                'num_stanzas_handled_by_server': handled,
+                'num_stanzas_since_last_ack': 0,
+                'unacked_stanzas': unacked_stanzas.slice(delta)
+            });
+            return true;
+        }
+
+        function sendAck() {
+            if (_converse.session.get('smacks_enabled')) {
+                const h = _converse.session.get('num_stanzas_handled');
+                const stanza = u.toStanza(`<a xmlns="${Strophe.NS.SM}" h="${h}"/>`);
+                _converse.api.send(stanza);
+            }
+            return true;
+        }
+
+        function stanzaHandler (el) {
+            if (_converse.session.get('smacks_enabled')) {
+                if (u.isTagEqual(el, 'iq') || u.isTagEqual(el, 'presence') || u.isTagEqual(el, 'message'))  {
+                    const h = _converse.session.get('num_stanzas_handled');
+                    _converse.session.save('num_stanzas_handled', h+1);
+                }
+            }
+            return true;
+        }
+
+        function clearSessionData () {
+            _converse.session.save({
+                'smacks_enabled': false,
+                'num_stanzas_handled': 0,
+                'num_stanzas_handled_by_server': 0,
+                'num_stanzas_since_last_ack': 0,
+                'unacked_stanzas': []
+            });
+        }
+
+        function saveSessionData (el) {
+            const data = {'smacks_enabled': true};
+            if (['1', 'true'].includes(el.getAttribute('resume'))) {
+                data['smacks_stream_id'] = el.getAttribute('id');
+            }
+            _converse.session.save(data);
+            return true;
+        }
+
+        function onFailedStanza (el) {
+            if (el.querySelector('item-not-found')) {
+                // Stream resumption must happen before resource binding but
+                // enabling a new stream must happen after resource binding.
+                // Since resumption failed, we simply continue.
+                //
+                // After resource binding, sendEnableStanza will be called
+                // based on the afterResourceBinding event.
+                _converse.log('Could not resume previous SMACKS session, session id not found. '+
+                              'A new session will be established.', Strophe.LogLevel.WARN);
+            } else {
+                _converse.log('Failed to enable stream management', Strophe.LogLevel.ERROR);
+                _converse.log(el.outerHTML, Strophe.LogLevel.ERROR);
+            }
+            clearSessionData();
+            return true;
+        }
+
+        function resendUnackedStanzas () {
+            const stanzas = _converse.session.get('unacked_stanzas');
+            // We clear the unacked_stanzas array because it'll get populated
+            // again in `onStanzaSent`
+            _converse.session.save('unacked_stanzas', []);
+
+            // XXX: Currently we're resending *all* unacked stanzas, including
+            // IQ[type="get"] stanzas that longer have handlers (because the
+            // page reloaded or we reconnected, causing removal of handlers).
+            //
+            // *Side-note:* Is it necessary to clear handlers upon reconnection?
+            //
+            // I've considered not resending those stanzas, but then keeping
+            // track of what's been sent and ack'd and their order gets
+            // prohibitively complex.
+            //
+            // It's unclear how much of a problem this poses.
+            //
+            // Two possible solutions are running @converse/headless as a
+            // service worker or handling IQ[type="result"] stanzas
+            // differently, more like push stanzas, so that they don't need
+            // explicit handlers.
+            stanzas.forEach(s => _converse.api.send(s));
+        }
+
+        function onResumedStanza (el, resolve) {
+            saveSessionData(el);
+            handleAck(el);
+            resendUnackedStanzas();
+            _converse.connection.do_bind = false; // No need to bind our resource anymore
+            _converse.connection.authenticated = true;
+            _converse.connection._changeConnectStatus(Strophe.Status.CONNECTED, null);
+        }
+
+        async function sendResumeStanza () {
+            const promise = u.getResolveablePromise();
+            _converse.connection._addSysHandler(_.flow(onResumedStanza, promise.resolve), Strophe.NS.SM, 'resumed');
+            _converse.connection._addSysHandler(_.flow(onFailedStanza, promise.resolve), Strophe.NS.SM, 'failed');
+
+            const previous_id = _converse.session.get('smacks_stream_id');
+            const h = _converse.session.get('num_stanzas_handled_by_server');
+            const stanza = u.toStanza(`<resume xmlns="${Strophe.NS.SM}" h="${h}" previd="${previous_id}"/>`);
+            _converse.api.send(stanza);
+            _converse.connection.flush();
+            await promise;
+        }
+
+        async function sendEnableStanza () {
+            if (!_converse.enable_smacks || _converse.session.get('smacks_enabled')) {
+                return;
+            }
+            if (await isStreamManagementSupported()) {
+                const promise = u.getResolveablePromise();
+                _converse.connection._addSysHandler(_.flow(saveSessionData, promise.resolve), Strophe.NS.SM, 'enabled');
+                _converse.connection._addSysHandler(_.flow(onFailedStanza, promise.resolve), Strophe.NS.SM, 'failed');
+
+                const stanza = u.toStanza(`<enable xmlns="${Strophe.NS.SM}" resume="true"/>`);
+                _converse.api.send(stanza);
+                _converse.connection.flush();
+                await promise;
+            }
+        }
+
+        async function enableStreamManagement () {
+            if (!_converse.enable_smacks) {
+                return;
+            }
+            if (!(await isStreamManagementSupported())) {
+                return;
+            }
+            _converse.connection.addHandler(stanzaHandler);
+            _converse.connection.addHandler(sendAck, Strophe.NS.SM, 'r');
+            _converse.connection.addHandler(handleAck, Strophe.NS.SM, 'a');
+
+            if (_converse.connection._proto instanceof Strophe.Bosh &&
+                    _converse.connfeedback.get('connection_status') === Strophe.Status.ATTACHED) {
+                // No need to continue further when we have an existing BOSH session,
+                // since our existing session still exists server-side.
+                return;
+            }
+
+            if (_converse.session.get('smacks_stream_id')) {
+                await sendResumeStanza();
+            } else {
+                clearSessionData();
+            }
+        }
+
+        function onStanzaSent (stanza) {
+            if (!_converse.session) {
+                _converse.log('No _converse.session!', Strophe.LogLevel.WARN);
+                return;
+            }
+            if (!_converse.session.get('smacks_enabled')) {
+                return;
+            }
+            if (u.isTagEqual(stanza, 'iq') ||
+                    u.isTagEqual(stanza, 'presence') ||
+                    u.isTagEqual(stanza, 'message')) {
+
+                const stanza_string = Strophe.serialize(stanza);
+                _converse.session.save(
+                    'unacked_stanzas',
+                    _converse.session.get('unacked_stanzas').concat([stanza_string])
+                );
+                const max_unacked = _converse.smacks_max_unacked_stanzas;
+                if (max_unacked > 0) {
+                    const num = _converse.session.get('num_stanzas_since_last_ack') + 1;
+                    if (num % max_unacked === 0) {
+                        // Request confirmation of sent stanzas
+                        _converse.api.send(u.toStanza(`<r xmlns="${Strophe.NS.SM}"/>`));
+                    }
+                    _converse.session.save({'num_stanzas_since_last_ack': num});
+                }
+            }
+        }
+
+        _converse.api.listen.on('beforeResourceBinding', enableStreamManagement);
+        _converse.api.listen.on('afterResourceBinding', sendEnableStanza);
+        _converse.api.listen.on('send', onStanzaSent);
+    }
+});

+ 6 - 8
src/headless/converse-vcard.js

@@ -55,7 +55,7 @@ converse.plugins.add('converse-vcard', {
             model: _converse.VCard,
 
             initialize () {
-                this.on('add', (vcard) => _converse.api.vcard.update(vcard));
+                this.on('add', vcard => _converse.api.vcard.update(vcard));
             }
         });
 
@@ -125,19 +125,17 @@ converse.plugins.add('converse-vcard', {
             _converse.vcards.browserStorage = new BrowserStorage[_converse.config.get('storage')](id);
             _converse.vcards.fetch();
         }
-        _converse.api.listen.on('setUserJID', _converse.initVCardCollection);
+        _converse.api.listen.on('afterResourceBinding', _converse.initVCardCollection);
 
 
         _converse.api.listen.on('statusInitialized', () => {
             const vcards = _converse.vcards;
-            const jid = _converse.xmppstatus.get('jid');
+            const jid = _converse.session.get('bare_jid');
             _converse.xmppstatus.vcard = vcards.findWhere({'jid': jid}) || vcards.create({'jid': jid});
         });
 
 
-        _converse.api.listen.on('addClientFeatures', () => {
-            _converse.api.disco.own.features.add(Strophe.NS.VCARD);
-        });
+        _converse.api.listen.on('addClientFeatures', () => _converse.api.disco.own.features.add(Strophe.NS.VCARD));
 
         /************************ BEGIN API ************************/
         Object.assign(_converse.api, {
@@ -191,7 +189,7 @@ converse.plugins.add('converse-vcard', {
                  *     );
                  * });
                  */
-                 'get' (model, force) {
+                 get (model, force) {
                     if (_.isString(model)) {
                         return getVCard(_converse, model);
                     } else if (force ||
@@ -224,7 +222,7 @@ converse.plugins.add('converse-vcard', {
                  *     _converse.api.vcard.update(chatbox);
                  * });
                  */
-                'update' (model, force) {
+                update (model, force) {
                     return this.get(model, force)
                         .then(vcard => {
                             delete vcard['stanza']

+ 1 - 0
src/headless/headless.js

@@ -12,6 +12,7 @@ import "./converse-ping";        // XEP-0199 XMPP Ping
 import "./converse-pubsub";      // XEP-0199 XMPP Ping
 import "./converse-roster";      // Contacts Roster
 import "./converse-rsm";         // XEP-0059 Result Set management
+import "./converse-smacks";      // XEP-0198 Stream Management
 import "./converse-vcard";       // XEP-0054 VCard-temp
 /* END: Removable components */
 

+ 1 - 1
src/headless/package.json

@@ -29,7 +29,7 @@
     "jed": "1.1.1",
     "lodash": "^4.17.11",
     "pluggable.js": "2.0.1",
-    "strophe.js": "strophe/strophejs#44da5faca8baa61c691739d63af8b1dea1d2436c",
+    "strophe.js": "strophe/strophejs#f52f26e8cc23f738b7b39180a7ee4511ccd41526",
     "twemoji": "^11.0.1",
     "urijs": "^1.19.1"
   }

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

@@ -21,6 +21,18 @@ import sizzle from "sizzle";
  */
 const u = {};
 
+u.isTagEqual = function (stanza, name) {
+    if (stanza.nodeTree) {
+        return u.isTagEqual(stanza.nodeTree, name);
+    } else if (!(stanza instanceof Element)) {
+        throw Error(
+            "isTagEqual called with value which isn't "+
+            "an element or Strophe.Builder instance");
+    } else {
+        return Strophe.isTagEqual(stanza, name);
+    }
+}
+
 u.toStanza = function (string) {
     return Strophe.xmlHtmlNode(string).firstElementChild;
 }

+ 10 - 6
tests/mock.js

@@ -145,16 +145,22 @@
                     '<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">'+
                         '<required/>'+
                     '</bind>'+
+                    `<sm xmlns='urn:xmpp:sm:3'/>`+
                     '<session xmlns="urn:ietf:params:xml:ns:xmpp-session">'+
                         '<optional/>'+
                     '</session>'+
                 '</stream:features>').firstChild;
 
             c._proto._connect = function () {
-                c.authenticated = true;
                 c.connected = true;
                 c.mock = true;
                 c.jid = 'dummy@localhost/resource';
+                c._changeConnectStatus(Strophe.Status.BINDREQUIRED);
+            };
+
+            c.bind = function () {
+                c.authenticated = true;
+                this.authenticated = true;
                 c._changeConnectStatus(Strophe.Status.CONNECTED);
             };
 
@@ -180,7 +186,7 @@
             _.forEach(spies.connection, method => spyOn(connection, method));
         }
 
-        const _converse = await converse.initialize(_.extend({
+        const _converse = await converse.initialize(Object.assign({
             'i18n': 'en',
             'auto_subscribe': false,
             'play_sounds': false,
@@ -232,10 +238,8 @@
             }).catch(_.partial(_converse.log, _, Strophe.LogLevel.FATAL));
         };
         if (_.get(settings, 'auto_login') !== false) {
-            _converse.api.user.login({
-                'jid': 'dummy@localhost',
-                'password': 'secret'
-            });
+            _converse.api.user.login('dummy@localhost', 'secret');
+            await _converse.api.waitUntil('afterResourceBinding');
         }
         window.converse_disable_effects = true;
         return _converse;

+ 1 - 0
tests/runner.js

@@ -44,6 +44,7 @@ var specs = [
     "spec/protocol",
     "spec/presence",
     "spec/eventemitter",
+    "spec/smacks",
     "spec/ping",
     "spec/push",
     "spec/xmppstatus",