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
This commit is contained in:
JC Brand 2018-05-29 12:00:23 +02:00
parent a46ee4dfe1
commit 7b11d85503
24 changed files with 764 additions and 268 deletions

View File

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

View File

@ -15,15 +15,15 @@
- Message deduplication bugfixes and improvements - 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 - 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). - 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 option [enable_smacks](https://conversejs.org/docs/html/configuration.html#enable-smacks).
- New config setting [muc_show_join_leave_status](https://conversejs.org/docs/html/configuration.html#muc-show-join-leave-status) - 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). - 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 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 "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`. 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: [chatBoxBlurred](https://conversejs.org/docs/html/api/-_converse.html#event:chatBoxBlurred)
- New event: [chatReconnected](https://conversejs.org/docs/html/api/-_converse.html#event:chatReconnected) - 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 - #1296: `embedded` view mode shows `chatbox-navback` arrow in header
- #1465: When highlighting a roster contact, they're incorrectly shown as online - #1465: When highlighting a roster contact, they're incorrectly shown as online
- #1532: Converse reloads on enter pressed in the filter box - #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` - #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 - #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). - 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.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. - `_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. - 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. Use [message_carbons](https://conversejs.org/docs/html/configuration.html#message-carbons) instead.
### API changes ### API changes
- `_converse.chats.open` and `_converse.rooms.open` now take a `force` - `_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.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.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. - `_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.2.0 (2019-04-04)

View File

@ -25,14 +25,15 @@
// 'prosody@conference.prosody.im', // 'prosody@conference.prosody.im',
// 'jdev@conference.jabber.org' // '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', view_mode: 'fullscreen',
notify_all_room_messages: [ notify_all_room_messages: [
'discuss@conference.conversejs.org' 'discuss@conference.conversejs.org'
], ],
enable_smacks: true,
muc_respect_autojoin: false, 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', message_archiving: 'always',
debug: true debug: true
}); });

View File

@ -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 Registering a push app server against a MUC domain is not (yet) standardized
and this feature should be considered experimental. 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 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 Alternatively you could use it with `view_mode`_ set to ``overlayed`` to create
a single helpdesk-type chat. 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 sounds_path
----------- -----------

View File

@ -71,8 +71,8 @@ and a list of servers that you can set up yourself on `xmpp.org <https://xmpp.or
.. _`BOSH-section`: .. _`BOSH-section`:
BOSH BOSH (XMPP-over-HTTP)
==== =====================
Web-browsers do not allow the persistent, direct TCP socket connections used by Web-browsers do not allow the persistent, direct TCP socket connections used by
desktop XMPP clients to communicate with XMPP servers. 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 Refer to the :ref:`bosh-service-url` configuration setting for information on
how to configure Converse to connect to a BOSH URL. how to configure Converse to connect to a BOSH URL.
Configuring your webserver for BOSH
.. _`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
=============
Lets say the domain under which you host Converse is *example.org:80*, Lets say the domain under which you host Converse is *example.org:80*,
but the domain of your connection manager or the domain of 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: .. _CORS:
1. Cross-Origin Resource Sharing (CORS) 1. Cross-Origin Resource Sharing (CORS)
--------------------------------------- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
CORS is a technique for overcoming browser restrictions related to the 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>`_. `same-origin security policy <https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy>`_.
@ -159,7 +141,7 @@ 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 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. 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: Your ``nginx`` or ``apache`` configuration will look as follows:
Nginx Nginx
~~~~~ ^^^^^
.. code-block:: nginx .. code-block:: nginx
@ -202,7 +184,7 @@ Nginx
} }
Apache Apache
~~~~~~ ^^^^^^
.. code-block:: apache .. code-block:: apache
@ -239,6 +221,70 @@ Apache
this problem. 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`: .. _`session-support`:
Single Session Support Single Session Support

4
package-lock.json generated
View File

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

View File

@ -32,7 +32,7 @@
delete _converse.jid; delete _converse.jid;
_converse.keepalive = true; _converse.keepalive = true;
_converse.authentication = "prebind"; _converse.authentication = "prebind";
expect(_converse.logIn.bind(_converse)).toThrow( expect(_converse.api.user.login.bind(_converse)).toThrow(
new Error( new Error(
"restoreBOSHSession: tried to restore a \"keepalive\" session "+ "restoreBOSHSession: tried to restore a \"keepalive\" session "+
"but we don't have the JID for the user!")); "but we don't have the JID for the user!"));
@ -47,7 +47,7 @@
delete _converse.jid; delete _converse.jid;
_converse.keepalive = false; _converse.keepalive = false;
_converse.authentication = "prebind"; _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.")); 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.bosh_service_url = undefined;
_converse.jid = jid; _converse.jid = jid;

View File

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

View File

@ -273,7 +273,7 @@
'name': 'Nicky'}); 'name': 'Nicky'});
_converse.connection._dataRecv(test_utils.createRequest(stanza)); _converse.connection._dataRecv(test_utils.createRequest(stanza));
// Check that the IQ set was acknowledged. // 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"/>` `<iq from="dummy@localhost/resource" id="${IQ_id}" type="result" xmlns="jabber:client"/>`
); );
expect(_converse.roster.updateContact).toHaveBeenCalled(); expect(_converse.roster.updateContact).toHaveBeenCalled();

View File

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

128
spec/smacks.js Normal file
View File

@ -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();
}));
});
}));

View File

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

View File

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

View File

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

View File

@ -102,6 +102,7 @@ _converse.core_plugins = [
'converse-pubsub', 'converse-pubsub',
'converse-roster', 'converse-roster',
'converse-rsm', 'converse-rsm',
'converse-smacks',
'converse-vcard' 'converse-vcard'
]; ];
@ -190,7 +191,7 @@ _converse.CHATROOMS_TYPE = 'chatroom';
_converse.HEADLINES_TYPE = 'headline'; _converse.HEADLINES_TYPE = 'headline';
_converse.CONTROLBOX_TYPE = 'controlbox'; _converse.CONTROLBOX_TYPE = 'controlbox';
_converse.default_connection_options = {}; _converse.default_connection_options = {'explicitResourceBinding': true};
// Default configuration values // Default configuration values
// ---------------------------- // ----------------------------
@ -304,8 +305,9 @@ _converse.__ = function (str) {
const __ = _converse.__; const __ = _converse.__;
const PROMISES = [ const PROMISES = [
'initialized', 'afterResourceBinding',
'connectionInitialized', 'connectionInitialized',
'initialized',
'pluginsInitialized', 'pluginsInitialized',
'statusInitialized' 'statusInitialized'
]; ];
@ -405,6 +407,34 @@ function initClientConfig () {
_converse.api.trigger('clientConfigInitialized'); _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 () { _converse.initConnection = function () {
/* Creates a new Strophe.Connection instance if we don't already have one. /* 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'; const id = 'converse.bosh-session';
_converse.session = new Backbone.Model({id}); _converse.bosh_session = new Backbone.Model({id});
_converse.session.browserStorage = new BrowserStorage.session(id); _converse.bosh_session.browserStorage = new BrowserStorage.session(id);
try { try {
await new Promise((success, error) => _converse.session.fetch({success, error})); await new Promise((success, error) => _converse.bosh_session.fetch({success, error}));
if (_converse.jid && !u.isSameBareJID(_converse.session.get('jid'), _converse.jid)) { if (_converse.jid && !u.isSameBareJID(_converse.bosh_session.get('jid'), _converse.jid)) {
_converse.session.clear({'silent': true}); _converse.bosh_session.clear({'silent': true});
_converse.session.save({'jid': _converse.jid, id}); _converse.bosh_session.save({'jid': _converse.jid, id});
} }
} catch (e) { } catch (e) {
if (_converse.jid) { 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 * Triggered once the session has been initialized. The session is a
* persistent object which stores session information in the browser storage. * persistent object which stores session information in the browser storage.
* @event _converse#sessionInitialized * @event _converse#BOSHSessionInitialized
* @memberOf _converse * @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(); initClientConfig();
initPlugins(); initPlugins();
_converse.initConnection(); _converse.initConnection();
await initSession(); await initBOSHSession();
_converse.logIn(); _converse.api.user.login();
_converse.registerGlobalEventHandlers(); _converse.registerGlobalEventHandlers();
if (!Backbone.history.started) { if (!Backbone.history.started) {
Backbone.history.start(); Backbone.history.start();
@ -758,7 +836,7 @@ _converse.initialize = async function (settings, callback) {
_converse.connection.reconnecting = true; _converse.connection.reconnecting = true;
_converse.tearDown(); _converse.tearDown();
_converse.logIn(null, true); _converse.api.user.login(null, null, true);
}, 2000); }, 2000);
@ -773,7 +851,7 @@ _converse.initialize = async function (settings, callback) {
delete _converse.connection.reconnecting; delete _converse.connection.reconnecting;
_converse.connection.reset(); _converse.connection.reset();
_converse.tearDown(); _converse.tearDown();
_converse.clearSession(); clearSession();
/** /**
* Triggered after converse.js has disconnected from the XMPP server. * Triggered after converse.js has disconnected from the XMPP server.
* @event _converse#disconnected * @event _converse#disconnected
@ -840,7 +918,7 @@ _converse.initialize = async function (settings, callback) {
_converse.setDisconnectionCause(); _converse.setDisconnectionCause();
if (_converse.connection.reconnecting) { if (_converse.connection.reconnecting) {
_converse.log(status === Strophe.Status.CONNECTED ? 'Reconnected' : 'Reattached'); _converse.log(status === Strophe.Status.CONNECTED ? 'Reconnected' : 'Reattached');
_converse.onConnected(true); onConnected(true);
} else { } else {
_converse.log(status === Strophe.Status.CONNECTED ? 'Connected' : 'Attached'); _converse.log(status === Strophe.Status.CONNECTED ? 'Connected' : 'Attached');
if (_converse.connection.restored) { if (_converse.connection.restored) {
@ -848,7 +926,7 @@ _converse.initialize = async function (settings, callback) {
// we're restoring an existing session. // we're restoring an existing session.
_converse.send_initial_presence = false; _converse.send_initial_presence = false;
} }
_converse.onConnected(); onConnected();
} }
} else if (status === Strophe.Status.DISCONNECTED) { } else if (status === Strophe.Status.DISCONNECTED) {
_converse.setDisconnectionCause(status, message); _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) { this.saveWindowState = function (ev) {
// XXX: eventually we should be able to just use // XXX: eventually we should be able to just use
// document.visibilityState (when we drop support for older // 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 /* Ask the XMPP server to enable Message Carbons
* See XEP-0280 https://xmpp.org/extensions/xep-0280.html#enabling * 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; return;
} }
const carbons_iq = new Strophe.Builder('iq', { 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 () { this.bindResource = async function () {
/** /**
* Synchronous event triggered before we send an IQ to bind the user's * 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(); _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({ this.ConnectionFeedback = Backbone.Model.extend({
defaults: { defaults: {
'connection_status': Strophe.Status.DISCONNECTED, 'connection_status': Strophe.Status.DISCONNECTED,
@ -1130,12 +1145,8 @@ _converse.initialize = async function (settings, callback) {
this.XMPPStatus = Backbone.Model.extend({ this.XMPPStatus = Backbone.Model.extend({
defaults: {
defaults () { "status": _converse.default_state
return {
"jid": _converse.bare_jid,
"status": _converse.default_state
}
}, },
initialize () { initialize () {
@ -1237,7 +1248,7 @@ _converse.initialize = async function (settings, callback) {
return false; return false;
} }
/* Tries to restore a cached BOSH session. */ /* Tries to restore a cached BOSH session. */
const jid = _converse.session.get('jid'); const jid = _converse.bosh_session.get('jid');
if (!jid) { if (!jid) {
const msg = "restoreBOSHSession: tried to restore a \"keepalive\" session "+ const msg = "restoreBOSHSession: tried to restore a \"keepalive\" session "+
"but we don't have the JID for the user!"; "but we don't have the JID for the user!";
@ -1256,7 +1267,7 @@ _converse.initialize = async function (settings, callback) {
_converse.log( _converse.log(
"Could not restore session for jid: "+ "Could not restore session for jid: "+
jid+" Error message: "+e.message, Strophe.LogLevel.WARN); 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; return false;
} }
} }
@ -1323,11 +1334,6 @@ _converse.initialize = async function (settings, callback) {
}; };
this.autoLogin = function (credentials) { 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.authentication === _converse.ANONYMOUS || this.authentication === _converse.EXTERNAL) {
if (!this.jid) { if (!this.jid) {
throw new Error("Config Error: when using anonymous login " + throw new Error("Config Error: when using anonymous login " +
@ -1350,12 +1356,6 @@ _converse.initialize = async function (settings, callback) {
_converse.api.connection.disconnect(); _converse.api.connection.disconnect();
return; 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) { if (!this.connection.reconnecting) {
this.connection.reset(); 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 () { this.tearDown = function () {
_converse.api.trigger('beforeTearDown'); _converse.api.trigger('beforeTearDown');
if (!_.isUndefined(_converse.session)) { if (!_.isUndefined(_converse.bosh_session)) {
_converse.session.destroy(); _converse.bosh_session.destroy();
} }
window.removeEventListener('click', _converse.onUserActivity); window.removeEventListener('click', _converse.onUserActivity);
window.removeEventListener('focus', _converse.onUserActivity); window.removeEventListener('focus', _converse.onUserActivity);
@ -1451,7 +1441,7 @@ _converse.api = {
_converse.connection.disconnect(); _converse.connection.disconnect();
} else { } else {
_converse.tearDown(); _converse.tearDown();
_converse.clearSession(); clearSession();
} }
}, },
}, },
@ -1473,7 +1463,7 @@ _converse.api = {
/* Event emitter and promise resolver */ /* Event emitter and promise resolver */
const args = Array.from(arguments); const args = Array.from(arguments);
const options = args.pop(); const options = args.pop();
if (options.synchronous) { if (options && options.synchronous) {
const events = _converse._events[name] || []; const events = _converse._events[name] || [];
await Promise.all(events.map(e => e.callback.call(e.ctx, args))); await Promise.all(events.map(e => e.callback.call(e.ctx, args)));
} else { } else {
@ -1507,31 +1497,59 @@ _converse.api = {
* to log the user in by calling the `prebind_url` or `credentials_url` depending * to log the user in by calling the `prebind_url` or `credentials_url` depending
* on whether prebinding is used or not. * on whether prebinding is used or not.
* *
* Otherwise the user will be shown a login form.
*
* @method _converse.api.user.login * @method _converse.api.user.login
* @param {object} [credentials] An object with the credentials. * @param {string} [jid]
* @param {string} [password]
* @param {boolean} [reconnecting]
* @example * @example
* converse.plugins.add('myplugin', { * converse.plugins.add('myplugin', {
* initialize: function () { * initialize: function () {
* * this._converse.api.user.login('dummy@example.com', 'secret');
* this._converse.api.user.login({
* 'jid': 'dummy@example.com',
* 'password': 'secret'
* });
*
* } * }
* }); * });
*/ */
'login' (credentials) { login (jid, password, reconnecting) {
_converse.logIn(credentials); 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. * Logs the user out of the current XMPP session.
* *
* @method _converse.api.user.logout * @method _converse.api.user.logout
* @example _converse.api.user.logout(); * @example _converse.api.user.logout();
*/ */
'logout' () { logout () {
_converse.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*. * Set and get the user's chat status, also called their *availability*.
@ -1841,9 +1859,16 @@ _converse.api = {
* }); * });
* _converse.api.send(msg); * _converse.api.send(msg);
*/ */
'send' (stanza) { send (stanza) {
_converse.connection.send(stanza); if (_.isString(stanza)) {
_converse.api.trigger('send', 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 * @returns {Promise} A promise which resolves when we receive a `result` stanza
* or is rejected when we receive an `error` stanza. * or is rejected when we receive an `error` stanza.
*/ */
'sendIQ' (stanza, timeout) { sendIQ (stanza, timeout) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
_converse.connection.sendIQ(stanza, resolve, reject, timeout || _converse.IQ_TIMEOUT); _converse.connection.sendIQ(stanza, resolve, reject, timeout || _converse.IQ_TIMEOUT);
_converse.api.trigger('send', stanza); _converse.api.trigger('send', stanza);

View File

@ -22,6 +22,7 @@ converse.plugins.add('converse-disco', {
// Promises exposed by this plugin // Promises exposed by this plugin
_converse.api.promises.add('discoInitialized'); _converse.api.promises.add('discoInitialized');
_converse.api.promises.add('streamFeaturesAdded');
/** /**
@ -260,32 +261,33 @@ converse.plugins.add('converse-disco', {
} }
function initStreamFeatures () { function initStreamFeatures () {
_converse.stream_features = new Backbone.Collection(); const bare_jid = Strophe.getBareJidFromJid(_converse.jid);
_converse.stream_features.browserStorage = new BrowserStorage.session( const id = `converse.stream-features-${bare_jid}`;
`converse.stream-features-${_converse.bare_jid}` if (!_converse.stream_features || _converse.stream_features.browserStorage.id !== id) {
); _converse.stream_features = new Backbone.Collection();
_converse.stream_features.fetch({ _converse.stream_features.browserStorage = new BrowserStorage.session(id);
success (collection) { _converse.stream_features.fetch({
if (collection.length === 0 && _converse.connection.features) { success (collection) {
_.forEach( if (collection.length === 0 && _converse.connection.features) {
_converse.connection.features.childNodes, Array.from(_converse.connection.features.childNodes)
(feature) => { .forEach(feature => {
_converse.stream_features.create({ _converse.stream_features.create({
'name': feature.nodeName, 'name': feature.nodeName,
'xmlns': feature.getAttribute('xmlns') '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 () { async function initializeDisco () {
@ -313,7 +315,9 @@ converse.plugins.add('converse-disco', {
_converse.api.trigger('discoInitialized'); _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('reconnected', initializeDisco);
_converse.api.listen.on('connected', initializeDisco); _converse.api.listen.on('connected', initializeDisco);
@ -326,6 +330,10 @@ converse.plugins.add('converse-disco', {
_converse.disco_entities.reset(); _converse.disco_entities.reset();
_converse.disco_entities.browserStorage._clear(); _converse.disco_entities.browserStorage._clear();
} }
if (_converse.stream_features) {
_converse.stream_features.reset();
_converse.stream_features.browserStorage._clear();
}
}); });
const plugin = this; const plugin = this;
@ -386,7 +394,8 @@ converse.plugins.add('converse-disco', {
* @param {String} xmlns The XML namespace * @param {String} xmlns The XML namespace
* @example _converse.api.disco.stream.getFeature('ver', 'urn:xmpp:features:rosterver') * @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)) { if (_.isNil(name) || _.isNil(xmlns)) {
throw new Error("name and xmlns need to be provided when calling disco.stream.getFeature"); throw new Error("name and xmlns need to be provided when calling disco.stream.getFeature");
} }

View File

@ -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);
}
});

View File

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

View File

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

View File

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

View File

@ -21,6 +21,18 @@ import sizzle from "sizzle";
*/ */
const u = {}; 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) { u.toStanza = function (string) {
return Strophe.xmlHtmlNode(string).firstElementChild; return Strophe.xmlHtmlNode(string).firstElementChild;
} }

View File

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

View File

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