ソースを参照

Move roster view code into a separate plugin

JC Brand 9 年 前
コミット
df3bcad0b3
5 ファイル変更773 行追加722 行削除
  1. 1 0
      converse.js
  2. 1 715
      src/converse-controlbox.js
  3. 7 6
      src/converse-core.js
  4. 0 1
      src/converse-notification.js
  5. 764 0
      src/converse-rosterview.js

+ 1 - 0
converse.js

@@ -46,6 +46,7 @@ require.config({
         // Converse
         "converse-api":             "src/converse-api",
         "converse-chatview":        "src/converse-chatview",
+        "converse-rosterview":      "src/converse-rosterview",
         "converse-controlbox":      "src/converse-controlbox",
         "converse-core":            "src/converse-core",
         "converse-headline":        "src/converse-headline",

+ 1 - 715
src/converse-controlbox.js

@@ -10,15 +10,14 @@
     define("converse-controlbox", [
             "converse-core",
             "converse-api",
+            // TODO: remove the next two dependencies
             "converse-rosterview",
-            // TODO: remove this dependency
             "converse-chatview"
     ], factory);
 }(this, function (converse, converse_api) {
     "use strict";
     // Strophe methods for building stanzas
     var Strophe = converse_api.env.Strophe,
-        $iq = converse_api.env.$iq,
         b64_sha1 = converse_api.env.b64_sha1,
         utils = converse_api.env.utils;
     // Other necessary globals
@@ -177,26 +176,7 @@
                 show_controlbox_by_default: false,
             });
 
-            var STATUSES = {
-                'dnd': __('This contact is busy'),
-                'online': __('This contact is online'),
-                'offline': __('This contact is offline'),
-                'unavailable': __('This contact is unavailable'),
-                'xa': __('This contact is away for an extended period'),
-                'away': __('This contact is away')
-            };
-            var DESC_GROUP_TOGGLE = __('Click to hide these contacts');
             var LABEL_CONTACTS = __('Contacts');
-            var LABEL_GROUPS = __('Groups');
-            var HEADER_CURRENT_CONTACTS =  __('My contacts');
-            var HEADER_PENDING_CONTACTS = __('Pending contacts');
-            var HEADER_REQUESTING_CONTACTS = __('Contact requests');
-            var HEADER_UNGROUPED = __('Ungrouped');
-            var HEADER_WEIGHTS = {};
-            HEADER_WEIGHTS[HEADER_CURRENT_CONTACTS]    = 0;
-            HEADER_WEIGHTS[HEADER_UNGROUPED]           = 1;
-            HEADER_WEIGHTS[HEADER_REQUESTING_CONTACTS] = 2;
-            HEADER_WEIGHTS[HEADER_PENDING_CONTACTS]    = 3;
 
             converse.addControlBox = function () {
                 return converse.chatboxes.add({
@@ -693,700 +673,6 @@
             });
 
 
-            converse.RosterContactView = Backbone.View.extend({
-                tagName: 'dd',
-
-                events: {
-                    "click .accept-xmpp-request": "acceptRequest",
-                    "click .decline-xmpp-request": "declineRequest",
-                    "click .open-chat": "openChat",
-                    "click .remove-xmpp-contact": "removeContact"
-                },
-
-                initialize: function () {
-                    this.model.on("change", this.render, this);
-                    this.model.on("remove", this.remove, this);
-                    this.model.on("destroy", this.remove, this);
-                    this.model.on("open", this.openChat, this);
-                },
-
-                render: function () {
-                    if (!this.model.showInRoster()) {
-                        this.$el.hide();
-                        return this;
-                    } else if (this.$el[0].style.display === "none") {
-                        this.$el.show();
-                    }
-                    var item = this.model,
-                        ask = item.get('ask'),
-                        chat_status = item.get('chat_status'),
-                        requesting  = item.get('requesting'),
-                        subscription = item.get('subscription');
-
-                    var classes_to_remove = [
-                        'current-xmpp-contact',
-                        'pending-xmpp-contact',
-                        'requesting-xmpp-contact'
-                        ].concat(_.keys(STATUSES));
-
-                    _.each(classes_to_remove,
-                        function (cls) {
-                            if (this.el.className.indexOf(cls) !== -1) {
-                                this.$el.removeClass(cls);
-                            }
-                        }, this);
-                    this.$el.addClass(chat_status).data('status', chat_status);
-
-                    if ((ask === 'subscribe') || (subscription === 'from')) {
-                        /* ask === 'subscribe'
-                        *      Means we have asked to subscribe to them.
-                        *
-                        * subscription === 'from'
-                        *      They are subscribed to use, but not vice versa.
-                        *      We assume that there is a pending subscription
-                        *      from us to them (otherwise we're in a state not
-                        *      supported by converse.js).
-                        *
-                        *  So in both cases the user is a "pending" contact.
-                        */
-                        this.$el.addClass('pending-xmpp-contact');
-                        this.$el.html(converse.templates.pending_contact(
-                            _.extend(item.toJSON(), {
-                                'desc_remove': __('Click to remove this contact'),
-                                'allow_chat_pending_contacts': converse.allow_chat_pending_contacts
-                            })
-                        ));
-                    } else if (requesting === true) {
-                        this.$el.addClass('requesting-xmpp-contact');
-                        this.$el.html(converse.templates.requesting_contact(
-                            _.extend(item.toJSON(), {
-                                'desc_accept': __("Click to accept this contact request"),
-                                'desc_decline': __("Click to decline this contact request"),
-                                'allow_chat_pending_contacts': converse.allow_chat_pending_contacts
-                            })
-                        ));
-                        converse.controlboxtoggle.showControlBox();
-                    } else if (subscription === 'both' || subscription === 'to') {
-                        this.$el.addClass('current-xmpp-contact');
-                        this.$el.removeClass(_.without(['both', 'to'], subscription)[0]).addClass(subscription);
-                        this.$el.html(converse.templates.roster_item(
-                            _.extend(item.toJSON(), {
-                                'desc_status': STATUSES[chat_status||'offline'],
-                                'desc_chat': __('Click to chat with this contact'),
-                                'desc_remove': __('Click to remove this contact'),
-                                'title_fullname': __('Name'),
-                                'allow_contact_removal': converse.allow_contact_removal
-                            })
-                        ));
-                    }
-                    return this;
-                },
-
-                openChat: function (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    return converse.chatboxviews.showChat(this.model.attributes);
-                },
-
-                removeContact: function (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    if (!converse.allow_contact_removal) { return; }
-                    var result = confirm(__("Are you sure you want to remove this contact?"));
-                    if (result === true) {
-                        var iq = $iq({type: 'set'})
-                            .c('query', {xmlns: Strophe.NS.ROSTER})
-                            .c('item', {jid: this.model.get('jid'), subscription: "remove"});
-                        converse.connection.sendIQ(iq,
-                            function (iq) {
-                                this.model.destroy();
-                                this.remove();
-                            }.bind(this),
-                            function (err) {
-                                alert(__("Sorry, there was an error while trying to remove "+name+" as a contact."));
-                                converse.log(err);
-                            }
-                        );
-                    }
-                },
-
-                acceptRequest: function (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    converse.roster.sendContactAddIQ(
-                        this.model.get('jid'),
-                        this.model.get('fullname'),
-                        [],
-                        function () { this.model.authorize().subscribe(); }.bind(this)
-                    );
-                },
-
-                declineRequest: function (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    var result = confirm(__("Are you sure you want to decline this contact request?"));
-                    if (result === true) {
-                        this.model.unauthorize().destroy();
-                    }
-                    return this;
-                }
-            });
-
-
-            converse.RosterGroup = Backbone.Model.extend({
-                initialize: function (attributes, options) {
-                    this.set(_.extend({
-                        description: DESC_GROUP_TOGGLE,
-                        state: converse.OPENED
-                    }, attributes));
-                    // Collection of contacts belonging to this group.
-                    this.contacts = new converse.RosterContacts();
-                }
-            });
-
-
-            converse.RosterGroupView = Backbone.Overview.extend({
-                tagName: 'dt',
-                className: 'roster-group',
-                events: {
-                    "click a.group-toggle": "toggle"
-                },
-
-                initialize: function () {
-                    this.model.contacts.on("add", this.addContact, this);
-                    this.model.contacts.on("change:subscription", this.onContactSubscriptionChange, this);
-                    this.model.contacts.on("change:requesting", this.onContactRequestChange, this);
-                    this.model.contacts.on("change:chat_status", function (contact) {
-                        // This might be optimized by instead of first sorting,
-                        // finding the correct position in positionContact
-                        this.model.contacts.sort();
-                        this.positionContact(contact).render();
-                    }, this);
-                    this.model.contacts.on("destroy", this.onRemove, this);
-                    this.model.contacts.on("remove", this.onRemove, this);
-                    converse.roster.on('change:groups', this.onContactGroupChange, this);
-                },
-
-                render: function () {
-                    this.$el.attr('data-group', this.model.get('name'));
-                    this.$el.html(
-                        $(converse.templates.group_header({
-                            label_group: this.model.get('name'),
-                            desc_group_toggle: this.model.get('description'),
-                            toggle_state: this.model.get('state')
-                        }))
-                    );
-                    return this;
-                },
-
-                addContact: function (contact) {
-                    var view = new converse.RosterContactView({model: contact});
-                    this.add(contact.get('id'), view);
-                    view = this.positionContact(contact).render();
-                    if (contact.showInRoster()) {
-                        if (this.model.get('state') === converse.CLOSED) {
-                            if (view.$el[0].style.display !== "none") { view.$el.hide(); }
-                            if (!this.$el.is(':visible')) { this.$el.show(); }
-                        } else {
-                            if (this.$el[0].style.display !== "block") { this.show(); }
-                        }
-                    }
-                },
-
-                positionContact: function (contact) {
-                    /* Place the contact's DOM element in the correct alphabetical
-                    * position amongst the other contacts in this group.
-                    */
-                    var view = this.get(contact.get('id'));
-                    var index = this.model.contacts.indexOf(contact);
-                    view.$el.detach();
-                    if (index === 0) {
-                        this.$el.after(view.$el);
-                    } else if (index === (this.model.contacts.length-1)) {
-                        this.$el.nextUntil('dt').last().after(view.$el);
-                    } else {
-                        this.$el.nextUntil('dt').eq(index).before(view.$el);
-                    }
-                    return view;
-                },
-
-                show: function () {
-                    this.$el.show();
-                    _.each(this.getAll(), function (contactView) {
-                        if (contactView.model.showInRoster()) {
-                            contactView.$el.show();
-                        }
-                    });
-                },
-
-                hide: function () {
-                    this.$el.nextUntil('dt').addBack().hide();
-                },
-
-                filter: function (q) {
-                    /* Filter the group's contacts based on the query "q".
-                    * The query is matched against the contact's full name.
-                    * If all contacts are filtered out (i.e. hidden), then the
-                    * group must be filtered out as well.
-                    */
-                    var matches;
-                    if (q.length === 0) {
-                        if (this.model.get('state') === converse.OPENED) {
-                            this.model.contacts.each(function (item) {
-                                if (item.showInRoster()) {
-                                    this.get(item.get('id')).$el.show();
-                                }
-                            }.bind(this));
-                        }
-                        this.showIfNecessary();
-                    } else {
-                        q = q.toLowerCase();
-                        matches = this.model.contacts.filter(utils.contains.not('fullname', q));
-                        if (matches.length === this.model.contacts.length) { // hide the whole group
-                            this.hide();
-                        } else {
-                            _.each(matches, function (item) {
-                                this.get(item.get('id')).$el.hide();
-                            }.bind(this));
-                            _.each(this.model.contacts.reject(utils.contains.not('fullname', q)), function (item) {
-                                this.get(item.get('id')).$el.show();
-                            }.bind(this));
-                            this.showIfNecessary();
-                        }
-                    }
-                },
-
-                showIfNecessary: function () {
-                    if (!this.$el.is(':visible') && this.model.contacts.length > 0) {
-                        this.$el.show();
-                    }
-                },
-
-                toggle: function (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    var $el = $(ev.target);
-                    if ($el.hasClass("icon-opened")) {
-                        this.$el.nextUntil('dt').slideUp();
-                        this.model.save({state: converse.CLOSED});
-                        $el.removeClass("icon-opened").addClass("icon-closed");
-                    } else {
-                        $el.removeClass("icon-closed").addClass("icon-opened");
-                        this.model.save({state: converse.OPENED});
-                        this.filter(
-                            converse.rosterview.$('.roster-filter').val(),
-                            converse.rosterview.$('.filter-type').val()
-                        );
-                    }
-                },
-
-                onContactGroupChange: function (contact) {
-                    var in_this_group = _.contains(contact.get('groups'), this.model.get('name'));
-                    var cid = contact.get('id');
-                    var in_this_overview = !this.get(cid);
-                    if (in_this_group && !in_this_overview) {
-                        this.model.contacts.remove(cid);
-                    } else if (!in_this_group && in_this_overview) {
-                        this.addContact(contact);
-                    }
-                },
-
-                onContactSubscriptionChange: function (contact) {
-                    if ((this.model.get('name') === HEADER_PENDING_CONTACTS) && contact.get('subscription') !== 'from') {
-                        this.model.contacts.remove(contact.get('id'));
-                    }
-                },
-
-                onContactRequestChange: function (contact) {
-                    if ((this.model.get('name') === HEADER_REQUESTING_CONTACTS) && !contact.get('requesting')) {
-                        this.model.contacts.remove(contact.get('id'));
-                    }
-                },
-
-                onRemove: function (contact) {
-                    this.remove(contact.get('id'));
-                    if (this.model.contacts.length === 0) {
-                        this.$el.hide();
-                    }
-                }
-            });
-
-
-            converse.RosterGroups = Backbone.Collection.extend({
-                model: converse.RosterGroup,
-                comparator: function (a, b) {
-                    /* Groups are sorted alphabetically, ignoring case.
-                    * However, Ungrouped, Requesting Contacts and Pending Contacts
-                    * appear last and in that order. */
-                    a = a.get('name');
-                    b = b.get('name');
-                    var special_groups = _.keys(HEADER_WEIGHTS);
-                    var a_is_special = _.contains(special_groups, a);
-                    var b_is_special = _.contains(special_groups, b);
-                    if (!a_is_special && !b_is_special ) {
-                        return a.toLowerCase() < b.toLowerCase() ? -1 : (a.toLowerCase() > b.toLowerCase() ? 1 : 0);
-                    } else if (a_is_special && b_is_special) {
-                        return HEADER_WEIGHTS[a] < HEADER_WEIGHTS[b] ? -1 : (HEADER_WEIGHTS[a] > HEADER_WEIGHTS[b] ? 1 : 0);
-                    } else if (!a_is_special && b_is_special) {
-                        return (b === HEADER_CURRENT_CONTACTS) ? 1 : -1;
-                    } else if (a_is_special && !b_is_special) {
-                        return (a === HEADER_CURRENT_CONTACTS) ? -1 : 1;
-                    }
-                }
-            });
-
-            converse.RosterView = Backbone.Overview.extend({
-                tagName: 'div',
-                id: 'converse-roster',
-                events: {
-                    "keydown .roster-filter": "liveFilter",
-                    "click .onX": "clearFilter",
-                    "mousemove .x": "togglePointer",
-                    "change .filter-type": "changeFilterType"
-                },
-
-                initialize: function () {
-                    this.roster_handler_ref = this.registerRosterHandler();
-                    this.rosterx_handler_ref = this.registerRosterXHandler();
-                    this.presence_ref = this.registerPresenceHandler();
-                    converse.roster.on("add", this.onContactAdd, this);
-                    converse.roster.on('change', this.onContactChange, this);
-                    converse.roster.on("destroy", this.update, this);
-                    converse.roster.on("remove", this.update, this);
-                    this.model.on("add", this.onGroupAdd, this);
-                    this.model.on("reset", this.reset, this);
-                    this.$roster = $('<dl class="roster-contacts" style="display: none;"></dl>');
-                },
-
-                unregisterHandlers: function () {
-                    converse.connection.deleteHandler(this.roster_handler_ref);
-                    delete this.roster_handler_ref;
-                    converse.connection.deleteHandler(this.rosterx_handler_ref);
-                    delete this.rosterx_handler_ref;
-                    converse.connection.deleteHandler(this.presence_ref);
-                    delete this.presence_ref;
-                },
-
-                update: _.debounce(function () {
-                    var $count = $('#online-count');
-                    $count.text('('+converse.roster.getNumOnlineContacts()+')');
-                    if (!$count.is(':visible')) {
-                        $count.show();
-                    }
-                    if (this.$roster.parent().length === 0) {
-                        this.$el.append(this.$roster.show());
-                    }
-                    return this.showHideFilter();
-                }, converse.animate ? 100 : 0),
-
-                render: function () {
-                    this.$el.html(converse.templates.roster({
-                        placeholder: __('Type to filter'),
-                        label_contacts: LABEL_CONTACTS,
-                        label_groups: LABEL_GROUPS
-                    }));
-                    if (!converse.allow_contact_requests) {
-                        // XXX: if we ever support live editing of config then
-                        // we'll need to be able to remove this class on the fly.
-                        this.$el.addClass('no-contact-requests');
-                    }
-                    return this;
-                },
-
-                fetch: function () {
-                    this.model.fetch({
-                        silent: true, // We use the success handler to handle groups that were added,
-                                    // we need to first have all groups before positionFetchedGroups
-                                    // will work properly.
-                        success: function (collection, resp, options) {
-                            if (collection.length !== 0) {
-                                this.positionFetchedGroups(collection, resp, options);
-                            }
-                            converse.roster.fetch({
-                                add: true,
-                                success: function (collection) {
-                                    if (collection.length === 0) {
-                                        /* We don't have any roster contacts stored in sessionStorage,
-                                        * so lets fetch the roster from the XMPP server. We pass in
-                                        * 'sendPresence' as callback method, because after initially
-                                        * fetching the roster we are ready to receive presence
-                                        * updates from our contacts.
-                                        */
-                                        converse.roster.fetchFromServer(function () {
-                                            converse.xmppstatus.sendPresence();
-                                        });
-                                    } else if (converse.send_initial_presence) {
-                                        /* We're not going to fetch the roster again because we have
-                                        * it already cached in sessionStorage, but we still need to
-                                        * send out a presence stanza because this is a new session.
-                                        * See: https://github.com/jcbrand/converse.js/issues/536
-                                        */
-                                        converse.xmppstatus.sendPresence();
-                                    }
-                                }
-                            });
-                        }.bind(this)
-                    });
-                    return this;
-                },
-
-                changeFilterType: function (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    this.clearFilter();
-                    this.filter(
-                        this.$('.roster-filter').val(),
-                        ev.target.value
-                    );
-                },
-
-                tog: function (v) {
-                    return v?'addClass':'removeClass';
-                },
-
-                togglePointer: function (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    var el = ev.target;
-                    $(el)[this.tog(el.offsetWidth-18 < ev.clientX-el.getBoundingClientRect().left)]('onX');
-                },
-
-                filter: function (query, type) {
-                    query = query.toLowerCase();
-                    if (type === 'groups') {
-                        _.each(this.getAll(), function (view, idx) {
-                            if (view.model.get('name').toLowerCase().indexOf(query.toLowerCase()) === -1) {
-                                view.hide();
-                            } else if (view.model.contacts.length > 0) {
-                                view.show();
-                            }
-                        });
-                    } else {
-                        _.each(this.getAll(), function (view) {
-                            view.filter(query, type);
-                        });
-                    }
-                },
-
-                liveFilter: _.debounce(function (ev) {
-                    if (ev && ev.preventDefault) { ev.preventDefault(); }
-                    var $filter = this.$('.roster-filter');
-                    var q = $filter.val();
-                    var t = this.$('.filter-type').val();
-                    $filter[this.tog(q)]('x');
-                    this.filter(q, t);
-                }, 300),
-
-                clearFilter: function (ev) {
-                    if (ev && ev.preventDefault) {
-                        ev.preventDefault();
-                        $(ev.target).removeClass('x onX').val('');
-                    }
-                    this.filter('');
-                },
-
-                showHideFilter: function () {
-                    if (!this.$el.is(':visible')) {
-                        return;
-                    }
-                    var $filter = this.$('.roster-filter');
-                    var $type  = this.$('.filter-type');
-                    var visible = $filter.is(':visible');
-                    if (visible && $filter.val().length > 0) {
-                        // Don't hide if user is currently filtering.
-                        return;
-                    }
-                    if (this.$roster.hasScrollBar()) {
-                        if (!visible) {
-                            $filter.show();
-                            $type.show();
-                        }
-                    } else {
-                        $filter.hide();
-                        $type.hide();
-                    }
-                    return this;
-                },
-
-                reset: function () {
-                    converse.roster.reset();
-                    this.removeAll();
-                    this.$roster = $('<dl class="roster-contacts" style="display: none;"></dl>');
-                    this.render().update();
-                    return this;
-                },
-
-                registerRosterHandler: function () {
-                    converse.connection.addHandler(
-                        converse.roster.onRosterPush.bind(converse.roster),
-                        Strophe.NS.ROSTER, 'iq', "set"
-                    );
-                },
-
-                registerRosterXHandler: function () {
-                    var t = 0;
-                    converse.connection.addHandler(
-                        function (msg) {
-                            window.setTimeout(
-                                function () {
-                                    converse.connection.flush();
-                                    converse.roster.subscribeToSuggestedItems.bind(converse.roster)(msg);
-                                },
-                                t
-                            );
-                            t += $(msg).find('item').length*250;
-                            return true;
-                        },
-                        Strophe.NS.ROSTERX, 'message', null
-                    );
-                },
-
-                registerPresenceHandler: function () {
-                    converse.connection.addHandler(
-                        function (presence) {
-                            converse.roster.presenceHandler(presence);
-                            return true;
-                        }.bind(this), null, 'presence', null);
-                },
-
-                onGroupAdd: function (group) {
-                    var view = new converse.RosterGroupView({model: group});
-                    this.add(group.get('name'), view.render());
-                    this.positionGroup(view);
-                },
-
-                onContactAdd: function (contact) {
-                    this.addRosterContact(contact).update();
-                    if (!contact.get('vcard_updated')) {
-                        // This will update the vcard, which triggers a change
-                        // request which will rerender the roster contact.
-                        converse.getVCard(contact.get('jid'));
-                    }
-                },
-
-                onContactChange: function (contact) {
-                    this.updateChatBox(contact).update();
-                    if (_.has(contact.changed, 'subscription')) {
-                        if (contact.changed.subscription === 'from') {
-                            this.addContactToGroup(contact, HEADER_PENDING_CONTACTS);
-                        } else if (_.contains(['both', 'to'], contact.get('subscription'))) {
-                            this.addExistingContact(contact);
-                        }
-                    }
-                    if (_.has(contact.changed, 'ask') && contact.changed.ask === 'subscribe') {
-                        this.addContactToGroup(contact, HEADER_PENDING_CONTACTS);
-                    }
-                    if (_.has(contact.changed, 'subscription') && contact.changed.requesting === 'true') {
-                        this.addContactToGroup(contact, HEADER_REQUESTING_CONTACTS);
-                    }
-                    this.liveFilter();
-                },
-
-                updateChatBox: function (contact) {
-                    var chatbox = converse.chatboxes.get(contact.get('jid')),
-                        changes = {};
-                    if (!chatbox) {
-                        return this;
-                    }
-                    if (_.has(contact.changed, 'chat_status')) {
-                        changes.chat_status = contact.get('chat_status');
-                    }
-                    if (_.has(contact.changed, 'status')) {
-                        changes.status = contact.get('status');
-                    }
-                    chatbox.save(changes);
-                    return this;
-                },
-
-                positionFetchedGroups: function (model, resp, options) {
-                    /* Instead of throwing an add event for each group
-                    * fetched, we wait until they're all fetched and then
-                    * we position them.
-                    * Works around the problem of positionGroup not
-                    * working when all groups besides the one being
-                    * positioned aren't already in inserted into the
-                    * roster DOM element.
-                    */
-                    model.sort();
-                    model.each(function (group, idx) {
-                        var view = this.get(group.get('name'));
-                        if (!view) {
-                            view = new converse.RosterGroupView({model: group});
-                            this.add(group.get('name'), view.render());
-                        }
-                        if (idx === 0) {
-                            this.$roster.append(view.$el);
-                        } else {
-                            this.appendGroup(view);
-                        }
-                    }.bind(this));
-                },
-
-                positionGroup: function (view) {
-                    /* Place the group's DOM element in the correct alphabetical
-                    * position amongst the other groups in the roster.
-                    */
-                    var $groups = this.$roster.find('.roster-group'),
-                        index = $groups.length ? this.model.indexOf(view.model) : 0;
-                    if (index === 0) {
-                        this.$roster.prepend(view.$el);
-                    } else if (index === (this.model.length-1)) {
-                        this.appendGroup(view);
-                    } else {
-                        $($groups.eq(index)).before(view.$el);
-                    }
-                    return this;
-                },
-
-                appendGroup: function (view) {
-                    /* Add the group at the bottom of the roster
-                    */
-                    var $last = this.$roster.find('.roster-group').last();
-                    var $siblings = $last.siblings('dd');
-                    if ($siblings.length > 0) {
-                        $siblings.last().after(view.$el);
-                    } else {
-                        $last.after(view.$el);
-                    }
-                    return this;
-                },
-
-                getGroup: function (name) {
-                    /* Returns the group as specified by name.
-                    * Creates the group if it doesn't exist.
-                    */
-                    var view =  this.get(name);
-                    if (view) {
-                        return view.model;
-                    }
-                    return this.model.create({name: name, id: b64_sha1(name)});
-                },
-
-                addContactToGroup: function (contact, name) {
-                    this.getGroup(name).contacts.add(contact);
-                },
-
-                addExistingContact: function (contact) {
-                    var groups;
-                    if (converse.roster_groups) {
-                        groups = contact.get('groups');
-                        if (groups.length === 0) {
-                            groups = [HEADER_UNGROUPED];
-                        }
-                    } else {
-                        groups = [HEADER_CURRENT_CONTACTS];
-                    }
-                    _.each(groups, _.bind(this.addContactToGroup, this, contact));
-                },
-
-                addRosterContact: function (contact) {
-                    if (contact.get('subscription') === 'both' || contact.get('subscription') === 'to') {
-                        this.addExistingContact(contact);
-                    } else {
-                        if ((contact.get('ask') === 'subscribe') || (contact.get('subscription') === 'from')) {
-                            this.addContactToGroup(contact, HEADER_PENDING_CONTACTS);
-                        } else if (contact.get('requesting') === true) {
-                            this.addContactToGroup(contact, HEADER_REQUESTING_CONTACTS);
-                        }
-                    }
-                    return this;
-                }
-            });
-
-
             converse.ControlBoxToggle = Backbone.View.extend({
                 tagName: 'a',
                 className: 'toggle-controlbox',

+ 7 - 6
src/converse-core.js

@@ -761,17 +761,18 @@
             }.bind(this), 200));
         };
 
+        this.afterReconnected = function () {
+            this.chatboxes.registerMessageHandler();
+            this.xmppstatus.sendPresence();
+            this.giveFeedback(__('Contacts'));
+        };
+
         this.onReconnected = function () {
             // We need to re-register all the event handlers on the newly
             // created connection.
             var deferred = new $.Deferred();
             this.initStatus(function () {
-                // FIXME: leaky abstraction from RosterView
-                this.rosterview.registerRosterXHandler();
-                this.rosterview.registerPresenceHandler();
-                this.chatboxes.registerMessageHandler();
-                this.xmppstatus.sendPresence();
-                this.giveFeedback(__('Contacts'));
+                this.afterReconnected();
                 deferred.resolve();
             }.bind(this));
             return deferred.promise();

+ 0 - 1
src/converse-notification.js

@@ -36,7 +36,6 @@
                 notification_icon: '/logo/conversejs.png'
             });
 
-
             converse.isOnlyChatStateNotification = function ($msg) {
                 // See XEP-0085 Chat State Notification
                 return (

+ 764 - 0
src/converse-rosterview.js

@@ -0,0 +1,764 @@
+// Converse.js (A browser based XMPP chat client)
+// http://conversejs.org
+//
+// Copyright (c) 2012-2016, Jan-Carel Brand <jc@opkode.com>
+// Licensed under the Mozilla Public License (MPLv2)
+//
+/*global Backbone, define */
+
+(function (root, factory) {
+    define("converse-rosterview", ["converse-core", "converse-api"], factory);
+}(this, function (converse, converse_api) {
+    "use strict";
+    var $ = converse_api.env.jQuery,
+        utils = converse_api.env.utils,
+        Strophe = converse_api.env.Strophe,
+        $iq = converse_api.env.$iq,
+        b64_sha1 = converse_api.env.b64_sha1,
+        _ = converse_api.env._,
+        __ = utils.__.bind(converse);
+
+
+    converse_api.plugins.add('rosterview', {
+
+        overrides: {
+            // Overrides mentioned here will be picked up by converse.js's
+            // plugin architecture they will replace existing methods on the
+            // relevant objects or classes.
+            //
+            // New functions which don't exist yet can also be added.
+
+            afterReconnected: function () {
+                this.rosterview.registerRosterXHandler();
+                this.rosterview.registerPresenceHandler();
+                this._super.afterReconnected.apply(this, arguments);
+            }
+        },
+
+
+        initialize: function () {
+            /* The initialize function gets called as soon as the plugin is
+             * loaded by converse.js's plugin machinery.
+             */
+            this.updateSettings({
+                show_toolbar: true,
+            });
+
+            var STATUSES = {
+                'dnd': __('This contact is busy'),
+                'online': __('This contact is online'),
+                'offline': __('This contact is offline'),
+                'unavailable': __('This contact is unavailable'),
+                'xa': __('This contact is away for an extended period'),
+                'away': __('This contact is away')
+            };
+            var DESC_GROUP_TOGGLE = __('Click to hide these contacts');
+            var LABEL_CONTACTS = __('Contacts');
+            var LABEL_GROUPS = __('Groups');
+            var HEADER_CURRENT_CONTACTS =  __('My contacts');
+            var HEADER_PENDING_CONTACTS = __('Pending contacts');
+            var HEADER_REQUESTING_CONTACTS = __('Contact requests');
+            var HEADER_UNGROUPED = __('Ungrouped');
+            var HEADER_WEIGHTS = {};
+            HEADER_WEIGHTS[HEADER_CURRENT_CONTACTS]    = 0;
+            HEADER_WEIGHTS[HEADER_UNGROUPED]           = 1;
+            HEADER_WEIGHTS[HEADER_REQUESTING_CONTACTS] = 2;
+            HEADER_WEIGHTS[HEADER_PENDING_CONTACTS]    = 3;
+
+
+            converse.RosterView = Backbone.Overview.extend({
+                tagName: 'div',
+                id: 'converse-roster',
+                events: {
+                    "keydown .roster-filter": "liveFilter",
+                    "click .onX": "clearFilter",
+                    "mousemove .x": "togglePointer",
+                    "change .filter-type": "changeFilterType"
+                },
+
+                initialize: function () {
+                    this.roster_handler_ref = this.registerRosterHandler();
+                    this.rosterx_handler_ref = this.registerRosterXHandler();
+                    this.presence_ref = this.registerPresenceHandler();
+                    converse.roster.on("add", this.onContactAdd, this);
+                    converse.roster.on('change', this.onContactChange, this);
+                    converse.roster.on("destroy", this.update, this);
+                    converse.roster.on("remove", this.update, this);
+                    this.model.on("add", this.onGroupAdd, this);
+                    this.model.on("reset", this.reset, this);
+                    this.$roster = $('<dl class="roster-contacts" style="display: none;"></dl>');
+                },
+
+                unregisterHandlers: function () {
+                    converse.connection.deleteHandler(this.roster_handler_ref);
+                    delete this.roster_handler_ref;
+                    converse.connection.deleteHandler(this.rosterx_handler_ref);
+                    delete this.rosterx_handler_ref;
+                    converse.connection.deleteHandler(this.presence_ref);
+                    delete this.presence_ref;
+                },
+
+                update: _.debounce(function () {
+                    var $count = $('#online-count');
+                    $count.text('('+converse.roster.getNumOnlineContacts()+')');
+                    if (!$count.is(':visible')) {
+                        $count.show();
+                    }
+                    if (this.$roster.parent().length === 0) {
+                        this.$el.append(this.$roster.show());
+                    }
+                    return this.showHideFilter();
+                }, converse.animate ? 100 : 0),
+
+                render: function () {
+                    this.$el.html(converse.templates.roster({
+                        placeholder: __('Type to filter'),
+                        label_contacts: LABEL_CONTACTS,
+                        label_groups: LABEL_GROUPS
+                    }));
+                    if (!converse.allow_contact_requests) {
+                        // XXX: if we ever support live editing of config then
+                        // we'll need to be able to remove this class on the fly.
+                        this.$el.addClass('no-contact-requests');
+                    }
+                    return this;
+                },
+
+                fetch: function () {
+                    this.model.fetch({
+                        silent: true, // We use the success handler to handle groups that were added,
+                                    // we need to first have all groups before positionFetchedGroups
+                                    // will work properly.
+                        success: function (collection, resp, options) {
+                            if (collection.length !== 0) {
+                                this.positionFetchedGroups(collection, resp, options);
+                            }
+                            converse.roster.fetch({
+                                add: true,
+                                success: function (collection) {
+                                    if (collection.length === 0) {
+                                        /* We don't have any roster contacts stored in sessionStorage,
+                                        * so lets fetch the roster from the XMPP server. We pass in
+                                        * 'sendPresence' as callback method, because after initially
+                                        * fetching the roster we are ready to receive presence
+                                        * updates from our contacts.
+                                        */
+                                        converse.roster.fetchFromServer(function () {
+                                            converse.xmppstatus.sendPresence();
+                                        });
+                                    } else if (converse.send_initial_presence) {
+                                        /* We're not going to fetch the roster again because we have
+                                        * it already cached in sessionStorage, but we still need to
+                                        * send out a presence stanza because this is a new session.
+                                        * See: https://github.com/jcbrand/converse.js/issues/536
+                                        */
+                                        converse.xmppstatus.sendPresence();
+                                    }
+                                }
+                            });
+                        }.bind(this)
+                    });
+                    return this;
+                },
+
+                changeFilterType: function (ev) {
+                    if (ev && ev.preventDefault) { ev.preventDefault(); }
+                    this.clearFilter();
+                    this.filter(
+                        this.$('.roster-filter').val(),
+                        ev.target.value
+                    );
+                },
+
+                tog: function (v) {
+                    return v?'addClass':'removeClass';
+                },
+
+                togglePointer: function (ev) {
+                    if (ev && ev.preventDefault) { ev.preventDefault(); }
+                    var el = ev.target;
+                    $(el)[this.tog(el.offsetWidth-18 < ev.clientX-el.getBoundingClientRect().left)]('onX');
+                },
+
+                filter: function (query, type) {
+                    query = query.toLowerCase();
+                    if (type === 'groups') {
+                        _.each(this.getAll(), function (view, idx) {
+                            if (view.model.get('name').toLowerCase().indexOf(query.toLowerCase()) === -1) {
+                                view.hide();
+                            } else if (view.model.contacts.length > 0) {
+                                view.show();
+                            }
+                        });
+                    } else {
+                        _.each(this.getAll(), function (view) {
+                            view.filter(query, type);
+                        });
+                    }
+                },
+
+                liveFilter: _.debounce(function (ev) {
+                    if (ev && ev.preventDefault) { ev.preventDefault(); }
+                    var $filter = this.$('.roster-filter');
+                    var q = $filter.val();
+                    var t = this.$('.filter-type').val();
+                    $filter[this.tog(q)]('x');
+                    this.filter(q, t);
+                }, 300),
+
+                clearFilter: function (ev) {
+                    if (ev && ev.preventDefault) {
+                        ev.preventDefault();
+                        $(ev.target).removeClass('x onX').val('');
+                    }
+                    this.filter('');
+                },
+
+                showHideFilter: function () {
+                    if (!this.$el.is(':visible')) {
+                        return;
+                    }
+                    var $filter = this.$('.roster-filter');
+                    var $type  = this.$('.filter-type');
+                    var visible = $filter.is(':visible');
+                    if (visible && $filter.val().length > 0) {
+                        // Don't hide if user is currently filtering.
+                        return;
+                    }
+                    if (this.$roster.hasScrollBar()) {
+                        if (!visible) {
+                            $filter.show();
+                            $type.show();
+                        }
+                    } else {
+                        $filter.hide();
+                        $type.hide();
+                    }
+                    return this;
+                },
+
+                reset: function () {
+                    converse.roster.reset();
+                    this.removeAll();
+                    this.$roster = $('<dl class="roster-contacts" style="display: none;"></dl>');
+                    this.render().update();
+                    return this;
+                },
+
+                registerRosterHandler: function () {
+                    converse.connection.addHandler(
+                        converse.roster.onRosterPush.bind(converse.roster),
+                        Strophe.NS.ROSTER, 'iq', "set"
+                    );
+                },
+
+                registerRosterXHandler: function () {
+                    var t = 0;
+                    converse.connection.addHandler(
+                        function (msg) {
+                            window.setTimeout(
+                                function () {
+                                    converse.connection.flush();
+                                    converse.roster.subscribeToSuggestedItems.bind(converse.roster)(msg);
+                                },
+                                t
+                            );
+                            t += $(msg).find('item').length*250;
+                            return true;
+                        },
+                        Strophe.NS.ROSTERX, 'message', null
+                    );
+                },
+
+                registerPresenceHandler: function () {
+                    converse.connection.addHandler(
+                        function (presence) {
+                            converse.roster.presenceHandler(presence);
+                            return true;
+                        }.bind(this), null, 'presence', null);
+                },
+
+                onGroupAdd: function (group) {
+                    var view = new converse.RosterGroupView({model: group});
+                    this.add(group.get('name'), view.render());
+                    this.positionGroup(view);
+                },
+
+                onContactAdd: function (contact) {
+                    this.addRosterContact(contact).update();
+                    if (!contact.get('vcard_updated')) {
+                        // This will update the vcard, which triggers a change
+                        // request which will rerender the roster contact.
+                        converse.getVCard(contact.get('jid'));
+                    }
+                },
+
+                onContactChange: function (contact) {
+                    this.updateChatBox(contact).update();
+                    if (_.has(contact.changed, 'subscription')) {
+                        if (contact.changed.subscription === 'from') {
+                            this.addContactToGroup(contact, HEADER_PENDING_CONTACTS);
+                        } else if (_.contains(['both', 'to'], contact.get('subscription'))) {
+                            this.addExistingContact(contact);
+                        }
+                    }
+                    if (_.has(contact.changed, 'ask') && contact.changed.ask === 'subscribe') {
+                        this.addContactToGroup(contact, HEADER_PENDING_CONTACTS);
+                    }
+                    if (_.has(contact.changed, 'subscription') && contact.changed.requesting === 'true') {
+                        this.addContactToGroup(contact, HEADER_REQUESTING_CONTACTS);
+                    }
+                    this.liveFilter();
+                },
+
+                updateChatBox: function (contact) {
+                    var chatbox = converse.chatboxes.get(contact.get('jid')),
+                        changes = {};
+                    if (!chatbox) {
+                        return this;
+                    }
+                    if (_.has(contact.changed, 'chat_status')) {
+                        changes.chat_status = contact.get('chat_status');
+                    }
+                    if (_.has(contact.changed, 'status')) {
+                        changes.status = contact.get('status');
+                    }
+                    chatbox.save(changes);
+                    return this;
+                },
+
+                positionFetchedGroups: function (model, resp, options) {
+                    /* Instead of throwing an add event for each group
+                    * fetched, we wait until they're all fetched and then
+                    * we position them.
+                    * Works around the problem of positionGroup not
+                    * working when all groups besides the one being
+                    * positioned aren't already in inserted into the
+                    * roster DOM element.
+                    */
+                    model.sort();
+                    model.each(function (group, idx) {
+                        var view = this.get(group.get('name'));
+                        if (!view) {
+                            view = new converse.RosterGroupView({model: group});
+                            this.add(group.get('name'), view.render());
+                        }
+                        if (idx === 0) {
+                            this.$roster.append(view.$el);
+                        } else {
+                            this.appendGroup(view);
+                        }
+                    }.bind(this));
+                },
+
+                positionGroup: function (view) {
+                    /* Place the group's DOM element in the correct alphabetical
+                    * position amongst the other groups in the roster.
+                    */
+                    var $groups = this.$roster.find('.roster-group'),
+                        index = $groups.length ? this.model.indexOf(view.model) : 0;
+                    if (index === 0) {
+                        this.$roster.prepend(view.$el);
+                    } else if (index === (this.model.length-1)) {
+                        this.appendGroup(view);
+                    } else {
+                        $($groups.eq(index)).before(view.$el);
+                    }
+                    return this;
+                },
+
+                appendGroup: function (view) {
+                    /* Add the group at the bottom of the roster
+                    */
+                    var $last = this.$roster.find('.roster-group').last();
+                    var $siblings = $last.siblings('dd');
+                    if ($siblings.length > 0) {
+                        $siblings.last().after(view.$el);
+                    } else {
+                        $last.after(view.$el);
+                    }
+                    return this;
+                },
+
+                getGroup: function (name) {
+                    /* Returns the group as specified by name.
+                    * Creates the group if it doesn't exist.
+                    */
+                    var view =  this.get(name);
+                    if (view) {
+                        return view.model;
+                    }
+                    return this.model.create({name: name, id: b64_sha1(name)});
+                },
+
+                addContactToGroup: function (contact, name) {
+                    this.getGroup(name).contacts.add(contact);
+                },
+
+                addExistingContact: function (contact) {
+                    var groups;
+                    if (converse.roster_groups) {
+                        groups = contact.get('groups');
+                        if (groups.length === 0) {
+                            groups = [HEADER_UNGROUPED];
+                        }
+                    } else {
+                        groups = [HEADER_CURRENT_CONTACTS];
+                    }
+                    _.each(groups, _.bind(this.addContactToGroup, this, contact));
+                },
+
+                addRosterContact: function (contact) {
+                    if (contact.get('subscription') === 'both' || contact.get('subscription') === 'to') {
+                        this.addExistingContact(contact);
+                    } else {
+                        if ((contact.get('ask') === 'subscribe') || (contact.get('subscription') === 'from')) {
+                            this.addContactToGroup(contact, HEADER_PENDING_CONTACTS);
+                        } else if (contact.get('requesting') === true) {
+                            this.addContactToGroup(contact, HEADER_REQUESTING_CONTACTS);
+                        }
+                    }
+                    return this;
+                }
+            });
+
+
+            converse.RosterContactView = Backbone.View.extend({
+                tagName: 'dd',
+
+                events: {
+                    "click .accept-xmpp-request": "acceptRequest",
+                    "click .decline-xmpp-request": "declineRequest",
+                    "click .open-chat": "openChat",
+                    "click .remove-xmpp-contact": "removeContact"
+                },
+
+                initialize: function () {
+                    this.model.on("change", this.render, this);
+                    this.model.on("remove", this.remove, this);
+                    this.model.on("destroy", this.remove, this);
+                    this.model.on("open", this.openChat, this);
+                },
+
+                render: function () {
+                    if (!this.model.showInRoster()) {
+                        this.$el.hide();
+                        return this;
+                    } else if (this.$el[0].style.display === "none") {
+                        this.$el.show();
+                    }
+                    var item = this.model,
+                        ask = item.get('ask'),
+                        chat_status = item.get('chat_status'),
+                        requesting  = item.get('requesting'),
+                        subscription = item.get('subscription');
+
+                    var classes_to_remove = [
+                        'current-xmpp-contact',
+                        'pending-xmpp-contact',
+                        'requesting-xmpp-contact'
+                        ].concat(_.keys(STATUSES));
+
+                    _.each(classes_to_remove,
+                        function (cls) {
+                            if (this.el.className.indexOf(cls) !== -1) {
+                                this.$el.removeClass(cls);
+                            }
+                        }, this);
+                    this.$el.addClass(chat_status).data('status', chat_status);
+
+                    if ((ask === 'subscribe') || (subscription === 'from')) {
+                        /* ask === 'subscribe'
+                        *      Means we have asked to subscribe to them.
+                        *
+                        * subscription === 'from'
+                        *      They are subscribed to use, but not vice versa.
+                        *      We assume that there is a pending subscription
+                        *      from us to them (otherwise we're in a state not
+                        *      supported by converse.js).
+                        *
+                        *  So in both cases the user is a "pending" contact.
+                        */
+                        this.$el.addClass('pending-xmpp-contact');
+                        this.$el.html(converse.templates.pending_contact(
+                            _.extend(item.toJSON(), {
+                                'desc_remove': __('Click to remove this contact'),
+                                'allow_chat_pending_contacts': converse.allow_chat_pending_contacts
+                            })
+                        ));
+                    } else if (requesting === true) {
+                        this.$el.addClass('requesting-xmpp-contact');
+                        this.$el.html(converse.templates.requesting_contact(
+                            _.extend(item.toJSON(), {
+                                'desc_accept': __("Click to accept this contact request"),
+                                'desc_decline': __("Click to decline this contact request"),
+                                'allow_chat_pending_contacts': converse.allow_chat_pending_contacts
+                            })
+                        ));
+                        converse.controlboxtoggle.showControlBox();
+                    } else if (subscription === 'both' || subscription === 'to') {
+                        this.$el.addClass('current-xmpp-contact');
+                        this.$el.removeClass(_.without(['both', 'to'], subscription)[0]).addClass(subscription);
+                        this.$el.html(converse.templates.roster_item(
+                            _.extend(item.toJSON(), {
+                                'desc_status': STATUSES[chat_status||'offline'],
+                                'desc_chat': __('Click to chat with this contact'),
+                                'desc_remove': __('Click to remove this contact'),
+                                'title_fullname': __('Name'),
+                                'allow_contact_removal': converse.allow_contact_removal
+                            })
+                        ));
+                    }
+                    return this;
+                },
+
+                openChat: function (ev) {
+                    if (ev && ev.preventDefault) { ev.preventDefault(); }
+                    return converse.chatboxviews.showChat(this.model.attributes);
+                },
+
+                removeContact: function (ev) {
+                    if (ev && ev.preventDefault) { ev.preventDefault(); }
+                    if (!converse.allow_contact_removal) { return; }
+                    var result = confirm(__("Are you sure you want to remove this contact?"));
+                    if (result === true) {
+                        var iq = $iq({type: 'set'})
+                            .c('query', {xmlns: Strophe.NS.ROSTER})
+                            .c('item', {jid: this.model.get('jid'), subscription: "remove"});
+                        converse.connection.sendIQ(iq,
+                            function (iq) {
+                                this.model.destroy();
+                                this.remove();
+                            }.bind(this),
+                            function (err) {
+                                alert(__("Sorry, there was an error while trying to remove "+name+" as a contact."));
+                                converse.log(err);
+                            }
+                        );
+                    }
+                },
+
+                acceptRequest: function (ev) {
+                    if (ev && ev.preventDefault) { ev.preventDefault(); }
+                    converse.roster.sendContactAddIQ(
+                        this.model.get('jid'),
+                        this.model.get('fullname'),
+                        [],
+                        function () { this.model.authorize().subscribe(); }.bind(this)
+                    );
+                },
+
+                declineRequest: function (ev) {
+                    if (ev && ev.preventDefault) { ev.preventDefault(); }
+                    var result = confirm(__("Are you sure you want to decline this contact request?"));
+                    if (result === true) {
+                        this.model.unauthorize().destroy();
+                    }
+                    return this;
+                }
+            });
+
+
+            converse.RosterGroup = Backbone.Model.extend({
+                initialize: function (attributes, options) {
+                    this.set(_.extend({
+                        description: DESC_GROUP_TOGGLE,
+                        state: converse.OPENED
+                    }, attributes));
+                    // Collection of contacts belonging to this group.
+                    this.contacts = new converse.RosterContacts();
+                }
+            });
+
+
+            converse.RosterGroupView = Backbone.Overview.extend({
+                tagName: 'dt',
+                className: 'roster-group',
+                events: {
+                    "click a.group-toggle": "toggle"
+                },
+
+                initialize: function () {
+                    this.model.contacts.on("add", this.addContact, this);
+                    this.model.contacts.on("change:subscription", this.onContactSubscriptionChange, this);
+                    this.model.contacts.on("change:requesting", this.onContactRequestChange, this);
+                    this.model.contacts.on("change:chat_status", function (contact) {
+                        // This might be optimized by instead of first sorting,
+                        // finding the correct position in positionContact
+                        this.model.contacts.sort();
+                        this.positionContact(contact).render();
+                    }, this);
+                    this.model.contacts.on("destroy", this.onRemove, this);
+                    this.model.contacts.on("remove", this.onRemove, this);
+                    converse.roster.on('change:groups', this.onContactGroupChange, this);
+                },
+
+                render: function () {
+                    this.$el.attr('data-group', this.model.get('name'));
+                    this.$el.html(
+                        $(converse.templates.group_header({
+                            label_group: this.model.get('name'),
+                            desc_group_toggle: this.model.get('description'),
+                            toggle_state: this.model.get('state')
+                        }))
+                    );
+                    return this;
+                },
+
+                addContact: function (contact) {
+                    var view = new converse.RosterContactView({model: contact});
+                    this.add(contact.get('id'), view);
+                    view = this.positionContact(contact).render();
+                    if (contact.showInRoster()) {
+                        if (this.model.get('state') === converse.CLOSED) {
+                            if (view.$el[0].style.display !== "none") { view.$el.hide(); }
+                            if (!this.$el.is(':visible')) { this.$el.show(); }
+                        } else {
+                            if (this.$el[0].style.display !== "block") { this.show(); }
+                        }
+                    }
+                },
+
+                positionContact: function (contact) {
+                    /* Place the contact's DOM element in the correct alphabetical
+                    * position amongst the other contacts in this group.
+                    */
+                    var view = this.get(contact.get('id'));
+                    var index = this.model.contacts.indexOf(contact);
+                    view.$el.detach();
+                    if (index === 0) {
+                        this.$el.after(view.$el);
+                    } else if (index === (this.model.contacts.length-1)) {
+                        this.$el.nextUntil('dt').last().after(view.$el);
+                    } else {
+                        this.$el.nextUntil('dt').eq(index).before(view.$el);
+                    }
+                    return view;
+                },
+
+                show: function () {
+                    this.$el.show();
+                    _.each(this.getAll(), function (contactView) {
+                        if (contactView.model.showInRoster()) {
+                            contactView.$el.show();
+                        }
+                    });
+                },
+
+                hide: function () {
+                    this.$el.nextUntil('dt').addBack().hide();
+                },
+
+                filter: function (q) {
+                    /* Filter the group's contacts based on the query "q".
+                    * The query is matched against the contact's full name.
+                    * If all contacts are filtered out (i.e. hidden), then the
+                    * group must be filtered out as well.
+                    */
+                    var matches;
+                    if (q.length === 0) {
+                        if (this.model.get('state') === converse.OPENED) {
+                            this.model.contacts.each(function (item) {
+                                if (item.showInRoster()) {
+                                    this.get(item.get('id')).$el.show();
+                                }
+                            }.bind(this));
+                        }
+                        this.showIfNecessary();
+                    } else {
+                        q = q.toLowerCase();
+                        matches = this.model.contacts.filter(utils.contains.not('fullname', q));
+                        if (matches.length === this.model.contacts.length) { // hide the whole group
+                            this.hide();
+                        } else {
+                            _.each(matches, function (item) {
+                                this.get(item.get('id')).$el.hide();
+                            }.bind(this));
+                            _.each(this.model.contacts.reject(utils.contains.not('fullname', q)), function (item) {
+                                this.get(item.get('id')).$el.show();
+                            }.bind(this));
+                            this.showIfNecessary();
+                        }
+                    }
+                },
+
+                showIfNecessary: function () {
+                    if (!this.$el.is(':visible') && this.model.contacts.length > 0) {
+                        this.$el.show();
+                    }
+                },
+
+                toggle: function (ev) {
+                    if (ev && ev.preventDefault) { ev.preventDefault(); }
+                    var $el = $(ev.target);
+                    if ($el.hasClass("icon-opened")) {
+                        this.$el.nextUntil('dt').slideUp();
+                        this.model.save({state: converse.CLOSED});
+                        $el.removeClass("icon-opened").addClass("icon-closed");
+                    } else {
+                        $el.removeClass("icon-closed").addClass("icon-opened");
+                        this.model.save({state: converse.OPENED});
+                        this.filter(
+                            converse.rosterview.$('.roster-filter').val(),
+                            converse.rosterview.$('.filter-type').val()
+                        );
+                    }
+                },
+
+                onContactGroupChange: function (contact) {
+                    var in_this_group = _.contains(contact.get('groups'), this.model.get('name'));
+                    var cid = contact.get('id');
+                    var in_this_overview = !this.get(cid);
+                    if (in_this_group && !in_this_overview) {
+                        this.model.contacts.remove(cid);
+                    } else if (!in_this_group && in_this_overview) {
+                        this.addContact(contact);
+                    }
+                },
+
+                onContactSubscriptionChange: function (contact) {
+                    if ((this.model.get('name') === HEADER_PENDING_CONTACTS) && contact.get('subscription') !== 'from') {
+                        this.model.contacts.remove(contact.get('id'));
+                    }
+                },
+
+                onContactRequestChange: function (contact) {
+                    if ((this.model.get('name') === HEADER_REQUESTING_CONTACTS) && !contact.get('requesting')) {
+                        this.model.contacts.remove(contact.get('id'));
+                    }
+                },
+
+                onRemove: function (contact) {
+                    this.remove(contact.get('id'));
+                    if (this.model.contacts.length === 0) {
+                        this.$el.hide();
+                    }
+                }
+            });
+
+
+            converse.RosterGroups = Backbone.Collection.extend({
+                model: converse.RosterGroup,
+                comparator: function (a, b) {
+                    /* Groups are sorted alphabetically, ignoring case.
+                    * However, Ungrouped, Requesting Contacts and Pending Contacts
+                    * appear last and in that order. */
+                    a = a.get('name');
+                    b = b.get('name');
+                    var special_groups = _.keys(HEADER_WEIGHTS);
+                    var a_is_special = _.contains(special_groups, a);
+                    var b_is_special = _.contains(special_groups, b);
+                    if (!a_is_special && !b_is_special ) {
+                        return a.toLowerCase() < b.toLowerCase() ? -1 : (a.toLowerCase() > b.toLowerCase() ? 1 : 0);
+                    } else if (a_is_special && b_is_special) {
+                        return HEADER_WEIGHTS[a] < HEADER_WEIGHTS[b] ? -1 : (HEADER_WEIGHTS[a] > HEADER_WEIGHTS[b] ? 1 : 0);
+                    } else if (!a_is_special && b_is_special) {
+                        return (b === HEADER_CURRENT_CONTACTS) ? 1 : -1;
+                    } else if (a_is_special && !b_is_special) {
+                        return (a === HEADER_CURRENT_CONTACTS) ? -1 : 1;
+                    }
+                }
+            });
+        }
+    });
+}));