Browse Source

Move roster models into their own module

JC Brand 7 years ago
parent
commit
e5cfdca30d
6 changed files with 781 additions and 730 deletions
  1. 1 0
      src/config.js
  2. 2 725
      src/converse-core.js
  3. 769 0
      src/converse-roster.js
  4. 4 5
      src/converse-rosterview.js
  5. 4 0
      src/converse-vcard.js
  6. 1 0
      src/converse.js

+ 1 - 0
src/config.js

@@ -93,6 +93,7 @@ require.config({
         "converse-profile":         "src/converse-profile",
         "converse-register":        "src/converse-register",
         "converse-roomslist":       "src/converse-roomslist",
+        "converse-roster":          "src/converse-roster",
         "converse-rosterview":      "src/converse-rosterview",
         "converse-singleton":       "src/converse-singleton",
         "converse-vcard":           "src/converse-vcard",

+ 2 - 725
src/converse-core.js

@@ -92,6 +92,7 @@
         'converse-profile',
         'converse-register',
         'converse-roomslist',
+        'converse-roster',
         'converse-rosterview',
         'converse-singleton',
         'converse-spoilers',
@@ -212,13 +213,8 @@
 
     const PROMISES = [
         'initialized',
-        'cachedRoster',
         'connectionInitialized',
         'pluginsInitialized',
-        'roster',
-        'rosterContactsFetched',
-        'rosterGroupsFetched',
-        'rosterInitialized',
         'statusInitialized'
     ];
 
@@ -359,7 +355,6 @@
             registration_domain: '',
             rid: undefined,
             root: window.document,
-            roster_groups: true,
             show_only_online_users: false,
             show_send_button: false,
             sid: undefined,
@@ -679,9 +674,6 @@
         };
 
         this.clearSession = function () {
-            if (!_.isUndefined(this.roster)) {
-                this.roster.browserStorage._clear();
-            }
             if (!_.isUndefined(this.session) && this.session.browserStorage) {
                 this.session.browserStorage._clear();
             }
@@ -779,51 +771,6 @@
             this.connection.send(carbons_iq);
         };
 
-        this.initRoster = function () {
-            /* Initialize the Bakcbone collections that represent the contats
-             * roster and the roster groups.
-             */
-            _converse.roster = new _converse.RosterContacts();
-            _converse.roster.browserStorage = new Backbone.BrowserStorage.session(
-                b64_sha1(`converse.contacts-${_converse.bare_jid}`));
-            _converse.rostergroups = new _converse.RosterGroups();
-            _converse.rostergroups.browserStorage = new Backbone.BrowserStorage.session(
-                b64_sha1(`converse.roster.groups${_converse.bare_jid}`));
-            _converse.emit('rosterInitialized');
-        };
-
-        this.populateRoster = function (ignore_cache=false) {
-            /* Fetch all the roster groups, and then the roster contacts.
-             * Emit an event after fetching is done in each case.
-             *
-             * Parameters:
-             *    (Bool) ignore_cache - If set to to true, the local cache
-             *      will be ignored it's guaranteed that the XMPP server
-             *      will be queried for the roster.
-             */
-            if (ignore_cache) {
-                _converse.send_initial_presence = true;
-                _converse.roster.fetchFromServer()
-                    .then(() => {
-                        _converse.emit('rosterContactsFetched');
-                        _converse.sendInitialPresence();
-                    }).catch((reason) => {
-                        _converse.log(reason, Strophe.LogLevel.ERROR);
-                        _converse.sendInitialPresence();
-                    });
-            } else {
-                _converse.rostergroups.fetchRosterGroups().then(() => {
-                    _converse.emit('rosterGroupsFetched');
-                    return _converse.roster.fetchRosterContacts();
-                }).then(() => {
-                    _converse.emit('rosterContactsFetched');
-                    _converse.sendInitialPresence();
-                }).catch((reason) => {
-                    _converse.log(reason, Strophe.LogLevel.ERROR);
-                    _converse.sendInitialPresence();
-                });
-            }
-        };
 
         this.unregisterPresenceHandler = function () {
             if (!_.isUndefined(_converse.presence_ref)) {
@@ -832,16 +779,6 @@
             }
         };
 
-        this.registerPresenceHandler = function () {
-            _converse.unregisterPresenceHandler();
-            _converse.presence_ref = _converse.connection.addHandler(
-                function (presence) {
-                    _converse.roster.presenceHandler(presence);
-                    return true;
-                }, null, 'presence', null);
-        };
-
-
         this.sendInitialPresence = function () {
             if (_converse.send_initial_presence) {
                 _converse.xmppstatus.sendPresence();
@@ -849,24 +786,7 @@
         };
 
         this.onStatusInitialized = function (reconnecting) {
-            /* Continue with session establishment (e.g. fetching chat boxes,
-             * populating the roster etc.) necessary once the connection has
-             * been established.
-             */
-            _converse.emit('statusInitialized');
-            if (reconnecting) {
-                // No need to recreate the roster, otherwise we lose our
-                // cached data. However we still emit an event, to give
-                // event handlers a chance to register views for the
-                // roster and its groups, before we start populating.
-                _converse.emit('rosterReadyAfterReconnection');
-            } else {
-                _converse.registerIntervalHandler();
-                _converse.initRoster();
-            }
-            _converse.roster.onConnected();
-            _converse.populateRoster(reconnecting);
-            _converse.registerPresenceHandler();
+            _converse.emit('statusInitialized', reconnecting);
             if (reconnecting) {
                 _converse.emit('reconnected');
             } else {
@@ -894,627 +814,6 @@
             _converse.initStatus(reconnecting)
         };
 
-        this.RosterContact = Backbone.Model.extend({
-
-            defaults: {
-                'chat_state': undefined,
-                'chat_status': 'offline',
-                'image': _converse.DEFAULT_IMAGE,
-                'image_type': _converse.DEFAULT_IMAGE_TYPE,
-                'num_unread': 0,
-                'status': '',
-            },
-
-            initialize (attributes) {
-                const { jid } = attributes,
-                      bare_jid = Strophe.getBareJidFromJid(jid).toLowerCase(),
-                      resource = Strophe.getResourceFromJid(jid);
-
-                attributes.jid = bare_jid;
-                this.set(_.assignIn({
-                    'fullname': bare_jid,
-                    'groups': [],
-                    'id': bare_jid,
-                    'jid': bare_jid,
-                    'resources': {},
-                    'user_id': Strophe.getNodeFromJid(jid)
-                }, attributes));
-
-                this.on('change:chat_status', function (item) {
-                    _converse.emit('contactStatusChanged', item.attributes);
-                });
-            },
-
-            subscribe (message) {
-                /* Send a presence subscription request to this roster contact
-                 *
-                 * Parameters:
-                 *    (String) message - An optional message to explain the
-                 *      reason for the subscription request.
-                 */
-                const pres = $pres({to: this.get('jid'), type: "subscribe"});
-                if (message && message !== "") {
-                    pres.c("status").t(message).up();
-                }
-                const nick = _converse.xmppstatus.get('nickname') || _converse.xmppstatus.get('fullname');
-                if (nick) {
-                    pres.c('nick', {'xmlns': Strophe.NS.NICK}).t(nick).up();
-                }
-                _converse.connection.send(pres);
-                this.save('ask', "subscribe"); // ask === 'subscribe' Means we have asked to subscribe to them.
-                return this;
-            },
-
-            ackSubscribe () {
-                /* Upon receiving the presence stanza of type "subscribed",
-                 * the user SHOULD acknowledge receipt of that subscription
-                 * state notification by sending a presence stanza of type
-                 * "subscribe" to the contact
-                 */
-                _converse.connection.send($pres({
-                    'type': 'subscribe',
-                    'to': this.get('jid')
-                }));
-            },
-
-            ackUnsubscribe () {
-                /* Upon receiving the presence stanza of type "unsubscribed",
-                 * the user SHOULD acknowledge receipt of that subscription state
-                 * notification by sending a presence stanza of type "unsubscribe"
-                 * this step lets the user's server know that it MUST no longer
-                 * send notification of the subscription state change to the user.
-                 *  Parameters:
-                 *    (String) jid - The Jabber ID of the user who is unsubscribing
-                 */
-                _converse.connection.send($pres({'type': 'unsubscribe', 'to': this.get('jid')}));
-                this.removeFromRoster();
-                this.destroy();
-            },
-
-            unauthorize (message) {
-                /* Unauthorize this contact's presence subscription
-                 * Parameters:
-                 *   (String) message - Optional message to send to the person being unauthorized
-                 */
-                _converse.rejectPresenceSubscription(this.get('jid'), message);
-                return this;
-            },
-
-            authorize (message) {
-                /* Authorize presence subscription
-                 * Parameters:
-                 *   (String) message - Optional message to send to the person being authorized
-                 */
-                const pres = $pres({'to': this.get('jid'), 'type': "subscribed"});
-                if (message && message !== "") {
-                    pres.c("status").t(message);
-                }
-                _converse.connection.send(pres);
-                return this;
-            },
-
-            addResource (presence) {
-                /* Adds a new resource and it's associated attributes as taken
-                 * from the passed in presence stanza.
-                 *
-                 * Also updates the contact's chat_status if the presence has
-                 * higher priority (and is newer).
-                 */
-                const jid = presence.getAttribute('from'),
-                    chat_status = _.propertyOf(presence.querySelector('show'))('textContent') || 'online',
-                    resource = Strophe.getResourceFromJid(jid),
-                    delay = presence.querySelector(
-                        `delay[xmlns="${Strophe.NS.DELAY}"]`
-                    ),
-                    timestamp = _.isNull(delay) ? moment().format() : moment(delay.getAttribute('stamp')).format();
-
-                let priority = _.propertyOf(presence.querySelector('priority'))('textContent') || 0;
-                priority = _.isNaN(parseInt(priority, 10)) ? 0 : parseInt(priority, 10);
-
-                const resources = _.isObject(this.get('resources')) ? this.get('resources') : {};
-                resources[resource] = {
-                    'name': resource,
-                    'priority': priority,
-                    'status': chat_status,
-                    'timestamp': timestamp
-                };
-                const changed = {'resources': resources};
-                const hpr = this.getHighestPriorityResource();
-                if (priority == hpr.priority && timestamp == hpr.timestamp) {
-                    // Only set the chat status if this is the newest resource
-                    // with the highest priority
-                    changed.chat_status = chat_status;
-                }
-                this.save(changed);
-                return resources;
-            },
-
-            removeResource (resource) {
-                /* Remove the passed in resource from the contact's resources map.
-                 *
-                 * Also recomputes the chat_status given that there's one less
-                 * resource.
-                 */
-                let resources = this.get('resources');
-                if (!_.isObject(resources)) {
-                    resources = {};
-                } else {
-                    delete resources[resource];
-                }
-                this.save({
-                    'resources': resources,
-                    'chat_status': _.propertyOf(
-                        this.getHighestPriorityResource())('status') || 'offline'
-                });
-            },
-
-            getHighestPriorityResource () {
-                /* Return the resource with the highest priority.
-                 *
-                 * If multiple resources have the same priority, take the
-                 * newest one.
-                 */
-                const resources = this.get('resources');
-                if (_.isObject(resources) && _.size(resources)) {
-                    const val = _.flow(
-                            _.values,
-                            _.partial(_.sortBy, _, ['priority', 'timestamp']),
-                            _.reverse
-                        )(resources)[0];
-                    if (!_.isUndefined(val)) {
-                        return val;
-                    }
-                }
-            },
-
-            removeFromRoster (callback, errback) {
-                /* Instruct the XMPP server to remove this contact from our roster
-                 * Parameters:
-                 *   (Function) callback
-                 */
-                const iq = $iq({type: 'set'})
-                    .c('query', {xmlns: Strophe.NS.ROSTER})
-                    .c('item', {jid: this.get('jid'), subscription: "remove"});
-                _converse.connection.sendIQ(iq, callback, errback);
-                return this;
-            }
-        });
-
-
-        this.RosterContacts = Backbone.Collection.extend({
-            model: _converse.RosterContact,
-
-            comparator (contact1, contact2) {
-                const status1 = contact1.get('chat_status') || 'offline';
-                const status2 = contact2.get('chat_status') || 'offline';
-                if (_converse.STATUS_WEIGHTS[status1] === _converse.STATUS_WEIGHTS[status2]) {
-                    const name1 = (contact1.get('fullname') || contact1.get('jid')).toLowerCase();
-                    const name2 = (contact2.get('fullname') || contact2.get('jid')).toLowerCase();
-                    return name1 < name2 ? -1 : (name1 > name2? 1 : 0);
-                } else  {
-                    return _converse.STATUS_WEIGHTS[status1] < _converse.STATUS_WEIGHTS[status2] ? -1 : 1;
-                }
-            },
-
-            onConnected () {
-                /* Called as soon as the connection has been established
-                 * (either after initial login, or after reconnection).
-                 *
-                 * Use the opportunity to register stanza handlers.
-                 */
-                this.registerRosterHandler();
-                this.registerRosterXHandler();
-            },
-
-            registerRosterHandler () {
-                /* Register a handler for roster IQ "set" stanzas, which update
-                 * roster contacts.
-                 */
-                _converse.connection.addHandler(
-                    _converse.roster.onRosterPush.bind(_converse.roster),
-                    Strophe.NS.ROSTER, 'iq', "set"
-                );
-            },
-
-            registerRosterXHandler () {
-                /* Register a handler for RosterX message stanzas, which are
-                 * used to suggest roster contacts to a user.
-                 */
-                let t = 0;
-                _converse.connection.addHandler(
-                    function (msg) {
-                        window.setTimeout(
-                            function () {
-                                _converse.connection.flush();
-                                _converse.roster.subscribeToSuggestedItems.bind(_converse.roster)(msg);
-                            }, t);
-                        t += msg.querySelectorAll('item').length*250;
-                        return true;
-                    },
-                    Strophe.NS.ROSTERX, 'message', null
-                );
-            },
-
-            fetchRosterContacts () {
-                /* Fetches the roster contacts, first by trying the
-                 * sessionStorage cache, and if that's empty, then by querying
-                 * the XMPP server.
-                 *
-                 * Returns a promise which resolves once the contacts have been
-                 * fetched.
-                 */
-                return new Promise((resolve, reject) => {
-                    this.fetch({
-                        'add': true,
-                        'silent': true,
-                        success (collection) {
-                            if (collection.length === 0) {
-                                _converse.send_initial_presence = true;
-                                _converse.roster.fetchFromServer().then(resolve).catch(reject);
-                            } else {
-                                _converse.emit('cachedRoster', collection);
-                                resolve();
-                            }
-                        }
-                    });
-                });
-            },
-
-            subscribeToSuggestedItems (msg) {
-                _.each(msg.querySelectorAll('item'), function (item) {
-                    if (item.getAttribute('action') === 'add') {
-                        _converse.roster.addAndSubscribe(
-                            item.getAttribute('jid'),
-                            _converse.xmppstatus.get('nickname') || _converse.xmppstatus.get('fullname')
-                        );
-                    }
-                });
-                return true;
-            },
-
-            isSelf (jid) {
-                return u.isSameBareJID(jid, _converse.connection.jid);
-            },
-
-            addAndSubscribe (jid, name, groups, message, attributes) {
-                /* Add a roster contact and then once we have confirmation from
-                 * the XMPP server we subscribe to that contact's presence updates.
-                 *  Parameters:
-                 *    (String) jid - The Jabber ID of the user being added and subscribed to.
-                 *    (String) name - The name of that user
-                 *    (Array of Strings) groups - Any roster groups the user might belong to
-                 *    (String) message - An optional message to explain the
-                 *      reason for the subscription request.
-                 *    (Object) attributes - Any additional attributes to be stored on the user's model.
-                 */
-                const handler = (contact) => {
-                    if (contact instanceof _converse.RosterContact) {
-                        contact.subscribe(message);
-                    }
-                }
-                this.addContactToRoster(jid, name, groups, attributes).then(handler, handler);
-            },
-
-            sendContactAddIQ (jid, name, groups, callback, errback) {
-                /*  Send an IQ stanza to the XMPP server to add a new roster contact.
-                 *
-                 *  Parameters:
-                 *    (String) jid - The Jabber ID of the user being added
-                 *    (String) name - The name of that user
-                 *    (Array of Strings) groups - Any roster groups the user might belong to
-                 *    (Function) callback - A function to call once the IQ is returned
-                 *    (Function) errback - A function to call if an error occured
-                 */
-                name = _.isEmpty(name)? jid: name;
-                const iq = $iq({type: 'set'})
-                    .c('query', {xmlns: Strophe.NS.ROSTER})
-                    .c('item', { jid, name });
-                _.each(groups, function (group) { iq.c('group').t(group).up(); });
-                _converse.connection.sendIQ(iq, callback, errback);
-            },
-
-            addContactToRoster (jid, name, groups, attributes) {
-                /* Adds a RosterContact instance to _converse.roster and
-                 * registers the contact on the XMPP server.
-                 * Returns a promise which is resolved once the XMPP server has
-                 * responded.
-                 *
-                 *  Parameters:
-                 *    (String) jid - The Jabber ID of the user being added and subscribed to.
-                 *    (String) name - The name of that user
-                 *    (Array of Strings) groups - Any roster groups the user might belong to
-                 *    (Object) attributes - Any additional attributes to be stored on the user's model.
-                 */
-                return new Promise((resolve, reject) => {
-                    groups = groups || [];
-                    name = _.isEmpty(name)? jid: name;
-                    this.sendContactAddIQ(jid, name, groups,
-                        () => {
-                            const contact = this.create(_.assignIn({
-                                'ask': undefined,
-                                'fullname': name,
-                                groups,
-                                jid,
-                                'requesting': false,
-                                'subscription': 'none'
-                            }, attributes), {sort: false});
-                            resolve(contact);
-                        },
-                        function (err) {
-                            alert(__('Sorry, there was an error while trying to add %1$s as a contact.', name));
-                            _converse.log(err, Strophe.LogLevel.ERROR);
-                            resolve(err);
-                        }
-                    );
-                });
-            },
-
-            subscribeBack (bare_jid, presence) {
-                const contact = this.get(bare_jid);
-                if (contact instanceof _converse.RosterContact) {
-                    contact.authorize().subscribe();
-                } else {
-                    // Can happen when a subscription is retried or roster was deleted
-                    const handler = (contact) => {
-                        if (contact instanceof _converse.RosterContact) {
-                            contact.authorize().subscribe();
-                        }
-                    }
-                    const nickname = _.get(sizzle(`nick[xmlns="${Strophe.NS.NICK}"]`, presence).pop(), 'textContent', null);
-                    this.addContactToRoster(bare_jid, nickname, [], {'subscription': 'from'}).then(handler, handler);
-                }
-            },
-
-            getNumOnlineContacts () {
-                let ignored = ['offline', 'unavailable'];
-                if (_converse.show_only_online_users) {
-                    ignored = _.union(ignored, ['dnd', 'xa', 'away']);
-                }
-                return _.sum(this.models.filter((model) => !_.includes(ignored, model.get('chat_status'))));
-            },
-
-            onRosterPush (iq) {
-                /* Handle roster updates from the XMPP server.
-                 * See: https://xmpp.org/rfcs/rfc6121.html#roster-syntax-actions-push
-                 *
-                 * Parameters:
-                 *    (XMLElement) IQ - The IQ stanza received from the XMPP server.
-                 */
-                const id = iq.getAttribute('id');
-                const from = iq.getAttribute('from');
-                if (from && from !== "" && Strophe.getBareJidFromJid(from) !== _converse.bare_jid) {
-                    // Receiving client MUST ignore stanza unless it has no from or from = user's bare JID.
-                    // XXX: Some naughty servers apparently send from a full
-                    // JID so we need to explicitly compare bare jids here.
-                    // https://github.com/jcbrand/converse.js/issues/493
-                    _converse.connection.send(
-                        $iq({type: 'error', id, from: _converse.connection.jid})
-                            .c('error', {'type': 'cancel'})
-                            .c('service-unavailable', {'xmlns': Strophe.NS.ROSTER })
-                    );
-                    return true;
-                }
-                _converse.connection.send($iq({type: 'result', id, from: _converse.connection.jid}));
-                const items = sizzle(`query[xmlns="${Strophe.NS.ROSTER}"] item`, iq);
-                _.each(items, this.updateContact.bind(this));
-                _converse.emit('rosterPush', iq);
-                return true;
-            },
-
-            fetchFromServer () {
-                /* Fetch the roster from the XMPP server */
-                return new Promise((resolve, reject) => {
-                    const iq = $iq({
-                        'type': 'get',
-                        'id': _converse.connection.getUniqueId('roster')
-                    }).c('query', {xmlns: Strophe.NS.ROSTER});
-
-                    const callback = _.flow(this.onReceivedFromServer.bind(this), resolve);
-                    const errback = function (iq) {
-                        const errmsg = "Error while trying to fetch roster from the server";
-                        _converse.log(errmsg, Strophe.LogLevel.ERROR);
-                        reject(new Error(errmsg));
-                    }
-                    return _converse.connection.sendIQ(iq, callback, errback);
-                });
-            },
-
-            onReceivedFromServer (iq) {
-                /* An IQ stanza containing the roster has been received from
-                 * the XMPP server.
-                 */
-                const items = sizzle(`query[xmlns="${Strophe.NS.ROSTER}"] item`, iq);
-                _.each(items, this.updateContact.bind(this));
-                _converse.emit('roster', iq);
-            },
-
-            updateContact (item) {
-                /* Update or create RosterContact models based on items
-                 * received in the IQ from the server.
-                 */
-                const jid = item.getAttribute('jid');
-                if (this.isSelf(jid)) { return; }
-
-                const contact = this.get(jid),
-                      subscription = item.getAttribute("subscription"),
-                      ask = item.getAttribute("ask"),
-                      groups = _.map(item.getElementsByTagName('group'), Strophe.getText);
-
-                if (!contact) {
-                    if ((subscription === "none" && ask === null) || (subscription === "remove")) {
-                        return; // We're lazy when adding contacts.
-                    }
-                    this.create({
-                        'ask': ask,
-                        'fullname': item.getAttribute("name") || jid,
-                        'groups': groups,
-                        'jid': jid,
-                        'subscription': subscription
-                    }, {sort: false});
-                } else {
-                    if (subscription === "remove") {
-                        return contact.destroy();
-                    }
-                    // We only find out about requesting contacts via the
-                    // presence handler, so if we receive a contact
-                    // here, we know they aren't requesting anymore.
-                    // see docs/DEVELOPER.rst
-                    contact.save({
-                        'subscription': subscription,
-                        'ask': ask,
-                        'requesting': null,
-                        'groups': groups
-                    });
-                }
-            },
-
-            createRequestingContact (presence) {
-                /* Creates a Requesting Contact.
-                 *
-                 * Note: this method gets completely overridden by converse-vcard.js
-                 */
-                const bare_jid = Strophe.getBareJidFromJid(presence.getAttribute('from')),
-                      nickname = _.get(sizzle(`nick[xmlns="${Strophe.NS.NICK}"]`, presence).pop(), 'textContent', null);
-                const user_data = {
-                    'jid': bare_jid,
-                    'subscription': 'none',
-                    'ask': null,
-                    'requesting': true,
-                    'fullname': nickname
-                };
-                this.create(user_data);
-                _converse.emit('contactRequest', user_data);
-            },
-
-            handleIncomingSubscription (presence) {
-                const jid = presence.getAttribute('from'),
-                      bare_jid = Strophe.getBareJidFromJid(jid),
-                      contact = this.get(bare_jid);
-
-                if (!_converse.allow_contact_requests) {
-                    _converse.rejectPresenceSubscription(
-                        jid,
-                        __("This client does not allow presence subscriptions")
-                    );
-                }
-                if (_converse.auto_subscribe) {
-                    if ((!contact) || (contact.get('subscription') !== 'to')) {
-                        this.subscribeBack(bare_jid, presence);
-                    } else {
-                        contact.authorize();
-                    }
-                } else {
-                    if (contact) {
-                        if (contact.get('subscription') !== 'none')  {
-                            contact.authorize();
-                        } else if (contact.get('ask') === "subscribe") {
-                            contact.authorize();
-                        }
-                    } else {
-                        this.createRequestingContact(presence);
-                    }
-                }
-            },
-
-            presenceHandler (presence) {
-                const presence_type = presence.getAttribute('type');
-                if (presence_type === 'error') { return true; }
-
-                const jid = presence.getAttribute('from'),
-                    bare_jid = Strophe.getBareJidFromJid(jid),
-                    resource = Strophe.getResourceFromJid(jid),
-                    chat_status = _.propertyOf(presence.querySelector('show'))('textContent') || 'online',
-                    status_message = _.propertyOf(presence.querySelector('status'))('textContent'),
-                    contact = this.get(bare_jid);
-
-                if (this.isSelf(bare_jid)) {
-                    if ((_converse.connection.jid !== jid) &&
-                        (presence_type !== 'unavailable') &&
-                        (_converse.synchronize_availability === true ||
-                         _converse.synchronize_availability === resource)) {
-                        // Another resource has changed its status and
-                        // synchronize_availability option set to update,
-                        // we'll update ours as well.
-                        _converse.xmppstatus.save({'status': chat_status});
-                        if (status_message) {
-                            _converse.xmppstatus.save({'status_message': status_message});
-                        }
-                    }
-                    if (_converse.jid === jid && presence_type === 'unavailable') {
-                        // XXX: We've received an "unavailable" presence from our
-                        // own resource. Apparently this happens due to a
-                        // Prosody bug, whereby we send an IQ stanza to remove
-                        // a roster contact, and Prosody then sends
-                        // "unavailable" globally, instead of directed to the
-                        // particular user that's removed.
-                        //
-                        // Here is the bug report: https://prosody.im/issues/1121
-                        //
-                        // I'm not sure whether this might legitimately happen
-                        // in other cases.
-                        //
-                        // As a workaround for now we simply send our presence again,
-                        // otherwise we're treated as offline.
-                        _converse.xmppstatus.sendPresence();
-                    }
-                    return;
-                } else if (sizzle(`query[xmlns="${Strophe.NS.MUC}"]`, presence).length) {
-                    return; // Ignore MUC
-                }
-                if (contact && (status_message !== contact.get('status'))) {
-                    contact.save({'status': status_message});
-                }
-                if (presence_type === 'subscribed' && contact) {
-                    contact.ackSubscribe();
-                } else if (presence_type === 'unsubscribed' && contact) {
-                    contact.ackUnsubscribe();
-                } else if (presence_type === 'unsubscribe') {
-                    return;
-                } else if (presence_type === 'subscribe') {
-                    this.handleIncomingSubscription(presence);
-                } else if (presence_type === 'unavailable' && contact) {
-                    contact.removeResource(resource);
-                } else if (contact) {
-                    // presence_type is undefined
-                    contact.addResource(presence);
-                }
-            }
-        });
-
-
-        this.RosterGroup = Backbone.Model.extend({
-
-            initialize (attributes) {
-                this.set(_.assignIn({
-                    description: __('Click to hide these contacts'),
-                    state: _converse.OPENED
-                }, attributes));
-                // Collection of contacts belonging to this group.
-                this.contacts = new _converse.RosterContacts();
-            }
-        });
-
-
-        this.RosterGroups = Backbone.Collection.extend({
-            model: _converse.RosterGroup,
-
-            fetchRosterGroups () {
-                /* Fetches all the roster groups from sessionStorage.
-                 *
-                 * Returns a promise which resolves once the groups have been
-                 * returned.
-                 */
-                return new Promise((resolve, reject) => {
-                    this.fetch({
-                        silent: true, // We need to first have all groups before
-                                    // we can start positioning them, so we set
-                                    // 'silent' to true.
-                        success: resolve
-                    });
-                });
-            }
-        });
-
 
         this.ConnectionFeedback = Backbone.Model.extend({
             defaults: {
@@ -1816,9 +1115,6 @@
              */
             _converse.emit('beforeTearDown');
             _converse.unregisterPresenceHandler();
-            if (_converse.roster) {
-                _converse.roster.off().reset(); // Removes roster contacts
-            }
             if (!_.isUndefined(_converse.session)) {
                 _converse.session.destroy();
             }
@@ -1981,25 +1277,6 @@
                 _.each(promises, addPromise);
             }
         },
-        'contacts': {
-            'get' (jids) {
-                const _getter = function (jid) {
-                    return _converse.roster.get(Strophe.getBareJidFromJid(jid)) || null;
-                };
-                if (_.isUndefined(jids)) {
-                    jids = _converse.roster.pluck('jid');
-                } else if (_.isString(jids)) {
-                    return _getter(jids);
-                }
-                return _.map(jids, _getter);
-            },
-            'add' (jid, name) {
-                if (!_.isString(jid) || !_.includes(jid, '@')) {
-                    throw new TypeError('contacts.add: invalid jid');
-                }
-                _converse.roster.addAndSubscribe(jid, _.isEmpty(name)? jid: name);
-            }
-        },
         'tokens': {
             'get' (id) {
                 if (!_converse.expose_rid_and_sid || _.isUndefined(_converse.connection)) {

+ 769 - 0
src/converse-roster.js

@@ -0,0 +1,769 @@
+// Converse.js
+// http://conversejs.org
+//
+// Copyright (c) 2012-2018, the Converse.js developers
+// Licensed under the Mozilla Public License (MPLv2)
+
+(function (root, factory) {
+    define(["converse-core"], factory);
+}(this, function (converse) {
+    "use strict";
+    const { Backbone, Promise, Strophe, $iq, $pres, b64_sha1, moment, sizzle, _ } = converse.env;
+    const u = converse.env.utils;
+
+    converse.plugins.add('converse-roster', {
+
+        overrides: {
+            clearSession () {
+                this.__super__.clearSession.apply(this, arguments);
+                if (!_.isUndefined(this.roster)) {
+                    this.roster.browserStorage._clear();
+                }
+            },
+
+            _tearDown () {
+                this.__super__._tearDown.apply(this, arguments);
+                if (this.roster) {
+                    this.roster.off().reset(); // Removes roster contacts
+                }
+            }
+        },
+
+        initialize () {
+            /* The initialize function gets called as soon as the plugin is
+             * loaded by converse.js's plugin machinery.
+             */
+            const { _converse } = this,
+                  { __ } = _converse;
+
+            _converse.api.promises.add([
+                'cachedRoster',
+                'roster',
+                'rosterContactsFetched',
+                'rosterGroupsFetched',
+                'rosterInitialized',
+            ]);
+
+
+            _converse.registerPresenceHandler = function () {
+                _converse.unregisterPresenceHandler();
+                _converse.presence_ref = _converse.connection.addHandler(
+                    function (presence) {
+                        _converse.roster.presenceHandler(presence);
+                        return true;
+                    }, null, 'presence', null);
+            };
+
+
+            _converse.initRoster = function () {
+                /* Initialize the Bakcbone collections that represent the contats
+                * roster and the roster groups.
+                */
+                _converse.roster = new _converse.RosterContacts();
+                _converse.roster.browserStorage = new Backbone.BrowserStorage.session(
+                    b64_sha1(`converse.contacts-${_converse.bare_jid}`));
+                _converse.rostergroups = new _converse.RosterGroups();
+                _converse.rostergroups.browserStorage = new Backbone.BrowserStorage.session(
+                    b64_sha1(`converse.roster.groups${_converse.bare_jid}`));
+                _converse.emit('rosterInitialized');
+            };
+
+
+            _converse.populateRoster = function (ignore_cache=false) {
+                /* Fetch all the roster groups, and then the roster contacts.
+                 * Emit an event after fetching is done in each case.
+                 *
+                 * Parameters:
+                 *    (Bool) ignore_cache - If set to to true, the local cache
+                 *      will be ignored it's guaranteed that the XMPP server
+                 *      will be queried for the roster.
+                 */
+                if (ignore_cache) {
+                    _converse.send_initial_presence = true;
+                    _converse.roster.fetchFromServer()
+                        .then(() => {
+                            _converse.emit('rosterContactsFetched');
+                            _converse.sendInitialPresence();
+                        }).catch((reason) => {
+                            _converse.log(reason, Strophe.LogLevel.ERROR);
+                            _converse.sendInitialPresence();
+                        });
+                } else {
+                    _converse.rostergroups.fetchRosterGroups().then(() => {
+                        _converse.emit('rosterGroupsFetched');
+                        return _converse.roster.fetchRosterContacts();
+                    }).then(() => {
+                        _converse.emit('rosterContactsFetched');
+                        _converse.sendInitialPresence();
+                    }).catch((reason) => {
+                        _converse.log(reason, Strophe.LogLevel.ERROR);
+                        _converse.sendInitialPresence();
+                    });
+                }
+            };
+
+
+            _converse.RosterContact = Backbone.Model.extend({
+
+                defaults: {
+                    'chat_state': undefined,
+                    'chat_status': 'offline',
+                    'image': _converse.DEFAULT_IMAGE,
+                    'image_type': _converse.DEFAULT_IMAGE_TYPE,
+                    'num_unread': 0,
+                    'status': '',
+                },
+
+                initialize (attributes) {
+                    const { jid } = attributes,
+                        bare_jid = Strophe.getBareJidFromJid(jid).toLowerCase(),
+                        resource = Strophe.getResourceFromJid(jid);
+
+                    attributes.jid = bare_jid;
+                    this.set(_.assignIn({
+                        'fullname': bare_jid,
+                        'groups': [],
+                        'id': bare_jid,
+                        'jid': bare_jid,
+                        'resources': {},
+                        'user_id': Strophe.getNodeFromJid(jid)
+                    }, attributes));
+
+                    this.on('change:chat_status', function (item) {
+                        _converse.emit('contactStatusChanged', item.attributes);
+                    });
+                },
+
+                subscribe (message) {
+                    /* Send a presence subscription request to this roster contact
+                    *
+                    * Parameters:
+                    *    (String) message - An optional message to explain the
+                    *      reason for the subscription request.
+                    */
+                    const pres = $pres({to: this.get('jid'), type: "subscribe"});
+                    if (message && message !== "") {
+                        pres.c("status").t(message).up();
+                    }
+                    const nick = _converse.xmppstatus.get('nickname') || _converse.xmppstatus.get('fullname');
+                    if (nick) {
+                        pres.c('nick', {'xmlns': Strophe.NS.NICK}).t(nick).up();
+                    }
+                    _converse.connection.send(pres);
+                    this.save('ask', "subscribe"); // ask === 'subscribe' Means we have asked to subscribe to them.
+                    return this;
+                },
+
+                ackSubscribe () {
+                    /* Upon receiving the presence stanza of type "subscribed",
+                    * the user SHOULD acknowledge receipt of that subscription
+                    * state notification by sending a presence stanza of type
+                    * "subscribe" to the contact
+                    */
+                    _converse.connection.send($pres({
+                        'type': 'subscribe',
+                        'to': this.get('jid')
+                    }));
+                },
+
+                ackUnsubscribe () {
+                    /* Upon receiving the presence stanza of type "unsubscribed",
+                    * the user SHOULD acknowledge receipt of that subscription state
+                    * notification by sending a presence stanza of type "unsubscribe"
+                    * this step lets the user's server know that it MUST no longer
+                    * send notification of the subscription state change to the user.
+                    *  Parameters:
+                    *    (String) jid - The Jabber ID of the user who is unsubscribing
+                    */
+                    _converse.connection.send($pres({'type': 'unsubscribe', 'to': this.get('jid')}));
+                    this.removeFromRoster();
+                    this.destroy();
+                },
+
+                unauthorize (message) {
+                    /* Unauthorize this contact's presence subscription
+                    * Parameters:
+                    *   (String) message - Optional message to send to the person being unauthorized
+                    */
+                    _converse.rejectPresenceSubscription(this.get('jid'), message);
+                    return this;
+                },
+
+                authorize (message) {
+                    /* Authorize presence subscription
+                    * Parameters:
+                    *   (String) message - Optional message to send to the person being authorized
+                    */
+                    const pres = $pres({'to': this.get('jid'), 'type': "subscribed"});
+                    if (message && message !== "") {
+                        pres.c("status").t(message);
+                    }
+                    _converse.connection.send(pres);
+                    return this;
+                },
+
+                addResource (presence) {
+                    /* Adds a new resource and it's associated attributes as taken
+                    * from the passed in presence stanza.
+                    *
+                    * Also updates the contact's chat_status if the presence has
+                    * higher priority (and is newer).
+                    */
+                    const jid = presence.getAttribute('from'),
+                        chat_status = _.propertyOf(presence.querySelector('show'))('textContent') || 'online',
+                        resource = Strophe.getResourceFromJid(jid),
+                        delay = presence.querySelector(
+                            `delay[xmlns="${Strophe.NS.DELAY}"]`
+                        ),
+                        timestamp = _.isNull(delay) ? moment().format() : moment(delay.getAttribute('stamp')).format();
+
+                    let priority = _.propertyOf(presence.querySelector('priority'))('textContent') || 0;
+                    priority = _.isNaN(parseInt(priority, 10)) ? 0 : parseInt(priority, 10);
+
+                    const resources = _.isObject(this.get('resources')) ? this.get('resources') : {};
+                    resources[resource] = {
+                        'name': resource,
+                        'priority': priority,
+                        'status': chat_status,
+                        'timestamp': timestamp
+                    };
+                    const changed = {'resources': resources};
+                    const hpr = this.getHighestPriorityResource();
+                    if (priority == hpr.priority && timestamp == hpr.timestamp) {
+                        // Only set the chat status if this is the newest resource
+                        // with the highest priority
+                        changed.chat_status = chat_status;
+                    }
+                    this.save(changed);
+                    return resources;
+                },
+
+                removeResource (resource) {
+                    /* Remove the passed in resource from the contact's resources map.
+                    *
+                    * Also recomputes the chat_status given that there's one less
+                    * resource.
+                    */
+                    let resources = this.get('resources');
+                    if (!_.isObject(resources)) {
+                        resources = {};
+                    } else {
+                        delete resources[resource];
+                    }
+                    this.save({
+                        'resources': resources,
+                        'chat_status': _.propertyOf(
+                            this.getHighestPriorityResource())('status') || 'offline'
+                    });
+                },
+
+                getHighestPriorityResource () {
+                    /* Return the resource with the highest priority.
+                    *
+                    * If multiple resources have the same priority, take the
+                    * newest one.
+                    */
+                    const resources = this.get('resources');
+                    if (_.isObject(resources) && _.size(resources)) {
+                        const val = _.flow(
+                                _.values,
+                                _.partial(_.sortBy, _, ['priority', 'timestamp']),
+                                _.reverse
+                            )(resources)[0];
+                        if (!_.isUndefined(val)) {
+                            return val;
+                        }
+                    }
+                },
+
+                removeFromRoster (callback, errback) {
+                    /* Instruct the XMPP server to remove this contact from our roster
+                    * Parameters:
+                    *   (Function) callback
+                    */
+                    const iq = $iq({type: 'set'})
+                        .c('query', {xmlns: Strophe.NS.ROSTER})
+                        .c('item', {jid: this.get('jid'), subscription: "remove"});
+                    _converse.connection.sendIQ(iq, callback, errback);
+                    return this;
+                }
+            });
+
+
+            _converse.RosterContacts = Backbone.Collection.extend({
+                model: _converse.RosterContact,
+
+                comparator (contact1, contact2) {
+                    const status1 = contact1.get('chat_status') || 'offline';
+                    const status2 = contact2.get('chat_status') || 'offline';
+                    if (_converse.STATUS_WEIGHTS[status1] === _converse.STATUS_WEIGHTS[status2]) {
+                        const name1 = (contact1.get('fullname') || contact1.get('jid')).toLowerCase();
+                        const name2 = (contact2.get('fullname') || contact2.get('jid')).toLowerCase();
+                        return name1 < name2 ? -1 : (name1 > name2? 1 : 0);
+                    } else  {
+                        return _converse.STATUS_WEIGHTS[status1] < _converse.STATUS_WEIGHTS[status2] ? -1 : 1;
+                    }
+                },
+
+                onConnected () {
+                    /* Called as soon as the connection has been established
+                    * (either after initial login, or after reconnection).
+                    *
+                    * Use the opportunity to register stanza handlers.
+                    */
+                    this.registerRosterHandler();
+                    this.registerRosterXHandler();
+                },
+
+                registerRosterHandler () {
+                    /* Register a handler for roster IQ "set" stanzas, which update
+                    * roster contacts.
+                    */
+                    _converse.connection.addHandler(
+                        _converse.roster.onRosterPush.bind(_converse.roster),
+                        Strophe.NS.ROSTER, 'iq', "set"
+                    );
+                },
+
+                registerRosterXHandler () {
+                    /* Register a handler for RosterX message stanzas, which are
+                    * used to suggest roster contacts to a user.
+                    */
+                    let t = 0;
+                    _converse.connection.addHandler(
+                        function (msg) {
+                            window.setTimeout(
+                                function () {
+                                    _converse.connection.flush();
+                                    _converse.roster.subscribeToSuggestedItems.bind(_converse.roster)(msg);
+                                }, t);
+                            t += msg.querySelectorAll('item').length*250;
+                            return true;
+                        },
+                        Strophe.NS.ROSTERX, 'message', null
+                    );
+                },
+
+                fetchRosterContacts () {
+                    /* Fetches the roster contacts, first by trying the
+                    * sessionStorage cache, and if that's empty, then by querying
+                    * the XMPP server.
+                    *
+                    * Returns a promise which resolves once the contacts have been
+                    * fetched.
+                    */
+                    return new Promise((resolve, reject) => {
+                        this.fetch({
+                            'add': true,
+                            'silent': true,
+                            success (collection) {
+                                if (collection.length === 0) {
+                                    _converse.send_initial_presence = true;
+                                    _converse.roster.fetchFromServer().then(resolve).catch(reject);
+                                } else {
+                                    _converse.emit('cachedRoster', collection);
+                                    resolve();
+                                }
+                            }
+                        });
+                    });
+                },
+
+                subscribeToSuggestedItems (msg) {
+                    _.each(msg.querySelectorAll('item'), function (item) {
+                        if (item.getAttribute('action') === 'add') {
+                            _converse.roster.addAndSubscribe(
+                                item.getAttribute('jid'),
+                                _converse.xmppstatus.get('nickname') || _converse.xmppstatus.get('fullname')
+                            );
+                        }
+                    });
+                    return true;
+                },
+
+                isSelf (jid) {
+                    return u.isSameBareJID(jid, _converse.connection.jid);
+                },
+
+                addAndSubscribe (jid, name, groups, message, attributes) {
+                    /* Add a roster contact and then once we have confirmation from
+                    * the XMPP server we subscribe to that contact's presence updates.
+                    *  Parameters:
+                    *    (String) jid - The Jabber ID of the user being added and subscribed to.
+                    *    (String) name - The name of that user
+                    *    (Array of Strings) groups - Any roster groups the user might belong to
+                    *    (String) message - An optional message to explain the
+                    *      reason for the subscription request.
+                    *    (Object) attributes - Any additional attributes to be stored on the user's model.
+                    */
+                    const handler = (contact) => {
+                        if (contact instanceof _converse.RosterContact) {
+                            contact.subscribe(message);
+                        }
+                    }
+                    this.addContactToRoster(jid, name, groups, attributes).then(handler, handler);
+                },
+
+                sendContactAddIQ (jid, name, groups, callback, errback) {
+                    /*  Send an IQ stanza to the XMPP server to add a new roster contact.
+                    *
+                    *  Parameters:
+                    *    (String) jid - The Jabber ID of the user being added
+                    *    (String) name - The name of that user
+                    *    (Array of Strings) groups - Any roster groups the user might belong to
+                    *    (Function) callback - A function to call once the IQ is returned
+                    *    (Function) errback - A function to call if an error occured
+                    */
+                    name = _.isEmpty(name)? jid: name;
+                    const iq = $iq({type: 'set'})
+                        .c('query', {xmlns: Strophe.NS.ROSTER})
+                        .c('item', { jid, name });
+                    _.each(groups, function (group) { iq.c('group').t(group).up(); });
+                    _converse.connection.sendIQ(iq, callback, errback);
+                },
+
+                addContactToRoster (jid, name, groups, attributes) {
+                    /* Adds a RosterContact instance to _converse.roster and
+                    * registers the contact on the XMPP server.
+                    * Returns a promise which is resolved once the XMPP server has
+                    * responded.
+                    *
+                    *  Parameters:
+                    *    (String) jid - The Jabber ID of the user being added and subscribed to.
+                    *    (String) name - The name of that user
+                    *    (Array of Strings) groups - Any roster groups the user might belong to
+                    *    (Object) attributes - Any additional attributes to be stored on the user's model.
+                    */
+                    return new Promise((resolve, reject) => {
+                        groups = groups || [];
+                        name = _.isEmpty(name)? jid: name;
+                        this.sendContactAddIQ(jid, name, groups,
+                            () => {
+                                const contact = this.create(_.assignIn({
+                                    'ask': undefined,
+                                    'fullname': name,
+                                    groups,
+                                    jid,
+                                    'requesting': false,
+                                    'subscription': 'none'
+                                }, attributes), {sort: false});
+                                resolve(contact);
+                            },
+                            function (err) {
+                                alert(__('Sorry, there was an error while trying to add %1$s as a contact.', name));
+                                _converse.log(err, Strophe.LogLevel.ERROR);
+                                resolve(err);
+                            }
+                        );
+                    });
+                },
+
+                subscribeBack (bare_jid, presence) {
+                    const contact = this.get(bare_jid);
+                    if (contact instanceof _converse.RosterContact) {
+                        contact.authorize().subscribe();
+                    } else {
+                        // Can happen when a subscription is retried or roster was deleted
+                        const handler = (contact) => {
+                            if (contact instanceof _converse.RosterContact) {
+                                contact.authorize().subscribe();
+                            }
+                        }
+                        const nickname = _.get(sizzle(`nick[xmlns="${Strophe.NS.NICK}"]`, presence).pop(), 'textContent', null);
+                        this.addContactToRoster(bare_jid, nickname, [], {'subscription': 'from'}).then(handler, handler);
+                    }
+                },
+
+                getNumOnlineContacts () {
+                    let ignored = ['offline', 'unavailable'];
+                    if (_converse.show_only_online_users) {
+                        ignored = _.union(ignored, ['dnd', 'xa', 'away']);
+                    }
+                    return _.sum(this.models.filter((model) => !_.includes(ignored, model.get('chat_status'))));
+                },
+
+                onRosterPush (iq) {
+                    /* Handle roster updates from the XMPP server.
+                    * See: https://xmpp.org/rfcs/rfc6121.html#roster-syntax-actions-push
+                    *
+                    * Parameters:
+                    *    (XMLElement) IQ - The IQ stanza received from the XMPP server.
+                    */
+                    const id = iq.getAttribute('id');
+                    const from = iq.getAttribute('from');
+                    if (from && from !== "" && Strophe.getBareJidFromJid(from) !== _converse.bare_jid) {
+                        // Receiving client MUST ignore stanza unless it has no from or from = user's bare JID.
+                        // XXX: Some naughty servers apparently send from a full
+                        // JID so we need to explicitly compare bare jids here.
+                        // https://github.com/jcbrand/converse.js/issues/493
+                        _converse.connection.send(
+                            $iq({type: 'error', id, from: _converse.connection.jid})
+                                .c('error', {'type': 'cancel'})
+                                .c('service-unavailable', {'xmlns': Strophe.NS.ROSTER })
+                        );
+                        return true;
+                    }
+                    _converse.connection.send($iq({type: 'result', id, from: _converse.connection.jid}));
+                    const items = sizzle(`query[xmlns="${Strophe.NS.ROSTER}"] item`, iq);
+                    _.each(items, this.updateContact.bind(this));
+                    _converse.emit('rosterPush', iq);
+                    return true;
+                },
+
+                fetchFromServer () {
+                    /* Fetch the roster from the XMPP server */
+                    return new Promise((resolve, reject) => {
+                        const iq = $iq({
+                            'type': 'get',
+                            'id': _converse.connection.getUniqueId('roster')
+                        }).c('query', {xmlns: Strophe.NS.ROSTER});
+
+                        const callback = _.flow(this.onReceivedFromServer.bind(this), resolve);
+                        const errback = function (iq) {
+                            const errmsg = "Error while trying to fetch roster from the server";
+                            _converse.log(errmsg, Strophe.LogLevel.ERROR);
+                            reject(new Error(errmsg));
+                        }
+                        return _converse.connection.sendIQ(iq, callback, errback);
+                    });
+                },
+
+                onReceivedFromServer (iq) {
+                    /* An IQ stanza containing the roster has been received from
+                    * the XMPP server.
+                    */
+                    const items = sizzle(`query[xmlns="${Strophe.NS.ROSTER}"] item`, iq);
+                    _.each(items, this.updateContact.bind(this));
+                    _converse.emit('roster', iq);
+                },
+
+                updateContact (item) {
+                    /* Update or create RosterContact models based on items
+                    * received in the IQ from the server.
+                    */
+                    const jid = item.getAttribute('jid');
+                    if (this.isSelf(jid)) { return; }
+
+                    const contact = this.get(jid),
+                        subscription = item.getAttribute("subscription"),
+                        ask = item.getAttribute("ask"),
+                        groups = _.map(item.getElementsByTagName('group'), Strophe.getText);
+
+                    if (!contact) {
+                        if ((subscription === "none" && ask === null) || (subscription === "remove")) {
+                            return; // We're lazy when adding contacts.
+                        }
+                        this.create({
+                            'ask': ask,
+                            'fullname': item.getAttribute("name") || jid,
+                            'groups': groups,
+                            'jid': jid,
+                            'subscription': subscription
+                        }, {sort: false});
+                    } else {
+                        if (subscription === "remove") {
+                            return contact.destroy();
+                        }
+                        // We only find out about requesting contacts via the
+                        // presence handler, so if we receive a contact
+                        // here, we know they aren't requesting anymore.
+                        // see docs/DEVELOPER.rst
+                        contact.save({
+                            'subscription': subscription,
+                            'ask': ask,
+                            'requesting': null,
+                            'groups': groups
+                        });
+                    }
+                },
+
+                createRequestingContact (presence) {
+                    const bare_jid = Strophe.getBareJidFromJid(presence.getAttribute('from')),
+                        nickname = _.get(sizzle(`nick[xmlns="${Strophe.NS.NICK}"]`, presence).pop(), 'textContent', null);
+                    const user_data = {
+                        'jid': bare_jid,
+                        'subscription': 'none',
+                        'ask': null,
+                        'requesting': true,
+                        'fullname': nickname
+                    };
+                    this.create(user_data);
+                    _converse.emit('contactRequest', user_data);
+                },
+
+                handleIncomingSubscription (presence) {
+                    const jid = presence.getAttribute('from'),
+                        bare_jid = Strophe.getBareJidFromJid(jid),
+                        contact = this.get(bare_jid);
+
+                    if (!_converse.allow_contact_requests) {
+                        _converse.rejectPresenceSubscription(
+                            jid,
+                            __("This client does not allow presence subscriptions")
+                        );
+                    }
+                    if (_converse.auto_subscribe) {
+                        if ((!contact) || (contact.get('subscription') !== 'to')) {
+                            this.subscribeBack(bare_jid, presence);
+                        } else {
+                            contact.authorize();
+                        }
+                    } else {
+                        if (contact) {
+                            if (contact.get('subscription') !== 'none')  {
+                                contact.authorize();
+                            } else if (contact.get('ask') === "subscribe") {
+                                contact.authorize();
+                            }
+                        } else {
+                            this.createRequestingContact(presence);
+                        }
+                    }
+                },
+
+                presenceHandler (presence) {
+                    const presence_type = presence.getAttribute('type');
+                    if (presence_type === 'error') { return true; }
+
+                    const jid = presence.getAttribute('from'),
+                        bare_jid = Strophe.getBareJidFromJid(jid),
+                        resource = Strophe.getResourceFromJid(jid),
+                        chat_status = _.propertyOf(presence.querySelector('show'))('textContent') || 'online',
+                        status_message = _.propertyOf(presence.querySelector('status'))('textContent'),
+                        contact = this.get(bare_jid);
+
+                    if (this.isSelf(bare_jid)) {
+                        if ((_converse.connection.jid !== jid) &&
+                            (presence_type !== 'unavailable') &&
+                            (_converse.synchronize_availability === true ||
+                            _converse.synchronize_availability === resource)) {
+                            // Another resource has changed its status and
+                            // synchronize_availability option set to update,
+                            // we'll update ours as well.
+                            _converse.xmppstatus.save({'status': chat_status});
+                            if (status_message) {
+                                _converse.xmppstatus.save({'status_message': status_message});
+                            }
+                        }
+                        if (_converse.jid === jid && presence_type === 'unavailable') {
+                            // XXX: We've received an "unavailable" presence from our
+                            // own resource. Apparently this happens due to a
+                            // Prosody bug, whereby we send an IQ stanza to remove
+                            // a roster contact, and Prosody then sends
+                            // "unavailable" globally, instead of directed to the
+                            // particular user that's removed.
+                            //
+                            // Here is the bug report: https://prosody.im/issues/1121
+                            //
+                            // I'm not sure whether this might legitimately happen
+                            // in other cases.
+                            //
+                            // As a workaround for now we simply send our presence again,
+                            // otherwise we're treated as offline.
+                            _converse.xmppstatus.sendPresence();
+                        }
+                        return;
+                    } else if (sizzle(`query[xmlns="${Strophe.NS.MUC}"]`, presence).length) {
+                        return; // Ignore MUC
+                    }
+                    if (contact && (status_message !== contact.get('status'))) {
+                        contact.save({'status': status_message});
+                    }
+                    if (presence_type === 'subscribed' && contact) {
+                        contact.ackSubscribe();
+                    } else if (presence_type === 'unsubscribed' && contact) {
+                        contact.ackUnsubscribe();
+                    } else if (presence_type === 'unsubscribe') {
+                        return;
+                    } else if (presence_type === 'subscribe') {
+                        this.handleIncomingSubscription(presence);
+                    } else if (presence_type === 'unavailable' && contact) {
+                        contact.removeResource(resource);
+                    } else if (contact) {
+                        // presence_type is undefined
+                        contact.addResource(presence);
+                    }
+                }
+            });
+
+
+            _converse.RosterGroup = Backbone.Model.extend({
+
+                initialize (attributes) {
+                    this.set(_.assignIn({
+                        description: __('Click to hide these contacts'),
+                        state: _converse.OPENED
+                    }, attributes));
+                    // Collection of contacts belonging to this group.
+                    this.contacts = new _converse.RosterContacts();
+                }
+            });
+
+
+            _converse.RosterGroups = Backbone.Collection.extend({
+                model: _converse.RosterGroup,
+
+                fetchRosterGroups () {
+                    /* Fetches all the roster groups from sessionStorage.
+                    *
+                    * Returns a promise which resolves once the groups have been
+                    * returned.
+                    */
+                    return new Promise((resolve, reject) => {
+                        this.fetch({
+                            silent: true, // We need to first have all groups before
+                                        // we can start positioning them, so we set
+                                        // 'silent' to true.
+                            success: resolve
+                        });
+                    });
+                }
+            });
+
+
+            /********** Event Handlers *************/
+
+            _converse.api.listen.on('statusInitialized', (reconnecting) => {
+                if (reconnecting) {
+                    // No need to recreate the roster, otherwise we lose our
+                    // cached data. However we still emit an event, to give
+                    // event handlers a chance to register views for the
+                    // roster and its groups, before we start populating.
+                    _converse.emit('rosterReadyAfterReconnection');
+                } else {
+                    _converse.registerIntervalHandler();
+                    _converse.initRoster();
+                }
+                _converse.roster.onConnected();
+                _converse.populateRoster(reconnecting);
+                _converse.registerPresenceHandler();
+            });
+
+
+            /************************ API ************************/
+            // API methods only available to plugins
+
+            _.extend(_converse.api, {
+                'contacts': {
+                    'get' (jids) {
+                        const _getter = function (jid) {
+                            return _converse.roster.get(Strophe.getBareJidFromJid(jid)) || null;
+                        };
+                        if (_.isUndefined(jids)) {
+                            jids = _converse.roster.pluck('jid');
+                        } else if (_.isString(jids)) {
+                            return _getter(jids);
+                        }
+                        return _.map(jids, _getter);
+                    },
+                    'add' (jid, name) {
+                        if (!_.isString(jid) || !_.includes(jid, '@')) {
+                            throw new TypeError('contacts.add: invalid jid');
+                        }
+                        _converse.roster.addAndSubscribe(jid, _.isEmpty(name)? jid: name);
+                    }
+                }
+            });
+        }
+    });
+}));

+ 4 - 5
src/converse-rosterview.js

@@ -1,10 +1,8 @@
-// Converse.js (A browser based XMPP chat client)
+// Converse.js
 // http://conversejs.org
 //
-// Copyright (c) 2012-2017, Jan-Carel Brand <jc@opkode.com>
+// Copyright (c) 2012-2018, the Converse.js developers
 // Licensed under the Mozilla Public License (MPLv2)
-//
-/*global define */
 
 (function (root, factory) {
     define(["converse-core",
@@ -39,7 +37,7 @@
 
     converse.plugins.add('converse-rosterview', {
 
-        dependencies: ["converse-modal"],
+        dependencies: ["converse-roster", "converse-modal"],
 
         overrides: {
             // Overrides mentioned here will be picked up by converse.js's
@@ -82,6 +80,7 @@
             _converse.api.settings.update({
                 'allow_chat_pending_contacts': true,
                 'allow_contact_removal': true,
+                'roster_groups': true,
                 'show_toolbar': true,
                 'xhr_user_search_url': null
             });

+ 4 - 0
src/converse-vcard.js

@@ -63,6 +63,10 @@
 
     converse.plugins.add('converse-vcard', {
 
+        // FIXME: After refactoring, the dependency switches, from
+        // converse-roster to converse-vcard
+        dependencies: ["converse-roster"],
+
         overrides: {
             // Overrides mentioned here will be picked up by converse.js's
             // plugin architecture they will replace existing methods on the

+ 1 - 0
src/converse.js

@@ -22,6 +22,7 @@ if (typeof define !== 'undefined') {
         "converse-notification",    // HTML5 Notifications
         "converse-otr",             // Off-the-record encryption for one-on-one messages
         "converse-ping",            // XEP-0199 XMPP Ping
+        "converse-roster",
         "converse-register",        // XEP-0077 In-band registration
         "converse-roomslist",       // Show currently open chat rooms
         "converse-vcard",           // XEP-0054 VCard-temp