فهرست منبع

Merge branch 'groups-refactor'

Conflicts:
	index.html
JC Brand 11 سال پیش
والد
کامیت
8c20388ba9

+ 1 - 0
.gitignore

@@ -8,6 +8,7 @@
 .svn/
 .project
 .pydevproject
+Backbone.Overview
 node_modules
 components
 docs/doctrees/environment.pickle

+ 1 - 15
activate

@@ -9,13 +9,6 @@ deactivate () {
         unset _OLD_VIRTUAL_PATH
     fi
 
-    # This should detect bash and zsh, which have a hash command that must
-    # be called to get it to forget past commands.  Without forgetting
-    # past commands the $PATH changes we made may not be respected
-    if [ -n "$BASH" -o -n "$ZSH_VERSION" ] ; then
-        hash -r
-    fi
-
     if [ -n "$_OLD_VIRTUAL_PS1" ] ; then
         PS1="$_OLD_VIRTUAL_PS1"
         export PS1
@@ -32,7 +25,7 @@ deactivate () {
 # unset irrelavent variables
 deactivate nondestructive
 
-VIRTUAL_ENV="/home/jc/dev/converse.js"
+VIRTUAL_ENV="~/converse.js"
 export VIRTUAL_ENV
 
 _OLD_VIRTUAL_PATH="$PATH"
@@ -54,10 +47,3 @@ if [ -z "$VIRTUAL_ENV_DISABLE_PROMPT" ] ; then
     fi
     export PS1
 fi
-
-# This should detect bash and zsh, which have a hash command that must
-# be called to get it to forget past commands.  Without forgetting
-# past commands the $PATH changes we made may not be respected
-if [ -n "$BASH" -o -n "$ZSH_VERSION" ] ; then
-    hash -r
-fi

+ 360 - 235
converse.js

@@ -25,7 +25,9 @@
         root.converse = factory(jQuery, _, OTR, DSA, JST, moment);
     }
 }(this, function ($, _, OTR, DSA, templates, moment) {
-    "use strict";
+    // "use strict";
+    // Cannot use this due to Safari bug.
+    // See https://github.com/jcbrand/converse.js/issues/196
     if (typeof console === "undefined" || typeof console.log === "undefined") {
         console = { log: function () {}, error: function () {} };
     }
@@ -104,7 +106,7 @@
             if ($.browser.webkit) {
                 var conversejs = document.getElementById('conversejs');
                 conversejs.style.display = 'none';
-                conversejs.style.height = conversejs.offsetHeight;
+                conversejs.offsetHeight = conversejs.offsetHeight;
                 conversejs.style.display = 'block';
             }
         }
@@ -122,6 +124,14 @@
         var KEY = {
             ENTER: 13
         };
+        var STATUS_WEIGHTS = {
+            'offline':      6,
+            'unavailable':  5,
+            'xa':           4,
+            'away':         3,
+            'dnd':          2,
+            'online':       1
+        };
         var HAS_CSPRNG = ((typeof crypto !== 'undefined') &&
             ((typeof crypto.randomBytes === 'function') ||
                 (typeof crypto.getRandomValues === 'function')
@@ -132,6 +142,9 @@
             (typeof DSA !== "undefined")
         );
 
+        var OPENED = 'opened';
+        var CLOSED = 'closed';
+
         // Default configuration values
         // ----------------------------
         this.allow_contact_requests = true;
@@ -146,13 +159,14 @@
         this.cache_otr_key = false;
         this.debug = false;
         this.default_box_height = 324; // The default height, in pixels, for the control box, chat boxes and chatrooms.
-        this.message_carbons = false;
         this.expose_rid_and_sid = false;
         this.forward_messages = false;
         this.hide_muc_server = false;
         this.i18n = locales.en;
+        this.message_carbons = false;
         this.no_trimming = false; // Set to true for phantomjs tests (where browser apparently has no width)
         this.prebind = false;
+        this.roster_groups = false;
         this.show_controlbox_by_default = false;
         this.show_only_online_users = false;
         this.show_toolbar = true;
@@ -190,10 +204,11 @@
             'fullname',
             'hide_muc_server',
             'i18n',
-            'no_trimming',
             'jid',
+            'no_trimming',
             'prebind',
             'rid',
+            'roster_groups',
             'show_controlbox_by_default',
             'show_only_online_users',
             'show_toolbar',
@@ -270,6 +285,18 @@
             'xa': __('This contact is away for an extended period'),
             'away': __('This contact is away')
         };
+        var DESC_GROUP_TOGGLE = __('Click to hide these contacts');
+
+        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;
 
         // Module-level variables
         // ----------------------
@@ -313,10 +340,10 @@
                         img_type = $vcard.find('TYPE').text(),
                         url = $vcard.find('URL').text();
                     if (jid) {
-                        var rosteritem = converse.roster.get(jid);
-                        if (rosteritem) {
-                            fullname = _.isEmpty(fullname)? rosteritem.get('fullname') || jid: fullname;
-                            rosteritem.save({
+                        var contact = converse.roster.get(jid);
+                        if (contact) {
+                            fullname = _.isEmpty(fullname)? contact.get('fullname') || jid: fullname;
+                            contact.save({
                                 'fullname': fullname,
                                 'image_type': img_type,
                                 'image': img,
@@ -332,9 +359,9 @@
                 jid,
                 function (iq) {
                     // Error callback
-                    var rosteritem = converse.roster.get(jid);
-                    if (rosteritem) {
-                        rosteritem.save({
+                    var contact = converse.roster.get(jid);
+                    if (contact) {
+                        contact.save({
                             'vcard_updated': moment().format()
                         });
                     }
@@ -454,40 +481,6 @@
             this.xmppstatus.fetch({success: callback, error: callback});
         };
 
-        this.registerRosterHandler = function () {
-            // Register handlers that depend on the roster
-            this.connection.roster.registerCallback(
-                $.proxy(this.roster.rosterHandler, this.roster),
-                null, 'presence', null);
-        };
-
-        this.registerRosterXHandler = function () {
-            this.connection.addHandler(
-                $.proxy(this.roster.subscribeToSuggestedItems, this.roster),
-                'http://jabber.org/protocol/rosterx', 'message', null);
-        };
-
-        this.registerPresenceHandler = function () {
-            this.connection.addHandler(
-                $.proxy(function (presence) {
-                    this.presenceHandler(presence);
-                    return true;
-                }, this.roster), null, 'presence', null);
-        };
-
-        this.initRoster = function () {
-            // Set up the roster
-            this.roster = new this.RosterItems();
-            this.roster.browserStorage = new Backbone.BrowserStorage[converse.storage](
-                b64_sha1('converse.rosteritems-'+converse.bare_jid));
-            this.registerRosterHandler();
-            this.registerRosterXHandler();
-            this.registerPresenceHandler();
-            // Now create the view which will fetch roster items from
-            // browserStorage
-            this.rosterview = new this.RosterView({'model':this.roster});
-        };
-
         this.registerGlobalEventHandlers = function () {
             $(document).click(function() {
                 if ($('.toggle-otr ul').is(':visible')) {
@@ -574,7 +567,8 @@
             this.features = new this.Features();
             this.enableCarbons();
             this.initStatus($.proxy(function () {
-                this.initRoster();
+                this.roster = new converse.RosterContacts();
+                this.rosterview = new this.RosterView({model: new this.RosterGroups()});
                 this.chatboxes.onConnected();
                 this.connection.roster.get(function () {});
                 this.giveFeedback(__('Online Contacts'));
@@ -635,8 +629,7 @@
             }
         });
 
-        this.Message = Backbone.Model.extend();
-
+        this.Message = Backbone.Model;
         this.Messages = Backbone.Collection.extend({
             model: converse.Message
         });
@@ -1343,8 +1336,8 @@
 
             updateVCard: function () {
                 var jid = this.model.get('jid'),
-                    rosteritem = converse.roster.get(jid);
-                if ((rosteritem) && (!rosteritem.get('vcard_updated'))) {
+                    contact = converse.roster.get(jid);
+                if ((contact) && (!contact.get('vcard_updated'))) {
                     converse.getVCard(
                         jid,
                         $.proxy(function (jid, fullname, image, image_type, url) {
@@ -2712,9 +2705,7 @@
             },
 
             toggle: function (ev) {
-                if (ev && ev.preventDefault) {
-                    ev.preventDefault();
-                }
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
                 this.toggleview.model.save({'collapsed': !this.toggleview.model.get('collapsed')});
                 this.$('.minimized-chats-flyout').toggle();
             },
@@ -2746,7 +2737,8 @@
             },
 
             updateUnreadMessagesCounter: function () {
-                var ls = this.model.pluck('num_unread'), count = 0;
+                var ls = this.model.pluck('num_unread'),
+                    count = 0, i;
                 for (i=0; i<ls.length; i++) { count += ls[i]; }
                 this.toggleview.model.set({'num_unread': count});
                 this.render();
@@ -2787,7 +2779,7 @@
             },
         });
 
-        this.RosterItem = Backbone.Model.extend({
+        this.RosterContact = Backbone.Model.extend({
             initialize: function (attributes, options) {
                 var jid = attributes.jid;
                 if (!attributes.fullname) {
@@ -2797,15 +2789,15 @@
                     'id': jid,
                     'user_id': Strophe.getNodeFromJid(jid),
                     'resources': [],
+                    'groups': [],
                     'status': ''
                 }, attributes);
-                attrs.sorted = false;
                 attrs.chat_status = 'offline';
                 this.set(attrs);
             }
         });
 
-        this.RosterItemView = Backbone.View.extend({
+        this.RosterContactView = Backbone.View.extend({
             tagName: 'dd',
 
             events: {
@@ -2815,8 +2807,27 @@
                 "click .remove-xmpp-contact": "removeContact"
             },
 
+            initialize: function () {
+                this.model.on("change", this.onChange, this);
+                this.model.on("remove", this.remove, this);
+                this.model.on("destroy", this.remove, this);
+                this.model.on("open", this.openChat, this);
+            },
+
+            onChange: function () {
+                if (converse.show_only_online_users) {
+                    if (this.model.get('chat_status') !== 'online') {
+                        this.$el.hide();
+                    } else {
+                        this.$el.show();
+                    }
+                } else {
+                    this.render();
+                }
+            },
+
             openChat: function (ev) {
-                ev.preventDefault();
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
                 return converse.chatboxviews.showChat({
                     'id': this.model.get('jid'),
                     'jid': this.model.get('jid'),
@@ -2829,14 +2840,16 @@
             },
 
             removeContact: function (ev) {
-                ev.preventDefault();
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
                 var result = confirm(__("Are you sure you want to remove this contact?"));
                 if (result === true) {
                     var bare_jid = this.model.get('jid');
-                    converse.connection.roster.remove(bare_jid, function (iq) {
+                    converse.connection.roster.remove(bare_jid, $.proxy(function (iq) {
                         converse.connection.roster.unauthorize(bare_jid);
                         converse.rosterview.model.remove(bare_jid);
-                    });
+                        this.model.destroy();
+                        this.remove();
+                    }, this));
                 }
             },
 
@@ -2862,6 +2875,7 @@
             render: function () {
                 var item = this.model,
                     ask = item.get('ask'),
+                    chat_status = item.get('chat_status'),
                     requesting  = item.get('requesting'),
                     subscription = item.get('subscription');
 
@@ -2877,10 +2891,20 @@
                             this.$el.removeClass(cls);
                         }
                     }, this);
-
-                this.$el.addClass(item.get('chat_status'));
-
-                if (ask === 'subscribe') {
+                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(), {
@@ -2900,7 +2924,7 @@
                     this.$el.addClass('current-xmpp-contact');
                     this.$el.html(converse.templates.roster_item(
                         _.extend(item.toJSON(), {
-                            'desc_status': STATUSES[item.get('chat_status')||'offline'],
+                            'desc_status': STATUSES[chat_status||'offline'],
                             'desc_chat': __('Click to chat with this contact'),
                             'desc_remove': __('Click to remove this contact')
                         })
@@ -2910,32 +2934,21 @@
             }
         });
 
-        this.RosterItems = Backbone.Collection.extend({
-            model: converse.RosterItem,
-            comparator : function (rosteritem) {
-                var chat_status = rosteritem.get('chat_status'),
-                    rank = 4;
-                switch(chat_status) {
-                    case 'offline':
-                        rank = 0;
-                        break;
-                    case 'unavailable':
-                        rank = 1;
-                        break;
-                    case 'xa':
-                        rank = 2;
-                        break;
-                    case 'away':
-                        rank = 3;
-                        break;
-                    case 'dnd':
-                        rank = 4;
-                        break;
-                    case 'online':
-                        rank = 5;
-                        break;
+        this.RosterContacts = Backbone.Collection.extend({
+            model: converse.RosterContact,
+            browserStorage: new Backbone.BrowserStorage[converse.storage](
+                b64_sha1('converse.contacts-'+converse.bare_jid)),
+
+            comparator: function (contact1, contact2) {
+                var name1 = contact1.get('fullname').toLowerCase();
+                var status1 = contact1.get('chat_status') || 'offline';
+                var name2 = contact2.get('fullname').toLowerCase();
+                var status2 = contact2.get('chat_status') || 'offline';
+                if (STATUS_WEIGHTS[status1] === STATUS_WEIGHTS[status2]) {
+                    return name1 < name2 ? -1 : (name1 > name2? 1 : 0);
+                } else  {
+                    return STATUS_WEIGHTS[status1] < STATUS_WEIGHTS[status2] ? -1 : 1;
                 }
-                return rank;
             },
 
             subscribeToSuggestedItems: function (msg) {
@@ -3070,7 +3083,6 @@
                             ask: item.ask,
                             fullname: item.name || item.jid,
                             groups: item.groups,
-                            is_last: is_last,
                             jid: item.jid,
                             subscription: item.subscription
                         });
@@ -3136,8 +3148,7 @@
                                         image: img,
                                         image_type: img_type,
                                         url: url,
-                                        vcard_updated: moment().format(),
-                                        is_last: true
+                                        vcard_updated: moment().format()
                                     });
                                 }, this),
                                 $.proxy(function (jid, fullname, img, img_type, url) {
@@ -3148,8 +3159,7 @@
                                         subscription: 'none',
                                         ask: null,
                                         requesting: true,
-                                        fullname: jid,
-                                        is_last: true
+                                        fullname: jid
                                     });
                                 }, this)
                             );
@@ -3173,7 +3183,7 @@
                     $show = $presence.find('show'),
                     chat_status = $show.text() || 'online',
                     status_message = $presence.find('status'),
-                    item;
+                    contact;
 
                 if (this.isSelf(bare_jid)) {
                     if ((converse.connection.jid !== jid)&&(presence_type !== 'unavailable')) {
@@ -3184,9 +3194,9 @@
                 } else if (($presence.find('x').attr('xmlns') || '').indexOf(Strophe.NS.MUC) === 0) {
                     return true; // Ignore MUC
                 }
-                item = this.get(bare_jid);
-                if (item && (status_message.text() != item.get('status'))) {
-                    item.save({'status': status_message.text()});
+                contact = this.get(bare_jid);
+                if (contact && (status_message.text() != contact.get('status'))) {
+                    contact.save({'status': status_message.text()});
                 }
                 if ((presence_type === 'subscribed') || (presence_type === 'unsubscribe')) {
                     return true;
@@ -3196,185 +3206,300 @@
                     this.unsubscribe(bare_jid);
                 } else if (presence_type === 'unavailable') {
                     if (this.removeResource(bare_jid, resource) === 0) {
-                        if (item) {
-                            item.save({'chat_status': 'offline'});
+                        if (contact) {
+                            contact.save({'chat_status': 'offline'});
                         }
                     }
-                } else if (item) {
+                } else if (contact) {
                     // presence_type is undefined
                     this.addResource(bare_jid, resource);
-                    item.save({'chat_status': chat_status});
+                    contact.save({'chat_status': chat_status});
                 }
                 return true;
             }
         });
 
-        this.RosterView = Backbone.Overview.extend({
-            tagName: 'dl',
-            id: 'converse-roster',
+        this.RosterGroup = Backbone.Model.extend({
+            initialize: function (attributes, options) {
+                this.set(_.extend({
+                    description: DESC_GROUP_TOGGLE,
+                    state: OPENED
+                }, attributes));
+                // Collection of contacts belonging to this group.
+                this.contacts = new converse.RosterContacts();
+            }
+        });
+
+        this.RosterGroupView = Backbone.Overview.extend({
+            tagName: 'dt',
+            className: 'roster-group',
+            events: {
+                "click a.group-toggle": "toggle"
+            },
 
             initialize: function () {
-                this.model.on("add", function (item) {
-                    this.addRosterItemView(item).render(item);
-                    if (!item.get('vcard_updated')) {
-                        // This will update the vcard, which triggers a change
-                        // request which will rerender the roster item.
-                        converse.getVCard(item.get('jid'));
-                    }
+                this.model.contacts.on("add", this.addContact, 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);
+            },
 
-                this.model.on('change', function (item) {
-                    if ((_.size(item.changed) === 1) && _.contains(_.keys(item.changed), 'sorted')) {
-                        return;
-                    }
-                    this.updateChatBox(item).render(item);
-                }, 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;
+            },
 
-                this.model.on("remove", function (item) { this.removeRosterItemView(item); }, this);
-                this.model.on("destroy", function (item) { this.removeRosterItemView(item); }, this);
-                this.model.on("reset", function () { this.removeAllRosterItemViewss(); }, this);
+            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);
+                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;
+            },
 
-                var roster_markup = converse.templates.contacts({
-                    'label_contacts': __('My contacts')
-                });
-                if (converse.allow_contact_requests) {
-                    roster_markup =
-                        converse.templates.requesting_contacts({
-                            'label_contact_requests': __('Contact requests')
-                        }) +
-                        roster_markup +
-                        converse.templates.pending_contacts({
-                            'label_pending_contacts': __('Pending contacts')
-                        });
+            toggle: function (ev) {
+                if (ev && ev.preventDefault) { ev.preventDefault(); }
+                var $el = $(ev.target);
+                this.$el.nextUntil('dt').slideToggle();
+                if ($el.hasClass("icon-opened")) {
+                    this.model.save({state: CLOSED});
+                    $el.removeClass("icon-opened").addClass("icon-closed");
+                } else {
+                    $el.removeClass("icon-closed").addClass("icon-opened");
+                    this.model.save({state: OPENED});
                 }
-                this.$el.hide().html(roster_markup);
-                this.model.fetch({add: true}); // Get the cached roster items from localstorage
             },
 
-            updateChatBox: function (item, changed) {
-                var chatbox = converse.chatboxes.get(item.get('jid')),
-                    changes = {};
-                if (!chatbox) {
-                    return this;
+            addContact: function (contact) {
+                var view = new converse.RosterContactView({model: contact});
+                this.add(contact.get('id'), view);
+                var view = this.positionContact(contact).render();
+                if (this.model.get('state') === CLOSED) {
+                    view.$el.hide();
                 }
-                if (_.has(item.changed, 'chat_status')) {
-                    changes.chat_status = item.get('chat_status');
+                this.$el.show();
+            },
+
+            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.remove(cid); // Contact has been added to this group
+                } else if (!in_this_group && in_this_overview) {
+                    this.addContact(contact); // Contact has been removed from this group
                 }
-                if (_.has(item.changed, 'status')) {
-                    changes.status = item.get('status');
+            },
+
+            onRemove: function (contact) {
+                if (this.model.contacts.length === 0) {
+                    this.$el.hide();
                 }
-                chatbox.save(changes);
+            }
+        });
+
+        this.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;
+                }
+            },
+
+            initialize: function () {
+                this.browserStorage = new Backbone.BrowserStorage[converse.storage](
+                    b64_sha1('converse.roster.groups'+converse.bare_jid));
+            }
+        });
+
+        this.RosterView = Backbone.Overview.extend({
+            tagName: 'dl',
+            id: 'converse-roster',
+
+            initialize: function () {
+                this.registerRosterHandler();
+                this.registerRosterXHandler();
+                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.render();
+                this.model.fetch({add: true});
+                converse.roster.fetch({add: true});
+            },
+
+            render: function () {
+                this.$el.empty();
                 return this;
             },
 
-            addRosterItemView: function (item) {
-                var view = new converse.RosterItemView({model: item});
-                this.add(item.id, view);
+            update: function () {
+                // XXX: Is this still being used/valid?
+                var $count = $('#online-count');
+                $count.text('('+converse.roster.getNumOnlineContacts()+')');
+                if (!$count.is(':visible')) {
+                    $count.show();
+                }
                 return this;
             },
 
-            removeAllRosterItemViewss: function () {
-                var views = this.removeAll();
-                this.render();
+            reset: function () {
+                converse.roster.reset();
+                this.removeAll();
+                this.render().update();
                 return this;
             },
 
-            removeRosterItemView: function (item) {
-                if (this.get(item.id)) {
-                    this.get(item.id).remove();
-                    this.render();
+            registerRosterHandler: function () {
+                // Register handlers that depend on the roster
+                converse.connection.roster.registerCallback(
+                    $.proxy(converse.roster.rosterHandler, converse.roster),
+                    null, 'presence', null);
+            },
+
+            registerRosterXHandler: function () {
+                converse.connection.addHandler(
+                    $.proxy(converse.roster.subscribeToSuggestedItems, converse.roster),
+                    'http://jabber.org/protocol/rosterx', 'message', null);
+            },
+
+            registerPresenceHandler: function () {
+                converse.connection.addHandler(
+                    $.proxy(function (presence) {
+                        converse.roster.presenceHandler(presence);
+                        return true;
+                    }, 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'));
                 }
-                return this;
             },
 
-            renderRosterItem: function (item, view) {
-                if ((converse.show_only_online_users) && (item.get('chat_status') !== 'online')) {
-                    view.$el.remove();
-                    view.delegateEvents();
+            onContactChange: function (contact) {
+                this.updateChatBox(contact).update();
+            },
+
+            updateChatBox: function (contact, changed) {
+                var chatbox = converse.chatboxes.get(contact.get('jid')),
+                    changes = {};
+                if (!chatbox) {
                     return this;
                 }
-                if ($.contains(document.documentElement, view.el)) {
-                    view.render();
+                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;
+            },
+
+            positionGroup: function (view) {
+                /* Place the group's DOM element in the correct alphabetical
+                 * position amongst the other groups in the roster.
+                 */
+                var index = this.model.indexOf(view.model);
+                if (index === 0) {
+                    this.$el.prepend(view.$el);
+                } else if (index == (this.model.length-1)) {
+                    this.$('.roster-group').last().siblings('dd').last().after(view.$el);
                 } else {
-                    this.$el.find('#xmpp-contacts').after(view.render().el);
+                    $(this.$('.roster-group').eq(index)).before(view.$el);
                 }
             },
 
-            render: function (item) {
-                var $my_contacts = this.$el.find('#xmpp-contacts'),
-                    $contact_requests = this.$el.find('#xmpp-contact-requests'),
-                    $pending_contacts = this.$el.find('#pending-xmpp-contacts'),
-                    sorted = false,
-                    $count, changed_presence;
-                if (item) {
-                    var jid = item.id,
-                        view = this.get(item.id),
-                        ask = item.get('ask'),
-                        subscription = item.get('subscription'),
-                        requesting  = item.get('requesting'),
-                        crit = {order:'asc'};
-
-                    if ((ask === 'subscribe') && (subscription == 'none')) {
-                        $pending_contacts.after(view.render().el);
-                        $pending_contacts.after($pending_contacts.siblings('dd.pending-xmpp-contact').tsort(crit));
-                    } else if ((ask === 'subscribe') && (subscription == 'from')) {
-                        // TODO: We have accepted an incoming subscription
-                        // request and (automatically) made our own subscription request back.
-                        // It would be useful to update the roster here to show
-                        // things are happening... (see docs/DEVELOPER.rst)
-                        $pending_contacts.after(view.render().el);
-                        $pending_contacts.after($pending_contacts.siblings('dd.pending-xmpp-contact').tsort(crit));
-                    } else if (requesting === true) {
-                        $contact_requests.after(view.render().el);
-                        $contact_requests.after($contact_requests.siblings('dd.requesting-xmpp-contact').tsort(crit));
-                    } else if (subscription === 'both' || subscription === 'to') {
-                        this.renderRosterItem(item, view);
-                    }
-                    changed_presence = item.changed.chat_status;
-                    if (changed_presence) {
-                        this.sortRoster(changed_presence);
-                        sorted = true;
-                    }
-                    if (item.get('is_last')) {
-                        if (!sorted) {
-                            this.sortRoster(item.get('chat_status'));
-                        }
-                        if (!this.$el.is(':visible')) {
-                            // Once all initial roster items have been added, we
-                            // can show the roster.
-                            this.$el.show();
-                        }
-                    }
+            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;
                 }
-                // Hide the headings if there are no contacts under them
-                _.each([$my_contacts, $contact_requests, $pending_contacts], function (h) {
-                    if (h.nextUntil('dt').length) {
-                        if (!h.is(':visible')) {
-                            h.show();
-                        }
-                    }
-                    else if (h.is(':visible')) {
-                        h.hide();
+                return this.model.create({name: name, id: b64_sha1(name)});
+            },
+
+            addContactToGroup: function (contact, name) {
+                this.getGroup(name).contacts.add(contact);
+            },
+
+            addCurrentContact: function (contact) {
+                var groups;
+                if (converse.roster_groups) {
+                    groups = contact.get('groups');
+                    if (groups.length === 0) {
+                        groups = [HEADER_UNGROUPED];
                     }
-                });
-                $count = $('#online-count');
-                $count.text('('+this.model.getNumOnlineContacts()+')');
-                if (!$count.is(':visible')) {
-                    $count.show();
+                } else {
+                    groups = [HEADER_CURRENT_CONTACTS];
                 }
-                converse.emit('rosterViewUpdated');
-                return this;
+                _.each(groups, $.proxy(function (name) {
+                    this.addContactToGroup(contact, name);
+                }, this));
             },
 
-            sortRoster: function (chat_status) {
-                var $my_contacts = this.$el.find('#xmpp-contacts');
-                $my_contacts.siblings('dd.current-xmpp-contact.'+chat_status).tsort('a', {order:'asc'});
-                $my_contacts.after($my_contacts.siblings('dd.current-xmpp-contact.offline'));
-                $my_contacts.after($my_contacts.siblings('dd.current-xmpp-contact.unavailable'));
-                $my_contacts.after($my_contacts.siblings('dd.current-xmpp-contact.xa'));
-                $my_contacts.after($my_contacts.siblings('dd.current-xmpp-contact.away'));
-                $my_contacts.after($my_contacts.siblings('dd.current-xmpp-contact.dnd'));
-                $my_contacts.after($my_contacts.siblings('dd.current-xmpp-contact.online'));
+            addRosterContact: function (contact) {
+                var view;
+                if (contact.get('subscription') === 'both' || contact.get('subscription') === 'to') {
+                    this.addCurrentContact(contact);
+                } else {
+                    view = (new converse.RosterContactView({model: contact})).render();
+                    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;
             }
         });
 

+ 35 - 15
css/converse.css

@@ -38,8 +38,14 @@
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
 }
+.icon-closed:before {
+  content: "\25ba";
+}
+.icon-opened:before {
+  content: "\25bc";
+}
 .icon-checkmark:before {
-  content: "\e600";
+  content: "\2713";
 }
 #conversejs .icon-home:before {
   content: "\e000";
@@ -812,8 +818,7 @@ dl.add-converse-contact {
   top: 1px;
 }
 #conversejs .controlbox-pane dt {
-  margin: 0;
-  padding-top: 0.5em;
+  padding: 3px;
 }
 #conversejs .chatroom-form-container {
   height: 100%;
@@ -847,11 +852,11 @@ dl.add-converse-contact {
 #conversejs .chatroom-form label select {
   float: right;
 }
-#conversejs #converse-roster dd.odd {
+#converse-roster dd.odd {
   background-color: #DCEAC5;
   /* Make this difference */
 }
-#conversejs #converse-roster dd.current-xmpp-contact span {
+#converse-roster dd.current-xmpp-contact span {
   font-size: 16px;
   float: left;
   color: #4f4f4f;
@@ -860,18 +865,25 @@ dl.add-converse-contact {
   margin-left: 0.5em;
   float: right;
 }
-#conversejs #converse-roster dd a,
-#conversejs #converse-roster dd span {
+#converse-roster dd a,
+#converse-roster dd span {
   text-shadow: 0 1px 0 #fafafa;
   display: inline-block;
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
 }
-#conversejs #converse-roster dd span {
+#conversejs #converse-roster span.req-contact-name {
+  width: 65%;
+}
+#conversejs #converse-roster span.pending-contact-name,
+#conversejs #converse-roster a.open-chat {
+  width: 80%;
+}
+#converse-roster dd span {
   padding: 0 5px 0 0;
 }
-#conversejs #converse-roster {
+#converse-roster {
   overflow-y: auto;
   overflow-x: hidden;
   width: 100%;
@@ -880,6 +892,9 @@ dl.add-converse-contact {
   height: 254px;
   height: calc(100% - 70px);
 }
+#converse-roster .group-toggle {
+  color: #666;
+}
 #conversejs dd.available-chatroom {
   overflow-x: hidden;
   text-overflow: ellipsis;
@@ -889,8 +904,8 @@ dl.add-converse-contact {
 #conversejs dd.available-chatroom a.open-room {
   width: 148px;
 }
-#conversejs #available-chatrooms dt,
-#conversejs #converse-roster dt {
+#available-chatrooms dt,
+#converse-roster dt {
   font-weight: normal;
   font-size: 13px;
   color: #666;
@@ -898,7 +913,7 @@ dl.add-converse-contact {
   padding: 0.3em 0 0 0.5em;
   text-shadow: 0 1px 0 #fafafa;
 }
-#conversejs #converse-roster dt {
+#converse-roster dt {
   display: none;
 }
 #conversejs .room-info {
@@ -950,6 +965,11 @@ dl.add-converse-contact {
 #conversejs #converse-roster dd {
   line-height: 16px;
 }
+#conversejs .group-toggle {
+  display: block;
+  width: 100%;
+}
+#conversejs .roster-group:hover,
 #conversejs dd.available-chatroom:hover,
 #conversejs #converse-roster dd:hover {
   background-color: #eee;
@@ -967,9 +987,6 @@ dl.add-converse-contact {
 #conversejs #converse-roster dd:hover a.remove-xmpp-contact {
   display: inline-block;
 }
-#conversejs #converse-roster a.open-chat {
-  width: 80%;
-}
 #conversejs .chatbox,
 #conversejs .chatroom {
   height: 25px;
@@ -1287,6 +1304,9 @@ input.custom-xmpp-status {
 #conversejs .chatbox .dropdown dd ul li:hover {
   background-color: #bed6e5;
 }
+#conversejs .chatbox .dropdown dd.search-xmpp ul li:hover {
+  background-color: #bed6e5;
+}
 #conversejs .xmpp-status-menu li a {
   width: 100%;
 }

+ 5 - 1
docs/CHANGES.rst

@@ -20,10 +20,14 @@ Changelog
   Message forwarding was before a default behavior but is now optional (and disabled by default). [jcbrand]
 * Newly opened chat boxes always appear immediately left of the controlbox. [jcbrand]
 * #71 Chat boxes and rooms can be minimized. [jcbrand]
+* #83 Roster contacts can be shown according to their groups. [jcbrand]
+    Note: Converse.js can show users under groups if you have assigned them
+    already via another client or server configuration. There is not yet a way
+    to assign contacts to groups from within converse.js itself.
 * #123 Show converse.js in the resource assigned to a user. [jcbrand]
 * #130 Fixed bootstrap conflicts. [jcbrand]
 * #132 Support for `XEP-0280: Message Carbons <https://xmpp.org/extensions/xep-0280.html'>`_.
-  Configured via `message_carbons <https://conversejs.org/docs/html/index.html#message_carbons>`_ [hejazee]
+    Configured via `message_carbons <https://conversejs.org/docs/html/index.html#message_carbons>`_ [hejazee]
 * #176 Add support for caching in sessionStorage as opposed to localStorage. [jcbrand]
 * #180 RID and SID undefined [g8g3]
 * #191 No messages history [heban]

+ 8 - 1
docs/DEVELOPER.rst

@@ -30,7 +30,7 @@ subscription and subscribes back.
     <presence type="subscribed" to="contact1@localhost"/>
     <presence type="subscribe" to="contact1@localhost"/>
 
-Contact2 receives a roster update
+IF Contact1 is still online and likewise subscribes back, Contact2 will receive a roster update
 
 ::
     <iq type="set" to="contact2@localhost">
@@ -39,6 +39,13 @@ Contact2 receives a roster update
         </query>
     </iq>
 
+ELSE, Contact 2 will receive a roster update (but not an IQ stanza)
+
+::
+    ask = null
+    subscription = "from"
+
+
 Contact1's converse.js client will automatically
 approve.
 

+ 12 - 2
docs/source/index.rst

@@ -784,8 +784,6 @@ Here are the different events that are emitted:
 +----------------------------------+---------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------+
 | **roster**                       | When the roster is updated.                                                                       | ``converse.on('roster', function (items) { ... });``                                    |
 +----------------------------------+---------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------+
-| **rosterViewUpdated**            | Whenever the roster view (i.e. the rendered HTML) has changed.                                    | ``converse.on('rosterViewUpdated', function (items) { ... });``                         |
-+----------------------------------+---------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------+
 | **callButtonClicked**            | When a call button (i.e. with class .toggle-call) on a chat box has been clicked.                 | ``converse.on('callButtonClicked', function (connection, model) { ... });``             |
 +----------------------------------+---------------------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------+
 | **chatBoxOpened**                | When a chat box has been opened.                                                                  | ``converse.on('chatBoxOpened', function (chatbox) { ... });``                           |
@@ -1003,6 +1001,18 @@ values as ``jid``, ``sid``, ``rid``.
 
 Additionally, you have to specify ``bosh_service_url``.
 
+roster_groups
+-------------
+
+Default:  ``false``
+
+If set to ``true``, converse.js will show any roster groups you might have
+configured.
+
+.. Note ::
+    It's currently not possible to use converse.js to assign contacts to groups.
+    Converse.js can only show users and groups that were previously configured
+    elsewhere.
 
 show_controlbox_by_default
 --------------------------

+ 0 - 3
fonticons/demo-files/demo.css

@@ -150,6 +150,3 @@ p {
 .fs1 {
 	font-size: 32px;
 }
-.fs2 {
-	font-size: 32px;
-}

+ 115 - 92
fonticons/demo.html

@@ -9,27 +9,50 @@
 	<link rel="stylesheet" href="style.css"></head>
 <body>
 	<div class="bgc1 clearfix">
-		<h1 class="mhmm mvm"><span class="fgc1">Font Name:</span> icomoon <small class="fgc1">(Glyphs:&nbsp;86)</small></h1>
+		<h1 class="mhmm mvm"><span class="fgc1">Font Name:</span> icomoon <small class="fgc1">(Glyphs:&nbsp;88)</small></h1>
 	</div>
 	<div class="clearfix mhl ptl">
 		<h1 class="mvm mtn bshadow fgc1">Grid Size: 16</h1>
+		<div class="glyph fs1">
+			<div class="clearfix bshadow0 pbs">
+				<span class="icon-closed"></span><span class="mls"> icon-closed</span>
+			</div>
+			<fieldset class="fs0 size1of1 clearfix hidden-false">
+				<input type="text" readonly value="25ba" class="unit size1of2" />
+				<input type="text" maxlength="1" readonly value="&#x25ba;" class="unitRight size1of2 talign-right" />
+			</fieldset>
+			<div class="fs0 bshadow0 clearfix hidden-true">
+				<span class="unit pvs fgc1">liga: </span>
+				<input type="text" readonly value="" class="liga unitRight" />
+			</div>
+		</div>
+		<div class="glyph fs1">
+			<div class="clearfix bshadow0 pbs">
+				<span class="icon-opened"></span><span class="mls"> icon-opened</span>
+			</div>
+			<fieldset class="fs0 size1of1 clearfix hidden-false">
+				<input type="text" readonly value="25bc" class="unit size1of2" />
+				<input type="text" maxlength="1" readonly value="&#x25bc;" class="unitRight size1of2 talign-right" />
+			</fieldset>
+			<div class="fs0 bshadow0 clearfix hidden-true">
+				<span class="unit pvs fgc1">liga: </span>
+				<input type="text" readonly value="" class="liga unitRight" />
+			</div>
+		</div>
 		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-checkmark"></span><span class="mls"> icon-checkmark</span>
 			</div>
 			<fieldset class="fs0 size1of1 clearfix hidden-false">
-				<input type="text" readonly value="e600" class="unit size1of2" />
-				<input type="text" maxlength="1" readonly value="&#xe600;" class="unitRight size1of2 talign-right" />
+				<input type="text" readonly value="2713" class="unit size1of2" />
+				<input type="text" maxlength="1" readonly value="&#x2713;" class="unitRight size1of2 talign-right" />
 			</fieldset>
 			<div class="fs0 bshadow0 clearfix hidden-true">
 				<span class="unit pvs fgc1">liga: </span>
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-	</div>
-	<div class="clearfix mhl ptl">
-		<h1 class="mvm mtn bshadow fgc1">Grid Size: Unknown</h1>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-home"></span><span class="mls"> icon-home</span>
 			</div>
@@ -42,7 +65,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-pencil"></span><span class="mls"> icon-pencil</span>
 			</div>
@@ -55,7 +78,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-camera"></span><span class="mls"> icon-camera</span>
 			</div>
@@ -68,7 +91,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-camera2"></span><span class="mls"> icon-camera2</span>
 			</div>
@@ -81,9 +104,9 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
-				<span class="icon-play"></span><span class="mls"> icon-play</span>
+				<span class="icon-play22"></span><span class="mls"> icon-play22</span>
 			</div>
 			<fieldset class="fs0 size1of1 clearfix hidden-false">
 				<input type="text" readonly value="25d9" class="unit size1of2" />
@@ -94,7 +117,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-music"></span><span class="mls"> icon-music</span>
 			</div>
@@ -107,7 +130,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-headphones"></span><span class="mls"> icon-headphones</span>
 			</div>
@@ -120,7 +143,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-phone"></span><span class="mls"> icon-phone</span>
 			</div>
@@ -133,7 +156,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-phone-hang-up"></span><span class="mls"> icon-phone-hang-up</span>
 			</div>
@@ -146,7 +169,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-address-book"></span><span class="mls"> icon-address-book</span>
 			</div>
@@ -159,7 +182,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-notebook"></span><span class="mls"> icon-notebook</span>
 			</div>
@@ -172,7 +195,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-envelop"></span><span class="mls"> icon-envelop</span>
 			</div>
@@ -185,7 +208,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-pushpin"></span><span class="mls"> icon-pushpin</span>
 			</div>
@@ -198,7 +221,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-bubble"></span><span class="mls"> icon-bubble</span>
 			</div>
@@ -211,7 +234,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-bubble2"></span><span class="mls"> icon-bubble2</span>
 			</div>
@@ -224,7 +247,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-bubbles"></span><span class="mls"> icon-bubbles</span>
 			</div>
@@ -237,7 +260,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-bubbles2"></span><span class="mls"> icon-bubbles2</span>
 			</div>
@@ -250,7 +273,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-bubbles3"></span><span class="mls"> icon-bubbles3</span>
 			</div>
@@ -263,7 +286,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-user"></span><span class="mls"> icon-user</span>
 			</div>
@@ -276,7 +299,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-users"></span><span class="mls"> icon-users</span>
 			</div>
@@ -289,7 +312,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-quotes-left"></span><span class="mls"> icon-quotes-left</span>
 			</div>
@@ -302,7 +325,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-spinner"></span><span class="mls"> icon-spinner</span>
 			</div>
@@ -315,7 +338,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-search"></span><span class="mls"> icon-search</span>
 			</div>
@@ -328,7 +351,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-cogs"></span><span class="mls"> icon-cogs</span>
 			</div>
@@ -341,7 +364,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-wrench"></span><span class="mls"> icon-wrench</span>
 			</div>
@@ -354,7 +377,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-unlocked"></span><span class="mls"> icon-unlocked</span>
 			</div>
@@ -367,7 +390,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-lock"></span><span class="mls"> icon-lock</span>
 			</div>
@@ -380,7 +403,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-lock2"></span><span class="mls"> icon-lock2</span>
 			</div>
@@ -393,7 +416,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-key"></span><span class="mls"> icon-key</span>
 			</div>
@@ -406,7 +429,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-key2"></span><span class="mls"> icon-key2</span>
 			</div>
@@ -419,7 +442,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-zoomout"></span><span class="mls"> icon-zoomout</span>
 			</div>
@@ -432,7 +455,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-zoomin"></span><span class="mls"> icon-zoomin</span>
 			</div>
@@ -445,7 +468,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-cog"></span><span class="mls"> icon-cog</span>
 			</div>
@@ -458,7 +481,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-remove"></span><span class="mls"> icon-remove</span>
 			</div>
@@ -471,7 +494,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-remove2"></span><span class="mls"> icon-remove2</span>
 			</div>
@@ -484,7 +507,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-eye"></span><span class="mls"> icon-eye</span>
 			</div>
@@ -497,7 +520,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-eye-blocked"></span><span class="mls"> icon-eye-blocked</span>
 			</div>
@@ -510,7 +533,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-attachment"></span><span class="mls"> icon-attachment</span>
 			</div>
@@ -523,7 +546,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-globe"></span><span class="mls"> icon-globe</span>
 			</div>
@@ -536,7 +559,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-heart"></span><span class="mls"> icon-heart</span>
 			</div>
@@ -549,7 +572,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-happy"></span><span class="mls"> icon-happy</span>
 			</div>
@@ -562,7 +585,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-thumbs-up"></span><span class="mls"> icon-thumbs-up</span>
 			</div>
@@ -575,7 +598,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-smiley"></span><span class="mls"> icon-smiley</span>
 			</div>
@@ -588,7 +611,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-tongue"></span><span class="mls"> icon-tongue</span>
 			</div>
@@ -601,7 +624,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-sad"></span><span class="mls"> icon-sad</span>
 			</div>
@@ -614,7 +637,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-wink"></span><span class="mls"> icon-wink</span>
 			</div>
@@ -627,7 +650,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-wondering"></span><span class="mls"> icon-wondering</span>
 			</div>
@@ -640,7 +663,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-confused"></span><span class="mls"> icon-confused</span>
 			</div>
@@ -653,7 +676,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-shocked"></span><span class="mls"> icon-shocked</span>
 			</div>
@@ -666,7 +689,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-evil"></span><span class="mls"> icon-evil</span>
 			</div>
@@ -679,7 +702,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-angry"></span><span class="mls"> icon-angry</span>
 			</div>
@@ -692,7 +715,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-cool"></span><span class="mls"> icon-cool</span>
 			</div>
@@ -705,7 +728,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-grin"></span><span class="mls"> icon-grin</span>
 			</div>
@@ -718,7 +741,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-info"></span><span class="mls"> icon-info</span>
 			</div>
@@ -731,7 +754,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-notification"></span><span class="mls"> icon-notification</span>
 			</div>
@@ -744,7 +767,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-warning"></span><span class="mls"> icon-warning</span>
 			</div>
@@ -757,7 +780,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-spell-check"></span><span class="mls"> icon-spell-check</span>
 			</div>
@@ -770,7 +793,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-volume-high"></span><span class="mls"> icon-volume-high</span>
 			</div>
@@ -783,7 +806,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-volume-medium"></span><span class="mls"> icon-volume-medium</span>
 			</div>
@@ -796,7 +819,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-volume-low"></span><span class="mls"> icon-volume-low</span>
 			</div>
@@ -809,7 +832,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-volume-mute"></span><span class="mls"> icon-volume-mute</span>
 			</div>
@@ -822,7 +845,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-volume-mute2"></span><span class="mls"> icon-volume-mute2</span>
 			</div>
@@ -835,7 +858,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-volume-decrease"></span><span class="mls"> icon-volume-decrease</span>
 			</div>
@@ -848,7 +871,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-volume-increase"></span><span class="mls"> icon-volume-increase</span>
 			</div>
@@ -861,7 +884,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-bold"></span><span class="mls"> icon-bold</span>
 			</div>
@@ -874,7 +897,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-underline"></span><span class="mls"> icon-underline</span>
 			</div>
@@ -887,7 +910,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-italic"></span><span class="mls"> icon-italic</span>
 			</div>
@@ -900,7 +923,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-strikethrough"></span><span class="mls"> icon-strikethrough</span>
 			</div>
@@ -913,7 +936,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-newtab"></span><span class="mls"> icon-newtab</span>
 			</div>
@@ -926,7 +949,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-youtube"></span><span class="mls"> icon-youtube</span>
 			</div>
@@ -939,7 +962,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-close"></span><span class="mls"> icon-close</span>
 			</div>
@@ -952,7 +975,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-blocked"></span><span class="mls"> icon-blocked</span>
 			</div>
@@ -965,7 +988,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-cancel-circle"></span><span class="mls"> icon-cancel-circle</span>
 			</div>
@@ -978,7 +1001,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-minus"></span><span class="mls"> icon-minus</span>
 			</div>
@@ -991,7 +1014,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-plus"></span><span class="mls"> icon-plus</span>
 			</div>
@@ -1004,7 +1027,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-checkbox-checked"></span><span class="mls"> icon-checkbox-checked</span>
 			</div>
@@ -1017,7 +1040,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-checkbox-unchecked"></span><span class="mls"> icon-checkbox-unchecked</span>
 			</div>
@@ -1030,7 +1053,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-checkbox-partial"></span><span class="mls"> icon-checkbox-partial</span>
 			</div>
@@ -1043,7 +1066,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-radio-checked"></span><span class="mls"> icon-radio-checked</span>
 			</div>
@@ -1056,7 +1079,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-radio-unchecked"></span><span class="mls"> icon-radio-unchecked</span>
 			</div>
@@ -1069,7 +1092,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-info2"></span><span class="mls"> icon-info2</span>
 			</div>
@@ -1082,7 +1105,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-newspaper"></span><span class="mls"> icon-newspaper</span>
 			</div>
@@ -1095,7 +1118,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-image"></span><span class="mls"> icon-image</span>
 			</div>
@@ -1108,7 +1131,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-offline"></span><span class="mls"> icon-offline</span>
 			</div>
@@ -1121,7 +1144,7 @@
 				<input type="text" readonly value="" class="liga unitRight" />
 			</div>
 		</div>
-		<div class="glyph fs2">
+		<div class="glyph fs1">
 			<div class="clearfix bshadow0 pbs">
 				<span class="icon-busy"></span><span class="mls"> icon-busy</span>
 			</div>

BIN
fonticons/fonts/icomoon.eot


+ 3 - 1
fonticons/fonts/icomoon.svg

@@ -12,6 +12,8 @@
 <glyph unicode="&#x2364;" d="M256-32c141.385 0 256 114.615 256 256s-114.615 256-256 256-256-114.615-256-256 114.615-256 256-256zM256 432c114.875 0 208-93.125 208-208s-93.125-208-208-208-208 93.125-208 208 93.125 208 208 208zM192 128c0 35.346 28.654 64 64 64s64-28.654 64-64c0-35.346-28.654-64-64-64s-64 28.654-64 64zM320 304c0-26.51 14.327-48 32-48s32 21.49 32 48c0 26.51-14.327 48-32 48s-32-21.49-32-48zM128 304c0-26.51 14.327-48 32-48s32 21.49 32 48c0 26.51-14.327 48-32 48s-32-21.49-32-48z" />
 <glyph unicode="&#x2368;" d="M256-32c141.385 0 256 114.615 256 256s-114.615 256-256 256-256-114.615-256-256 114.615-256 256-256zM256 432c114.875 0 208-93.125 208-208s-93.125-208-208-208-208 93.125-208 208 93.125 208 208 208zM128 320c0-17.673 14.327-32 32-32s32 14.327 32 32c0 17.673-14.327 32-32 32s-32-14.327-32-32zM320 320c0-17.673 14.327-32 32-32s32 14.327 32 32c0 17.673-14.327 32-32 32s-32-14.327-32-32zM363.053 160h32.432c4.623-36.253-16.226-72.265-51.979-85.28-41.452-15.088-87.45 6.358-102.54 47.808-9.054 24.872-36.653 37.741-61.524 28.686-22.781-8.294-35.478-32.149-30.494-55.212h-32.43c-4.621 36.254 16.225 72.264 51.978 85.28 41.452 15.089 87.451-6.358 102.541-47.807 9.052-24.874 36.653-37.741 61.522-28.686 22.781 8.292 35.478 32.149 30.494 55.211z" />
 <glyph unicode="&#x2369;" d="M256-32c141.385 0 256 114.615 256 256s-114.615 256-256 256-256-114.615-256-256 114.615-256 256-256zM256 432c114.875 0 208-93.125 208-208s-93.125-208-208-208-208 93.125-208 208 93.125 208 208 208zM372.87 179.19l11.244-38.388-218.504-64.001-11.244 38.388zM128 320c0 17.673 14.327 32 32 32s32-14.327 32-32c0-17.673-14.327-32-32-32s-32 14.327-32 32zM320 320c0 17.673 14.327 32 32 32s32-14.327 32-32c0-17.673-14.327-32-32-32s-32 14.327-32 32z" />
+<glyph unicode="&#x25ba;" d="M96 416l320-192-320-192z" />
+<glyph unicode="&#x25bc;" d="M448 384l-192-320-192 320z" />
 <glyph unicode="&#x25d9;" d="M490.594 399.946c-71.816 10.325-151.166 16.054-234.593 16.054-83.43 0-162.778-5.729-234.597-16.054-13.765-53.863-21.404-113.375-21.404-175.946 0-62.57 7.639-122.083 21.404-175.945 71.819-10.326 151.168-16.055 234.597-16.055 83.427 0 162.776 5.729 234.593 16.055 13.766 53.862 21.406 113.375 21.406 175.945 0 62.571-7.64 122.083-21.406 175.946zM192.001 128v192l160-96-160-96z" />
 <glyph unicode="&#x25fb;" d="M256 384c-27.466 0-53.994-4.331-78.847-12.871-23.356-8.027-44.153-19.372-61.814-33.722-33.107-26.899-51.339-61.492-51.339-97.407 0-20.149 5.594-39.689 16.626-58.076 11.376-18.96 28.491-36.293 49.494-50.126 15.178-9.996 25.39-25.974 28.088-43.947 0.9-5.992 1.464-12.044 1.685-18.062 3.735 3.097 7.375 6.423 10.94 9.988 12.077 12.076 28.39 18.745 45.251 18.745 2.684 0 5.381-0.168 8.078-0.512 10.487-1.333 21.199-2.010 31.838-2.010 27.467 0 53.994 4.33 78.847 12.871 23.356 8.027 44.153 19.372 61.814 33.722 33.107 26.898 51.339 61.492 51.339 97.407s-18.232 70.508-51.339 97.407c-17.661 14.349-38.458 25.695-61.814 33.722-24.853 8.54-51.38 12.871-78.847 12.871zM256 448v0c141.385 0 256-93.125 256-208s-114.615-208-256-208c-13.578 0-26.905 0.867-39.912 2.522-54.989-54.989-120.625-64.85-184.088-66.298v13.458c34.268 16.789 64 47.37 64 82.318 0 4.877-0.379 9.665-1.082 14.348-57.898 38.132-94.918 96.377-94.918 161.652 0 114.875 114.615 208 256 208z" />
 <glyph unicode="&#x25fc;" d="M256 448c141.385 0 256-93.125 256-208s-114.615-208-256-208c-13.578 0-26.905 0.867-39.912 2.522-54.989-54.989-120.625-64.85-184.088-66.298v13.458c34.268 16.789 64 47.37 64 82.318 0 4.877-0.379 9.665-1.082 14.348-57.898 38.132-94.918 96.377-94.918 161.652 0 114.875 114.615 208 256 208z" />
@@ -31,6 +33,7 @@
 <glyph unicode="&#x270e;" d="M432 480c44.182 0 80-35.817 80-80 0-18.010-5.955-34.629-16-48l-32-32-112 112 32 32c13.371 10.045 29.989 16 48 16zM32 112l-32-144 144 32 296 296-112 112-296-296zM357.789 298.211l-224-224-27.578 27.578 224 224 27.578-27.578z" />
 <glyph unicode="&#x270f;" d="M480 352c17.673 0 32 14.327 32 32v64h-64v32h-416c-17.6 0-32-14.399-32-32v-448c0-17.6 14.398-32 32-32h416v128h32c17.673 0 32 14.327 32 32v64h-64v32h32c17.673 0 32 14.327 32 32v64h-64v32h32zM288 351.835c35.255 0 63.835-28.58 63.835-63.835s-28.58-63.835-63.835-63.835c-35.255 0-63.835 28.58-63.835 63.835s28.58 63.835 63.835 63.835zM128 0h-32v448h32v-448zM384 96h-192v32c0 35.347 28.654 64 64 64v0h64c35.348 0 64-28.653 64-64v-32z" />
 <glyph unicode="&#x2710;" d="M449.18 448h-385.18v-64h-46.82c-8.8 0-17.18-6.264-17.18-15.064v-32c0-8.8 8.38-16.936 17.18-16.936h46.82v-32h-46.82c-8.8 0-17.18-6.264-17.18-15.064v-32c0-8.8 8.38-16.936 17.18-16.936h46.82v-32h-46.82c-8.8 0-17.18-6.264-17.18-15.064v-32c0-8.799 8.38-16.936 17.18-16.936h46.82v-32h-46.82c-8.8 0-17.18-6.264-17.18-15.064v-32c0-8.8 8.38-16.936 17.18-16.936h46.82v-64h385.18c17.674 0 30.82 15.263 30.82 32.936v416c0 17.673-13.146 31.064-30.82 31.064zM160 0h-64v32h17.18c8.8 0 14.82 8.136 14.82 16.936v32c0 8.801-6.021 15.064-14.82 15.064h-17.18v32h17.18c8.8 0 14.82 8.136 14.82 16.936v32c0 8.801-6.021 15.064-14.82 15.064h-17.18v32h17.18c8.8 0 14.82 8.136 14.82 16.936v32c0 8.801-6.021 15.064-14.82 15.064h-17.18v32h17.18c8.8 0 14.82 8.136 14.82 16.936v32c0 8.801-6.020 15.064-14.82 15.064h-17.18v32h64v-416z" />
+<glyph unicode="&#x2713;" d="M432 416l-240-240-112 112-80-80 192-192 320 320z" />
 <glyph unicode="&#x2715;" d="M507.331 68.67c-0.002 0.002-0.004 0.004-0.006 0.005l-155.322 155.325 155.322 155.325c0.002 0.002 0.004 0.003 0.006 0.005 1.672 1.673 2.881 3.627 3.656 5.708 2.123 5.688 0.912 12.341-3.662 16.915l-73.373 73.373c-4.574 4.573-11.225 5.783-16.914 3.66-2.080-0.775-4.035-1.984-5.709-3.655 0-0.002-0.002-0.003-0.004-0.005l-155.324-155.326-155.324 155.325c-0.002 0.002-0.003 0.003-0.005 0.005-1.673 1.671-3.627 2.88-5.707 3.655-5.69 2.124-12.341 0.913-16.915-3.66l-73.374-73.374c-4.574-4.574-5.784-11.226-3.661-16.914 0.776-2.080 1.985-4.036 3.656-5.708 0.002-0.001 0.003-0.003 0.005-0.005l155.325-155.324-155.325-155.326c-0.001-0.002-0.003-0.003-0.004-0.005-1.671-1.673-2.88-3.627-3.657-5.707-2.124-5.688-0.913-12.341 3.661-16.915l73.374-73.373c4.575-4.574 11.226-5.784 16.915-3.661 2.080 0.776 4.035 1.985 5.708 3.656 0.001 0.002 0.003 0.003 0.005 0.005l155.324 155.325 155.324-155.325c0.002-0.001 0.004-0.003 0.006-0.004 1.674-1.672 3.627-2.881 5.707-3.657 5.689-2.123 12.342-0.913 16.914 3.661l73.373 73.374c4.574 4.574 5.785 11.227 3.662 16.915-0.776 2.080-1.985 4.034-3.657 5.707z" />
 <glyph unicode="&#x2718;" d="M0 224c0-141.385 114.615-256 256-256s256 114.615 256 256-114.614 256-256 256c-141.385 0-256-114.615-256-256zM448 224c0-36.618-10.256-70.84-28.044-99.956l-263.911 263.912c29.115 17.789 63.337 28.044 99.955 28.044 106.038 0 192-85.961 192-192zM64 224c0 36.618 10.256 70.839 28.045 99.956l263.911-263.912c-29.117-17.789-63.338-28.044-99.956-28.044-106.038 0-192 85.961-192 192z" />
 <glyph unicode="&#x271a;" d="M496 288h-176v176c0 8.836-7.164 16-16 16h-96c-8.836 0-16-7.164-16-16v-176h-176c-8.836 0-16-7.164-16-16v-96c0-8.836 7.164-16 16-16h176v-176c0-8.836 7.164-16 16-16h96c8.836 0 16 7.164 16 16v176h176c8.836 0 16 7.164 16 16v96c0 8.836-7.164 16-16 16z" />
@@ -92,5 +95,4 @@
 <glyph unicode="&#xe058;" d="M256 480c-141.385 0-256-114.615-256-256s114.615-256 256-256 256 114.615 256 256-114.615 256-256 256zM384 306.745l-82.744-82.745 82.744-82.744v-45.256h-45.256l-82.744 82.744-82.745-82.744h-45.255v45.256l82.745 82.744-82.745 82.745v45.255h45.255l82.745-82.745 82.744 82.745h45.256v-45.255z" />
 <glyph unicode="&#xe059;" d="M256 480c-141.385 0-256-114.615-256-256s114.615-256 256-256 256 114.615 256 256-114.615 256-256 256zM224 384h64v-64h-64v64zM320 64h-128v32h32v128h-32v32h96v-160h32v-32z" />
 <glyph unicode="&#xe05a;" d="M0 272v-96c0-8.836 7.164-16 16-16h480c8.836 0 16 7.164 16 16v96c0 8.836-7.164 16-16 16h-480c-8.836 0-16-7.164-16-16z" />
-<glyph unicode="&#xe600;" d="M432 416l-240-240-112 112-80-80 192-192 320 320z" />
 </font></defs></svg>

BIN
fonticons/fonts/icomoon.ttf


BIN
fonticons/fonts/icomoon.woff


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 189 - 145
fonticons/selection.json


+ 13 - 7
fonticons/style.css

@@ -1,10 +1,10 @@
 @font-face {
 	font-family: 'icomoon';
-	src:url('fonts/icomoon.eot?7poj4t');
-	src:url('fonts/icomoon.eot?#iefix7poj4t') format('embedded-opentype'),
-		url('fonts/icomoon.woff?7poj4t') format('woff'),
-		url('fonts/icomoon.ttf?7poj4t') format('truetype'),
-		url('fonts/icomoon.svg?7poj4t#icomoon') format('svg');
+	src:url('fonts/icomoon.eot?-m2p76k');
+	src:url('fonts/icomoon.eot?#iefix-m2p76k') format('embedded-opentype'),
+		url('fonts/icomoon.woff?-m2p76k') format('woff'),
+		url('fonts/icomoon.ttf?-m2p76k') format('truetype'),
+		url('fonts/icomoon.svg?-m2p76k#icomoon') format('svg');
 	font-weight: normal;
 	font-style: normal;
 }
@@ -23,8 +23,14 @@
 	-moz-osx-font-smoothing: grayscale;
 }
 
+.icon-closed:before {
+	content: "\25ba";
+}
+.icon-opened:before {
+	content: "\25bc";
+}
 .icon-checkmark:before {
-	content: "\e600";
+	content: "\2713";
 }
 .icon-home:before {
 	content: "\e000";
@@ -38,7 +44,7 @@
 .icon-camera2:before {
 	content: "\2616";
 }
-.icon-play:before {
+.icon-play22:before {
 	content: "\25d9";
 }
 .icon-music:before {

+ 2 - 1
index.html

@@ -226,13 +226,14 @@
             allow_otr: true,
             auto_list_rooms: false,
             auto_subscribe: false,
-            bosh_service_url: 'https://bind.opkode.com', // Please use this connection manager only for testing purposes
+            bosh_service_url: 'https://bind.conversejs.org', // Please use this connection manager only for testing purposes
             debug: true ,
             hide_muc_server: false,
             i18n: locales['en'], // Refer to ./locale/locales.js to see which locales are supported
             prebind: false,
             show_controlbox_by_default: true,
             xhr_user_search: false,
+            roster_groups: true
         });
     });
 </script>

+ 40 - 16
less/converse.less

@@ -46,8 +46,14 @@
 	-moz-osx-font-smoothing: grayscale;
 }
 
+.icon-closed:before {
+	content: "\25ba";
+}
+.icon-opened:before {
+	content: "\25bc";
+}
 .icon-checkmark:before {
-	content: "\e600";
+	content: "\2713";
 }
 #conversejs .icon-home:before {
 	content: "\e000";
@@ -897,8 +903,7 @@ dl.add-converse-contact {
 }
 
 #conversejs .controlbox-pane dt {
-    margin: 0;
-    padding-top: 0.5em;
+    padding: 3px;
 }
 
 #conversejs .chatroom-form-container {
@@ -939,11 +944,11 @@ dl.add-converse-contact {
     float: right;
 }
 
-#conversejs #converse-roster dd.odd {
+#converse-roster dd.odd {
     background-color: #DCEAC5; /* Make this difference */
 }
 
-#conversejs #converse-roster dd.current-xmpp-contact span {
+#converse-roster dd.current-xmpp-contact span {
     font-size: 16px;
     float: left;
     color: rgb(79, 79, 79);
@@ -954,8 +959,8 @@ dl.add-converse-contact {
     float: right;
 }
 
-#conversejs #converse-roster dd a,
-#conversejs #converse-roster dd span {
+#converse-roster dd a,
+#converse-roster dd span {
     text-shadow: 0 1px 0 rgba(250, 250, 250, 1);
     display: inline-block;
     overflow: hidden;
@@ -963,11 +968,20 @@ dl.add-converse-contact {
     text-overflow: ellipsis;
 }
 
-#conversejs #converse-roster dd span {
+#conversejs #converse-roster span.req-contact-name {
+    width: 65%;
+}
+
+#conversejs #converse-roster span.pending-contact-name,
+#conversejs #converse-roster a.open-chat {
+    width: 80%;
+}
+
+#converse-roster dd span {
     padding: 0 5px 0 0;
 }
 
-#conversejs #converse-roster {
+#converse-roster {
     overflow-y: auto;
     overflow-x: hidden;
     width: 100%;
@@ -977,6 +991,10 @@ dl.add-converse-contact {
     height: ~"calc(100% - 70px)";
 }
 
+#converse-roster .group-toggle {
+    color: #666;
+}
+
 #conversejs dd.available-chatroom {
     overflow-x: hidden;
     text-overflow: ellipsis;
@@ -988,8 +1006,8 @@ dl.add-converse-contact {
     width: 148px;
 }
 
-#conversejs #available-chatrooms dt,
-#conversejs #converse-roster dt {
+#available-chatrooms dt,
+#converse-roster dt {
     font-weight: normal;
     font-size: 13px;
     color: #666;
@@ -998,7 +1016,7 @@ dl.add-converse-contact {
     text-shadow: 0 1px 0 rgba(250, 250, 250, 1);
 }
 
-#conversejs #converse-roster dt {
+#converse-roster dt {
     display: none;
 }
 
@@ -1060,6 +1078,12 @@ dl.add-converse-contact {
     line-height: 16px;
 }
 
+#conversejs .group-toggle {
+    display: block;
+    width: 100%;
+}
+
+#conversejs .roster-group:hover,
 #conversejs dd.available-chatroom:hover,
 #conversejs #converse-roster dd:hover {
     background-color: #eee;
@@ -1081,10 +1105,6 @@ dl.add-converse-contact {
     display: inline-block;
 }
 
-#conversejs #converse-roster a.open-chat {
-    width: 80%;
-}
-
 #conversejs .chatbox,
 #conversejs .chatroom {
     height: 25px;
@@ -1462,6 +1482,10 @@ input.custom-xmpp-status {
     background-color: #bed6e5;
 }
 
+#conversejs .chatbox .dropdown dd.search-xmpp ul li:hover {
+    background-color: #bed6e5;
+}
+
 #conversejs .xmpp-status-menu li a {
     width: 100%;
 }

+ 352 - 0
mockup/controlbox.html

@@ -0,0 +1,352 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <title id="pageTitle">Converse.js: Mockup</title>
+    <meta charset="utf-8">
+    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
+    <meta name="description" content="Converse.js: Mockup" />
+    <link type="text/css" href="../css/theme.css" rel="stylesheet" media="screen" />
+    <link type="text/css" href="../css/converse.css" rel="stylesheet" media="screen" />
+    <script src="../components/jquery/dist/jquery.min.js"></script>
+</head>
+<body id="page-top" data-spy="scroll" data-target=".navbar-custom">
+
+<!-- HEADER -->
+<div id="header_wrap" class="outer">
+    <header class="inner">
+        <h1 id="project_title"><a href="http://conversejs.org">Converse.js</a></h1>
+        <h2 id="project_tagline">Static Mockup</h2>
+    </header>
+</div>
+
+<div id="conversejs">
+    <a id="toggle-controlbox" href="#" class="toggle-controlbox">
+        <span class="conn-feedback">Toggle Chat</span>
+        <span style="display: none" id="online-count">(0)</span>
+    </a>
+
+    <div id="controlbox" class="chatbox" style="opacity: 1; display: inline;">
+        <div class="box-flyout">
+            <div class="dragresize dragresize-tm"></div>
+            <div class="chat-head controlbox-head">
+                <ul id="controlbox-tabs">
+                    <li><a class="current" href="#login">Sign in</a></li>
+                </ul>
+                <a class="close-chatbox-button icon-close"></a>
+            </div>
+            <div id="login-dialog">
+                <form id="converse-login">
+                    <label>XMPP/Jabber Username:</label><input type="text" id="jid">
+                    <label>Password:</label><input type="password" id="password">
+                    <input class="login-submit" type="submit" value="Log In">
+                </form>
+                <span class="conn-feedback"></span>
+            </div>
+        </div>
+    </div>
+
+    <div id="controlbox" class="chatbox" style="opacity: 1; display: inline;">
+        <div class="box-flyout">
+            <div class="dragresize dragresize-tm"></div>
+            <div class="chat-head controlbox-head">
+                <ul id="controlbox-tabs">
+                    <li><a class="s current" href="#users">Contacts</a></li>
+                    <li><a class="s" href="#chatrooms">Rooms</a></li>
+                </ul>
+                <a class="close-chatbox-button icon-close"></a>
+            </div>
+            <div id="users" class="controlbox-pane" style="display: block;">
+                <form class="set-xmpp-status" action="" method="post">
+                    <span id="xmpp-status-holder">
+                    <dl id="target" class="dropdown">
+                        <dt id="fancy-xmpp-status-select" class="fancy-dropdown">
+                            <div class="xmpp-status">
+                                <a class="choose-xmpp-status online" data-value="I am online" href="#" title="Click to change your chat status">
+                                    <span class="icon-online"></span>
+                                    I am online
+                                </a>
+                                <a class="change-xmpp-status-message icon-pencil" href="#" title="Click here to write a custom status message"></a>
+                            </div>
+                        </dt>
+                        <dd>
+                            <ul style="display: none;" class="xmpp-status-menu">
+                                <li>
+                                    <a href="#" class="online" data-value="online">
+                                    <span class="icon-online"></span>
+                                    Online</a>
+                                </li>
+                                <li>
+                                    <a href="#" class="dnd" data-value="dnd">
+                                    <span class="icon-dnd"></span>
+                                    Busy</a>
+                                </li>
+                                <li>
+                                    <a href="#" class="away" data-value="away">
+                                    <span class="icon-away"></span>
+                                    Away</a>
+                                </li>
+                                <li>
+                                    <a href="#" class="offline" data-value="offline">
+                                    <span class="icon-offline"></span>
+                                    Offline</a>
+                                </li>
+                            </ul>
+                        </dd>
+                    </dl>
+                    </span>
+                </form>
+                <dl class="add-converse-contact dropdown">
+                    <dt id="xmpp-contact-search" class="fancy-dropdown">
+                        <a class="toggle-xmpp-contact-form" href="#" title="Click to add new chat contacts">
+                            <span class="icon-plus"></span>
+                            Add a contact
+                        </a>
+                    </dt>
+                    <dd class="search-xmpp" style="display:none">
+                        <ul>
+                            <li>
+                                <form class="add-xmpp-contact">
+                                    <input type="text" name="identifier" class="username" placeholder="Contact username">
+                                    <button type="submit">Add</button>
+                                </form>
+                            </li>
+                            <li></li>
+                        </ul>
+                    </dd>
+                </dl>
+                <dl id="converse-roster" style="display: block;">
+                    <dt class="roster-group" style="display: block;">
+                        <a href="#" data-group="Colleagues" class="group-toggle icon-opened" title="Click to hide these contacts">Colleagues</a>
+                    </dt>
+                    <dd class="online current-xmpp-contact">
+                        <a class="open-chat" title="Click to chat with this contact" href="#">
+                            <span class="icon-online" title="This contact is online"></span>
+                            Victor Matfield
+                        </a>
+                        <a class="remove-xmpp-contact icon-remove" title="Click to remove this contact" href="#"></a>
+                    </dd>
+                    <dd class="away current-xmpp-contact">
+                        <a class="open-chat" title="Click to chat with this contact" href="#">
+                            <span class="icon-away" title="this contact is away"></span>
+                            William Winterbottom
+                        </a>
+                        <a class="remove-xmpp-contact icon-remove" title="Click to remove this contact" href="#"></a>
+                    </dd>
+                    <dd class="dnd current-xmpp-contact">
+                        <a class="open-chat" title="Click to chat with this contact" href="#">
+                            <span class="icon-dnd" title="This contact is busy"></span>
+                            Gary Teichmann
+                        </a>
+                        <a class="remove-xmpp-contact icon-remove" title="Click to remove this contact" href="#"></a>
+                    </dd>
+
+                    <dt class="roster-group" style="display: block;">
+                        <a href="#" data-group="Family" class="group-toggle icon-opened" title="Click to hide these contacts">Family</a>
+                    </dt>
+                    <dd class="away current-xmpp-contact">
+                        <a class="open-chat" title="Click to chat with this contact" href="#">
+                            <span class="icon-away" title="this contact is away"></span>
+                            Allan Donald 
+                        </a>
+                        <a class="remove-xmpp-contact icon-remove" title="Click to remove this contact" href="#"></a>
+                    </dd>
+                    <dd class="offline current-xmpp-contact">
+                        <a class="open-chat" title="Click to chat with this contact" href="#">
+                            <span class="icon-offline" title="This contact is offline"></span>
+                            Corné Krige
+                        </a>
+                        <a class="remove-xmpp-contact icon-remove" title="Click to remove this contact" href="#"></a>
+                    </dd>
+
+                    <dt class="roster-group" style="display: block;">
+                        <a href="#" data-group="Friends" class="group-toggle icon-opened" title="Click to hide these contacts">Friends</a>
+                    </dt>
+                    <dd class="online current-xmpp-contact">
+                        <a class="open-chat" title="Click to chat with this contact" href="#">
+                            <span class="icon-online" title="This contact is online"></span>
+                            John Smit
+                        </a>
+                        <a class="remove-xmpp-contact icon-remove" title="Click to remove this contact" href="#"></a>
+                    </dd>
+                    <dd class="online current-xmpp-contact">
+                        <a class="open-chat" title="Click to chat with this contact" href="#">
+                            <span class="icon-online" title="This contact is online"></span>
+                            Bakkies Botha
+                        </a>
+                        <a class="remove-xmpp-contact icon-remove" title="Click to remove this contact" href="#"></a>
+                    </dd>
+
+                    <dt class="roster-group" style="display: block;">
+                        <a href="#" class="group-toggle icon-opened" title="Click to hide these contacts">Ungrouped</a>
+                    </dt>
+                    <dd class="online current-xmpp-contact">
+                        <a class="open-chat" title="Click to chat with this contact" href="#">
+                            <span class="icon-online" title="This contact is online"></span>
+                            James Small 
+                        </a>
+                        <a class="remove-xmpp-contact icon-remove" title="Click to remove this contact" href="#"></a>
+                    </dd>
+
+                    <dt id="xmpp-contact-requests" style="display: block;">
+                        <a href="#" class="group-toggle icon-opened" title="Click to hide these contacts">Contact Requests</a>
+                    </dt>
+                    <dd class="offline requesting-xmpp-contact">
+                        <span class="req-contact-name">Bob Skinstad</span>
+                        <span class="request-actions">
+                            <a class="accept-xmpp-request icon-checkmark" title="Click here to accept this contact's request" href="#"></a>
+                            <a class="decline-xmpp-request icon-close" title="Click here to decline this contact's request" href="#"></a>
+                        </span>
+                    </dd>
+                    <dd class="offline requesting-xmpp-contact">
+                        <span class="req-contact-name">André Vos</span>
+                        <span class="request-actions">
+                            <a class="accept-xmpp-request icon-checkmark" title="Click here to accept this contact's request" href="#"></a>
+                            <a class="decline-xmpp-request icon-close" title="Click here to decline this contact's request" href="#"></a>
+                        </span>
+                    </dd>
+
+                    <dt id="pending-xmpp-contacts" style="display: block;">
+                        <a href="#" class="group-toggle icon-opened" title="Click to hide these contacts">Pending Contacts</a>
+                    </dt>
+                    <dd class="offline pending-xmpp-contact"><span class="pending-contact-name">Rassie Erasmus</span>
+                        <a class="remove-xmpp-contact icon-remove" title="Click to remove this contact" href="#"></a>
+                    </dd>
+                    <dd class="offline pending-xmpp-contact"><span class="pending-contact-name">Victor Matfield</span>
+                        <a class="remove-xmpp-contact icon-remove" title="Click to remove this contact" href="#"></a>
+                    </dd>
+                </dl>
+            </div>
+            <div id="chatrooms" style="display: none;">
+                <form class="add-chatroom" action="" method="post">
+                    <input type="text" name="chatroom" class="new-chatroom-name" placeholder="Room name">
+                    <input type="text" name="nick" class="new-chatroom-nick" placeholder="Nickname">
+                    <input type="text" name="server" class="new-chatroom-server" placeholder="Server">
+                    <input type="submit" name="join" value="Join">
+                    <input type="button" name="show" id="show-rooms" value="Show rooms" style="display: inline-block;">
+                </form>
+                <dl id="available-chatrooms">
+                    <dt>Rooms on conference.opkode.im</dt>
+                    <dd class="available-chatroom">
+                        <a class="open-room" 
+                            data-room-jid="converse.js@conference.opkode.im"
+                            title="Click to open this room" href="#">Special chatroom with a long name (2)</a>
+                        <a class="room-info icon-room-info" 
+                            data-room-jid="converse.js@conference.opkode.im" 
+                            title="Show more information on this room" href="#">&nbsp;</a>
+                        <div class="room-info">
+                            <p class="room-info"><strong>Description:</strong></p>
+                            <p class="room-info"><strong>Occupants:</strong> 2</p>
+                            <p class="room-info"><strong>Features:</strong> </p>
+                            <ul>
+                                <li class="room-info">Moderated</li><li class="room-info">Open room</li>
+                                <li class="room-info">Permanent room</li><li class="room-info">Public</li>
+                                <li class="room-info">Semi-anonymous</li>
+                                <li class="room-info">Requires authentication <span class="icon-lock"></span></li>
+                                <p></p>
+                            </ul>
+                        </div>
+                    </dd>
+                </dl>
+            </div>
+        </div>
+    </div>
+</div>
+
+<script>
+$(document).ready(function () {
+    $('a[href=#chatrooms]').click(function (ev) { 
+        switchTab(ev);
+    });
+    $('a[href=#users]').click(function (ev) {
+        switchTab(ev); 
+    });
+
+    $("a.choose-xmpp-status").click(function (ev) {
+        ev.preventDefault();
+        $(ev.target).parent().parent().siblings('dd').find('ul').toggle('fast');
+    });
+
+    $("a.change-xmpp-status-message").click(function (ev) {
+        ev.preventDefault();
+        var form = ''+
+                '<form id="set-custom-xmpp-status">' +
+                    '<input type="text" class="custom-xmpp-status"I am online"'+
+                        'placeholder="I am online"/>' +
+                    '<button type="submit">Save</button>' +
+                '</form>';
+
+        $(ev.target).closest('.xmpp-status').replaceWith(form);
+        $(ev.target).closest('.custom-xmpp-status').focus().focus();
+    });
+
+    $('.toggle-xmpp-contact-form').click(function (ev) {
+        ev.preventDefault();
+        $(ev.target).parent().parent().find('.search-xmpp').toggle('fast', function () {
+            if ($(this).is(':visible')) {
+                $(this).find('input.username').focus();
+            }
+        });
+    });
+
+    var switchTab = function (ev) {
+        ev.preventDefault();
+        var $tab = $(ev.target),
+            $sibling = $tab.parent().siblings('li').children('a'),
+            $tab_panel = $($tab.attr('href')),
+            $sibling_panel = $($sibling.attr('href'));
+
+        $sibling_panel.hide();
+        $sibling.removeClass('current');
+        $tab.addClass('current');
+        $tab_panel.show();
+    }
+
+    $(function() {
+        $('.group-toggle').click(function(ev) {
+            ev.preventDefault();
+            var $el = $(ev.target);
+            $el.parent().nextUntil('dt').slideToggle();
+            if ($el.hasClass("icon-opened")) {
+                $el.removeClass("icon-opened").addClass("icon-closed");
+            } else {
+                $el.removeClass("icon-closed").addClass("icon-opened");
+            }
+        });
+
+        $('.close-chatbox-button').click(function(ev) {
+            var $grandparent = $(ev.target).parent().parent().parent();
+            $grandparent.hide(300, function () {
+                // Webkit fix
+                document.getElementById('conversejs').style.display = 'none';
+                document.getElementById('conversejs').offsetHeight; // no need to store this anywhere, the reference is enough
+                document.getElementById('conversejs').style.display = 'block';
+            });
+        });
+
+        $('.toggle-chatbox-button').click(function(ev) {
+            var $grandparent = $(ev.target).parent().parent().parent();
+            $grandparent.fadeOut('fast');
+        });
+
+        // Clickable Dropdown
+        $('.toggle-otr').click(function(e) {
+            $('.toggle-otr ul').slideToggle(200);
+            e.stopPropagation();
+        });
+
+        $('.toggle-smiley').click(function(e) {
+            $(e.target).find('ul').slideToggle(200);
+            e.stopPropagation();
+        });
+        $(document).click(function() {
+            if ($('.toggle-otr ul').is(':visible')) {
+                $('.toggle-otr ul', this).slideUp(200);
+            }
+            if ($('.toggle-smiley ul').is(':visible')) {
+                $('.toggle-smiley ul', this).slideUp(200);
+            }
+        });
+    });
+});
+</script>
+</html>

+ 6 - 15
spec/chatbox.js

@@ -13,7 +13,7 @@
                 runs(function () {
                     utils.closeAllChatBoxes();
                     utils.removeControlBox();
-                    converse.roster.browserStorage._clear();
+                    utils.clearBrowserStorage();
                     utils.initConverse();
                     utils.createContacts();
                     utils.openControlBox();
@@ -22,23 +22,19 @@
             });
 
             it("is created when you click on a roster item", $.proxy(function () {
-                var i, $el, click, jid, view, chatboxview;
+                var i, $el, click, jid, chatboxview;
                 // openControlBox was called earlier, so the controlbox is
                 // visible, but no other chat boxes have been created.
                 expect(this.chatboxes.length).toEqual(1);
                 spyOn(this.chatboxviews, 'trimChats');
                 expect($("#conversejs .chatbox").length).toBe(1); // Controlbox is open
 
-                var online_contacts = this.rosterview.$el.find('dt#xmpp-contacts').siblings('dd.current-xmpp-contact').find('a.open-chat');
+                var online_contacts = this.rosterview.$el.find('dt.roster-group').siblings('dd.current-xmpp-contact').find('a.open-chat');
                 for (i=0; i<online_contacts.length; i++) {
                     $el = $(online_contacts[i]);
                     jid = $el.text().replace(/ /g,'.').toLowerCase() + '@localhost';
-                    view = this.rosterview.get(jid);
-                    spyOn(view, 'openChat').andCallThrough();
-                    view.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
                     $el.click();
                     chatboxview = this.chatboxviews.get(jid);
-                    expect(view.openChat).toHaveBeenCalled();
                     expect(this.chatboxes.length).toEqual(i+2);
                     expect(this.chatboxviews.trimChats).toHaveBeenCalled();
                     // Check that new chat boxes are created to the left of the
@@ -49,7 +45,7 @@
             }, converse));
 
             it("can be trimmed to conserve space", $.proxy(function () {
-                var i, $el, click, jid, key, view, chatbox, chatboxview;
+                var i, $el, click, jid, key, chatbox, chatboxview;
                 // openControlBox was called earlier, so the controlbox is
                 // visible, but no other chat boxes have been created.
                 var trimmed_chatboxes = converse.minimized_chats;
@@ -60,11 +56,10 @@
                 expect($("#conversejs .chatbox").length).toBe(1); // Controlbox is open
 
                 // Test that they can be trimmed
-                var online_contacts = this.rosterview.$el.find('dt#xmpp-contacts').siblings('dd.current-xmpp-contact').find('a.open-chat');
+                var online_contacts = this.rosterview.$el.find('dt.roster-group').siblings('dd.current-xmpp-contact').find('a.open-chat');
                 for (i=0; i<online_contacts.length; i++) {
                     $el = $(online_contacts[i]);
                     jid = $el.text().replace(/ /g,'.').toLowerCase() + '@localhost';
-                    view = this.rosterview.get(jid);
                     $el.click();
                     expect(this.chatboxviews.trimChats).toHaveBeenCalled();
 
@@ -96,7 +91,7 @@
 
             it("is focused if its already open and you click on its corresponding roster item", $.proxy(function () {
                 var contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@localhost';
-                var i, $el, click, jid, view, chatboxview, chatbox;
+                var i, $el, click, jid, chatboxview, chatbox;
                 // openControlBox was called earlier, so the controlbox is
                 // visible, but no other chat boxes have been created.
                 expect(this.chatboxes.length).toEqual(1);
@@ -105,11 +100,7 @@
                 spyOn(chatboxview, 'focus');
                 $el = this.rosterview.$el.find('a.open-chat:contains("'+chatbox.get('fullname')+'")');
                 jid = $el.text().replace(/ /g,'.').toLowerCase() + '@localhost';
-                view = this.rosterview.get(jid);
-                spyOn(view, 'openChat').andCallThrough();
-                view.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
                 $el.click();
-                expect(view.openChat).toHaveBeenCalled();
                 expect(this.chatboxes.length).toEqual(2);
                 expect(chatboxview.focus).toHaveBeenCalled();
             }, converse));

+ 351 - 184
spec/controlbox.js

@@ -7,6 +7,23 @@
         }
     );
 } (this, function (mock, utils) {
+
+    var checkHeaderToggling = function ($header) {
+        var $toggle = $header.find('a.group-toggle');
+        expect($header.css('display')).toEqual('block');
+        expect($header.nextUntil('dt', 'dd').length === $header.nextUntil('dt', 'dd:visible').length).toBeTruthy();
+        expect($toggle.hasClass('icon-closed')).toBeFalsy();
+        expect($toggle.hasClass('icon-opened')).toBeTruthy();
+        $toggle.click();
+        expect($toggle.hasClass('icon-closed')).toBeTruthy();
+        expect($toggle.hasClass('icon-opened')).toBeFalsy();
+        expect($header.nextUntil('dt', 'dd').length === $header.nextUntil('dt', 'dd:hidden').length).toBeTruthy();
+        $toggle.click();
+        expect($toggle.hasClass('icon-closed')).toBeFalsy();
+        expect($toggle.hasClass('icon-opened')).toBeTruthy();
+        expect($header.nextUntil('dt', 'dd').length === $header.nextUntil('dt', 'dd:visible').length).toBeTruthy();
+    };
+
     describe("The Control Box", $.proxy(function (mock, utils) {
         beforeEach(function () {
             runs(function () {
@@ -107,264 +124,416 @@
 
     describe("The Contacts Roster", $.proxy(function (mock, utils) {
 
-        describe("Pending Contacts", $.proxy(function () {
-            beforeEach($.proxy(function () {
-                runs(function () {
-                    converse.rosterview.model.reset();
-                    utils.createContacts('pending').openControlBox();
-                });
-                waits(50);
-                runs(function () {
-                    utils.openContactsPanel();
-                });
-            }, converse));
+        describe("A Roster Group", $.proxy(function () {
+
+            beforeEach(function () {
+                converse.roster_groups = true;
+            });
+
+            afterEach(function () {
+                converse.roster_groups = false;
+            });
 
-            it("do not have a heading if there aren't any", $.proxy(function () {
+            function _clearContacts () {
+                utils.clearBrowserStorage();
                 converse.rosterview.model.reset();
-                expect(this.rosterview.$el.find('dt#pending-xmpp-contacts').css('display')).toEqual('none');
+            }
+
+            it("can be used to organize existing contacts", $.proxy(function () {
+                _clearContacts();
+                var i=0, j=0, t;
+                spyOn(converse, 'emit');
+                spyOn(this.rosterview, 'update').andCallThrough();
+                converse.rosterview.render();
+
+                utils.createContacts('pending');
+                utils.createContacts('requesting');
+                var groups = {
+                    'colleagues': 3,
+                    'friends & acquaintences': 3,
+                    'Family': 4,
+                    'ænemies': 3,
+                    'Ungrouped': 2
+                };
+                _.each(_.keys(groups), $.proxy(function (name) {
+                    j = i;
+                    for (i=j; i<j+groups[name]; i++) {
+                        this.roster.create({
+                            jid: mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@localhost',
+                            subscription: 'both',
+                            ask: null,
+                            groups: name === 'ungrouped'? [] : [name],
+                            fullname: mock.cur_names[i]
+                        });
+                    }
+                }, converse));
+                // Check that the groups appear alphabetically and that
+                // requesting and pending contacts are last.
+                var group_titles = $.map(this.rosterview.$el.find('dt'), function (o) { return $(o).text().trim(); });
+                expect(group_titles).toEqual([
+                    "colleagues",
+                    "Family",
+                    "friends & acquaintences",
+                    "ænemies",
+                    "Ungrouped",
+                    "Contact requests",
+                    "Pending contacts"
+                ]);
+                // Check that usernames appear alphabetically per group
+                _.each(_.keys(groups), $.proxy(function (name) {
+                    var $contacts = this.rosterview.$('dt.roster-group[data-group="'+name+'"]').nextUntil('dt', 'dd');
+                    var names = $.map($contacts, function (o) { return $(o).text().trim(); });
+                    expect(names).toEqual(_.clone(names).sort());
+                }, converse));
             }, converse));
 
-            it("will have their own heading once they have been added", $.proxy(function () {
-                expect(this.rosterview.$el.find('dt#pending-xmpp-contacts').css('display')).toEqual('block');
+            it("can share contacts with other roster groups", $.proxy(function () {
+                _clearContacts();
+                var i=0, j=0, t;
+                spyOn(converse, 'emit');
+                spyOn(this.rosterview, 'update').andCallThrough();
+                converse.rosterview.render();
+                var groups = ['colleagues', 'friends'];
+                for (i=0; i<mock.cur_names.length; i++) {
+                    this.roster.create({
+                        jid: mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@localhost',
+                        subscription: 'both',
+                        ask: null,
+                        groups: groups,
+                        fullname: mock.cur_names[i]
+                    });
+                }
+                // Check that usernames appear alphabetically per group
+                _.each(groups, $.proxy(function (name) {
+                    var $contacts = this.rosterview.$('dt.roster-group[data-group="'+name+'"]').nextUntil('dt', 'dd');
+                    var names = $.map($contacts, function (o) { return $(o).text().trim(); });
+                    expect(names).toEqual(_.clone(names).sort());
+                    expect(names.length).toEqual(mock.cur_names.length);
+                }, converse));
+            }, converse));
+
+            it("remembers whether it is closed or opened", $.proxy(function () {
+                var i=0, j=0, t;
+                var groups = {
+                    'colleagues': 3,
+                    'friends & acquaintences': 3,
+                    'Ungrouped': 2
+                };
+                _.each(_.keys(groups), $.proxy(function (name) {
+                    j = i;
+                    for (i=j; i<j+groups[name]; i++) {
+                        this.roster.create({
+                            jid: mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@localhost',
+                            subscription: 'both',
+                            ask: null,
+                            groups: name === 'ungrouped'? [] : [name],
+                            fullname: mock.cur_names[i]
+                        });
+                    }
+                }, converse));
+                var view = this.rosterview.get('colleagues');
+                var $toggle = view.$el.find('a.group-toggle');
+                expect(view.model.get('state')).toBe('opened');
+                $toggle.click();
+                expect(view.model.get('state')).toBe('closed');
+                $toggle.click();
+                expect(view.model.get('state')).toBe('opened');
+            }, converse));
+        }, converse));
+
+        describe("Pending Contacts", $.proxy(function () {
+            function _clearContacts () {
+                utils.clearBrowserStorage();
+                converse.rosterview.model.reset();
+            }
+
+            function _addContacts () {
+                _clearContacts();
+                // Must be initialized, so that render is called and documentFragment set up.
+                utils.createContacts('pending').openControlBox().openContactsPanel();
+            }
+
+            it("can be collapsed under their own header", $.proxy(function () {
+                _addContacts();
+                checkHeaderToggling.apply(this, [this.rosterview.get('Pending contacts').$el]);
             }, converse));
 
             it("can be added to the roster", $.proxy(function () {
-                converse.rosterview.model.reset(); // We want to manually create users so that we can spy
+                _clearContacts();
                 spyOn(converse, 'emit');
-                spyOn(this.rosterview, 'render').andCallThrough();
+                spyOn(this.rosterview, 'update').andCallThrough();
                 runs($.proxy(function () {
                     this.roster.create({
                         jid: mock.pend_names[0].replace(/ /g,'.').toLowerCase() + '@localhost',
                         subscription: 'none',
                         ask: 'subscribe',
-                        fullname: mock.pend_names[0],
-                        is_last: true
+                        fullname: mock.pend_names[0]
                     });
                 }, converse));
                 waits(300);
                 runs($.proxy(function () {
-                    expect(converse.emit).toHaveBeenCalledWith('rosterViewUpdated');
                     expect(this.rosterview.$el.is(':visible')).toEqual(true);
-                    expect(this.rosterview.render).toHaveBeenCalled();
+                    expect(this.rosterview.update).toHaveBeenCalled();
                 }, converse));
             }, converse));
 
             it("can be removed by the user", $.proxy(function () {
-                var jid = mock.pend_names[0].replace(/ /g,'.').toLowerCase() + '@localhost';
-                var view = this.rosterview.get(jid);
+                _addContacts();
+                var name = mock.pend_names[0];
+                var jid = name.replace(/ /g,'.').toLowerCase() + '@localhost';
                 spyOn(window, 'confirm').andReturn(true);
                 spyOn(converse, 'emit');
                 spyOn(this.connection.roster, 'remove').andCallThrough();
                 spyOn(this.connection.roster, 'unauthorize');
                 spyOn(this.rosterview.model, 'remove').andCallThrough();
 
-                view.$el.find('.remove-xmpp-contact').click();
+                converse.rosterview.$el.find(".pending-contact-name:contains('"+name+"')")
+                    .siblings('.remove-xmpp-contact').click();
+
                 expect(window.confirm).toHaveBeenCalled();
                 expect(this.connection.roster.remove).toHaveBeenCalled();
                 expect(this.connection.roster.unauthorize).toHaveBeenCalled();
                 expect(this.rosterview.model.remove).toHaveBeenCalled();
-                // The element must now be detached from the DOM.
-                expect(view.$el.closest('html').length).toBeFalsy();
-                expect(converse.emit).toHaveBeenCalledWith('rosterViewUpdated');
+                expect(converse.rosterview.$el.find(".pending-contact-name:contains('"+name+"')").length).toEqual(0);
             }, converse));
 
-            it("will lose their own heading once the last one has been removed", $.proxy(function () {
-                var view;
+            it("do not have a header if there aren't any", $.proxy(function () {
+                var name = mock.pend_names[0];
+                _clearContacts();
+                spyOn(window, 'confirm').andReturn(true);
+                this.roster.create({
+                    jid: name.replace(/ /g,'.').toLowerCase() + '@localhost',
+                    subscription: 'none',
+                    ask: 'subscribe',
+                    fullname: name
+                });
+                expect(this.rosterview.get('Pending contacts').$el.is(':visible')).toEqual(true);
+                converse.rosterview.$el.find(".pending-contact-name:contains('"+name+"')")
+                    .siblings('.remove-xmpp-contact').click();
+                expect(window.confirm).toHaveBeenCalled();
+                expect(this.rosterview.get('Pending contacts').$el.is(':visible')).toEqual(false);
+            }, converse));
+
+
+            it("will lose their own header once the last one has been removed", $.proxy(function () {
+                _addContacts();
+                var name;
                 spyOn(window, 'confirm').andReturn(true);
                 for (i=0; i<mock.pend_names.length; i++) {
-                    view = this.rosterview.get(mock.pend_names[i].replace(/ /g,'.').toLowerCase() + '@localhost');
-                    view.$el.find('.remove-xmpp-contact').click();
+                    name = mock.pend_names[i];
+                    converse.rosterview.$el.find(".pending-contact-name:contains('"+name+"')")
+                        .siblings('.remove-xmpp-contact').click();
                 }
                 expect(this.rosterview.$el.find('dt#pending-xmpp-contacts').is(':visible')).toBeFalsy();
             }, converse));
 
             it("can be added to the roster and they will be sorted alphabetically", $.proxy(function () {
-                converse.rosterview.model.reset(); // We want to manually create users so that we can spy
-                var i, t, is_last;
+                _clearContacts();
+                var i, t;
                 spyOn(converse, 'emit');
-                spyOn(this.rosterview, 'render').andCallThrough();
+                spyOn(this.rosterview, 'update').andCallThrough();
                 for (i=0; i<mock.pend_names.length; i++) {
-                    is_last = i===(mock.pend_names.length-1);
                     this.roster.create({
                         jid: mock.pend_names[i].replace(/ /g,'.').toLowerCase() + '@localhost',
                         subscription: 'none',
                         ask: 'subscribe',
-                        fullname: mock.pend_names[i],
-                        is_last: is_last
+                        fullname: mock.pend_names[i]
                     });
-                    expect(this.rosterview.render).toHaveBeenCalled();
-                    expect(converse.emit).toHaveBeenCalledWith('rosterViewUpdated');
-                    // Check that they are sorted alphabetically
-                    t = this.rosterview.$el.find('dt#pending-xmpp-contacts').siblings('dd.pending-xmpp-contact').find('span').text();
-                    expect(t).toEqual(mock.pend_names.slice(0,i+1).sort().join(''));
+                    expect(this.rosterview.update).toHaveBeenCalled();
                 }
+                // Check that they are sorted alphabetically
+                t = this.rosterview.get('Pending contacts').$el.siblings('dd.pending-xmpp-contact').find('span').text();
+                expect(t).toEqual(mock.pend_names.slice(0,i+1).sort().join(''));
             }, converse));
 
         }, converse));
 
         describe("Existing Contacts", $.proxy(function () {
-            beforeEach($.proxy(function () {
-                runs(function () {
-                    converse.rosterview.model.reset();
-                    utils.createContacts().openControlBox();
-                });
-                waits(50);
-                runs(function () {
-                    utils.openContactsPanel();
-                });
-            }, converse));
-
-            it("do not have a heading if there aren't any", $.proxy(function () {
+            function _clearContacts () {
+                utils.clearBrowserStorage();
                 converse.rosterview.model.reset();
-                expect(this.rosterview.$el.find('dt#xmpp-contacts').css('display')).toEqual('none');
+            }
+
+            var _addContacts = function () {
+                _clearContacts();
+                utils.createContacts().openControlBox().openContactsPanel();
+            };
+
+            it("can be collapsed under their own header", $.proxy(function () {
+                _addContacts();
+                checkHeaderToggling.apply(this, [this.rosterview.$el.find('dt.roster-group')]);
+            }, converse));
+
+            it("will be hidden when appearing under a collapsed group", $.proxy(function () {
+                _addContacts();
+                this.rosterview.$el.find('dt.roster-group').find('a.group-toggle').click();
+                var name = "Max Mustermann";
+                var jid = name.replace(/ /g,'.').toLowerCase() + '@localhost';
+                converse.roster.create({
+                    ask: null,
+                    fullname: name,
+                    jid: jid,
+                    requesting: false,
+                    subscription: 'both'
+                });
+                var view = this.rosterview.get('My contacts').get(jid);
+                expect(view.$el.is(':visible')).toBe(false);
             }, converse));
 
             it("can be added to the roster and they will be sorted alphabetically", $.proxy(function () {
+                _clearContacts();
                 var i, t;
-                converse.rosterview.model.reset();
                 spyOn(converse, 'emit');
-                spyOn(this.rosterview, 'render').andCallThrough();
+                spyOn(this.rosterview, 'update').andCallThrough();
                 for (i=0; i<mock.cur_names.length; i++) {
                     this.roster.create({
                         jid: mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@localhost',
                         subscription: 'both',
                         ask: null,
-                        fullname: mock.cur_names[i],
-                        is_last: i===(mock.cur_names.length-1)
+                        fullname: mock.cur_names[i]
                     });
-                    expect(this.rosterview.render).toHaveBeenCalled();
-                    expect(converse.emit).toHaveBeenCalledWith('rosterViewUpdated');
-                    // Check that they are sorted alphabetically
-                    t = this.rosterview.$el.find('dt#xmpp-contacts').siblings('dd.current-xmpp-contact.offline').find('a.open-chat').text();
-                    expect(t).toEqual(mock.cur_names.slice(0,i+1).sort().join(''));
+                    expect(this.rosterview.update).toHaveBeenCalled();
                 }
+                // Check that they are sorted alphabetically
+                t = this.rosterview.$el.find('dt.roster-group').siblings('dd.current-xmpp-contact.offline').find('a.open-chat').text();
+                expect(t).toEqual(mock.cur_names.slice(0,i+1).sort().join(''));
+            }, converse));
+
+            it("can be removed by the user", $.proxy(function () {
+                _addContacts();
+                var name = mock.cur_names[0];
+                var jid = name.replace(/ /g,'.').toLowerCase() + '@localhost';
+                spyOn(window, 'confirm').andReturn(true);
+                spyOn(converse, 'emit');
+                spyOn(this.connection.roster, 'remove').andCallThrough();
+                spyOn(this.connection.roster, 'unauthorize');
+                spyOn(this.rosterview.model, 'remove').andCallThrough();
+
+                converse.rosterview.$el.find(".open-chat:contains('"+name+"')")
+                    .siblings('.remove-xmpp-contact').click();
+
+                expect(window.confirm).toHaveBeenCalled();
+                expect(this.connection.roster.remove).toHaveBeenCalled();
+                expect(this.connection.roster.unauthorize).toHaveBeenCalled();
+                expect(this.rosterview.model.remove).toHaveBeenCalled();
+                expect(converse.rosterview.$el.find(".open-chat:contains('"+name+"')").length).toEqual(0);
             }, converse));
 
-            it("will have their own heading once they have been added", $.proxy(function () {
-                expect(this.rosterview.$el.find('dt#xmpp-contacts').css('display')).toEqual('block');
+
+            it("do not have a header if there aren't any", $.proxy(function () {
+                var name = mock.cur_names[0];
+                _clearContacts();
+                spyOn(window, 'confirm').andReturn(true);
+                this.roster.create({
+                    jid: name.replace(/ /g,'.').toLowerCase() + '@localhost',
+                    subscription: 'both',
+                    ask: null,
+                    fullname: name
+                });
+                expect(this.rosterview.$el.find('dt.roster-group').css('display')).toEqual('block');
+                converse.rosterview.$el.find(".open-chat:contains('"+name+"')")
+                    .siblings('.remove-xmpp-contact').click();
+                expect(window.confirm).toHaveBeenCalled();
+                expect(this.rosterview.$el.find('dt.roster-group').css('display')).toEqual('none');
             }, converse));
 
             it("can change their status to online and be sorted alphabetically", $.proxy(function () {
-                var item, view, jid, t;
+                _addContacts();
+                var jid, t;
                 spyOn(converse, 'emit');
-                spyOn(this.rosterview, 'render').andCallThrough();
+                spyOn(this.rosterview, 'update').andCallThrough();
                 for (i=0; i<mock.cur_names.length; i++) {
                     jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@localhost';
-                    view = this.rosterview.get(jid);
-                    spyOn(view, 'render').andCallThrough();
-                    item = view.model;
-                    item.set('chat_status', 'online');
-                    expect(view.render).toHaveBeenCalled();
-                    expect(this.rosterview.render).toHaveBeenCalled();
-                    expect(converse.emit).toHaveBeenCalledWith('rosterViewUpdated');
+                    this.roster.get(jid).set('chat_status', 'online');
+                    expect(this.rosterview.update).toHaveBeenCalled();
                     // Check that they are sorted alphabetically
-                    t = this.rosterview.$el.find('dt#xmpp-contacts').siblings('dd.current-xmpp-contact.online').find('a.open-chat').text();
+                    t = this.rosterview.$el.find('dt.roster-group').siblings('dd.current-xmpp-contact.online').find('a.open-chat').text();
                     expect(t).toEqual(mock.cur_names.slice(0,i+1).sort().join(''));
                 }
             }, converse));
 
             it("can change their status to busy and be sorted alphabetically", $.proxy(function () {
-                var item, view, jid, t;
+                _addContacts();
+                var jid, t;
                 spyOn(converse, 'emit');
-                spyOn(this.rosterview, 'render').andCallThrough();
+                spyOn(this.rosterview, 'update').andCallThrough();
                 for (i=0; i<mock.cur_names.length; i++) {
                     jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@localhost';
-                    view = this.rosterview.get(jid);
-                    spyOn(view, 'render').andCallThrough();
-                    item = view.model;
-                    item.set('chat_status', 'dnd');
-                    expect(view.render).toHaveBeenCalled();
-                    expect(this.rosterview.render).toHaveBeenCalled();
-                    expect(converse.emit).toHaveBeenCalledWith('rosterViewUpdated');
+                    this.roster.get(jid).set('chat_status', 'dnd');
+                    expect(this.rosterview.update).toHaveBeenCalled();
                     // Check that they are sorted alphabetically
-                    t = this.rosterview.$el.find('dt#xmpp-contacts').siblings('dd.current-xmpp-contact.dnd').find('a.open-chat').text();
+                    t = this.rosterview.$el.find('dt.roster-group').siblings('dd.current-xmpp-contact.dnd').find('a.open-chat').text();
                     expect(t).toEqual(mock.cur_names.slice(0,i+1).sort().join(''));
                 }
             }, converse));
 
             it("can change their status to away and be sorted alphabetically", $.proxy(function () {
-                var item, view, jid, t;
+                _addContacts();
+                var jid, t;
                 spyOn(converse, 'emit');
-                spyOn(this.rosterview, 'render').andCallThrough();
+                spyOn(this.rosterview, 'update').andCallThrough();
                 for (i=0; i<mock.cur_names.length; i++) {
                     jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@localhost';
-                    view = this.rosterview.get(jid);
-                    spyOn(view, 'render').andCallThrough();
-                    item = view.model;
-                    item.set('chat_status', 'away');
-                    expect(view.render).toHaveBeenCalled();
-                    expect(this.rosterview.render).toHaveBeenCalled();
-                    expect(converse.emit).toHaveBeenCalledWith('rosterViewUpdated');
+                    this.roster.get(jid).set('chat_status', 'away');
+                    expect(this.rosterview.update).toHaveBeenCalled();
                     // Check that they are sorted alphabetically
-                    t = this.rosterview.$el.find('dt#xmpp-contacts').siblings('dd.current-xmpp-contact.away').find('a.open-chat').text();
+                    t = this.rosterview.$el.find('dt.roster-group').siblings('dd.current-xmpp-contact.away').find('a.open-chat').text();
                     expect(t).toEqual(mock.cur_names.slice(0,i+1).sort().join(''));
                 }
             }, converse));
 
             it("can change their status to xa and be sorted alphabetically", $.proxy(function () {
-                var item, view, jid, t;
+                _addContacts();
+                var jid, t;
                 spyOn(converse, 'emit');
-                spyOn(this.rosterview, 'render').andCallThrough();
+                spyOn(this.rosterview, 'update').andCallThrough();
                 for (i=0; i<mock.cur_names.length; i++) {
                     jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@localhost';
-                    view = this.rosterview.get(jid);
-                    spyOn(view, 'render').andCallThrough();
-                    item = view.model;
-                    item.set('chat_status', 'xa');
-                    expect(view.render).toHaveBeenCalled();
-                    expect(this.rosterview.render).toHaveBeenCalled();
-                    expect(converse.emit).toHaveBeenCalledWith('rosterViewUpdated');
+                    this.roster.get(jid).set('chat_status', 'xa');
+                    expect(this.rosterview.update).toHaveBeenCalled();
                     // Check that they are sorted alphabetically
-                    t = this.rosterview.$el.find('dt#xmpp-contacts').siblings('dd.current-xmpp-contact.xa').find('a.open-chat').text();
+                    t = this.rosterview.$el.find('dt.roster-group').siblings('dd.current-xmpp-contact.xa').find('a.open-chat').text();
                     expect(t).toEqual(mock.cur_names.slice(0,i+1).sort().join(''));
                 }
             }, converse));
 
             it("can change their status to unavailable and be sorted alphabetically", $.proxy(function () {
-                var item, view, jid, t;
+                _addContacts();
+                var jid, t;
                 spyOn(converse, 'emit');
-                spyOn(this.rosterview, 'render').andCallThrough();
+                spyOn(this.rosterview, 'update').andCallThrough();
                 for (i=0; i<mock.cur_names.length; i++) {
                     jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@localhost';
-                    view = this.rosterview.get(jid);
-                    spyOn(view, 'render').andCallThrough();
-                    item = view.model;
-                    item.set('chat_status', 'unavailable');
-                    expect(view.render).toHaveBeenCalled();
-                    expect(this.rosterview.render).toHaveBeenCalled();
-                    expect(converse.emit).toHaveBeenCalledWith('rosterViewUpdated');
+                    this.roster.get(jid).set('chat_status', 'unavailable');
+                    expect(this.rosterview.update).toHaveBeenCalled();
                     // Check that they are sorted alphabetically
-                    t = this.rosterview.$el.find('dt#xmpp-contacts').siblings('dd.current-xmpp-contact.unavailable').find('a.open-chat').text();
+                    t = this.rosterview.$el.find('dt.roster-group').siblings('dd.current-xmpp-contact.unavailable').find('a.open-chat').text();
                     expect(t).toEqual(mock.cur_names.slice(0, i+1).sort().join(''));
                 }
             }, converse));
 
             it("are ordered according to status: online, busy, away, xa, unavailable, offline", $.proxy(function () {
+                _addContacts();
                 var i;
                 for (i=0; i<3; i++) {
                     jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@localhost';
-                    view = this.rosterview.get(jid);
-                    view.model.set('chat_status', 'online');
+                    this.roster.get(jid).set('chat_status', 'online');
                 }
                 for (i=3; i<6; i++) {
                     jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@localhost';
-                    view = this.rosterview.get(jid);
-                    view.model.set('chat_status', 'dnd');
+                    this.roster.get(jid).set('chat_status', 'dnd');
                 }
                 for (i=6; i<9; i++) {
                     jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@localhost';
-                    view = this.rosterview.get(jid);
-                    view.model.set('chat_status', 'away');
+                    this.roster.get(jid).set('chat_status', 'away');
                 }
                 for (i=9; i<12; i++) {
                     jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@localhost';
-                    view = this.rosterview.get(jid);
-                    view.model.set('chat_status', 'xa');
+                    this.roster.get(jid).set('chat_status', 'xa');
                 }
                 for (i=12; i<15; i++) {
                     jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@localhost';
-                    view = this.rosterview.get(jid);
-                    view.model.set('chat_status', 'unavailable');
+                    this.roster.get(jid).set('chat_status', 'unavailable');
                 }
 
                 var contacts = this.rosterview.$el.find('dd.current-xmpp-contact');
@@ -392,6 +561,7 @@
         describe("Requesting Contacts", $.proxy(function () {
             beforeEach($.proxy(function () {
                 runs(function () {
+                    utils.clearBrowserStorage();
                     converse.rosterview.model.reset();
                     utils.createContacts('requesting').openControlBox();
                 });
@@ -401,81 +571,89 @@
                 });
             }, converse));
 
-            it("do not have a heading if there aren't any", $.proxy(function () {
-                // by default the dts are hidden from css class and only later they will be hidden
-                // by jQuery therefore for the first check we will see if visible instead of none
-                converse.rosterview.model.reset();
-                expect(this.rosterview.$el.find('dt#xmpp-contact-requests').is(':visible')).toEqual(false);
-            }, converse));
-
             it("can be added to the roster and they will be sorted alphabetically", $.proxy(function () {
                 converse.rosterview.model.reset(); // We want to manually create users so that we can spy
                 var i, children;
                 var names = [];
                 spyOn(converse, 'emit');
-                spyOn(this.rosterview, 'render').andCallThrough();
+                spyOn(this.rosterview, 'update').andCallThrough();
                 spyOn(this.controlboxtoggle, 'showControlBox').andCallThrough();
+                var addName = function (idx, item) {
+                    if (!$(item).hasClass('request-actions')) {
+                        names.push($(item).text().replace(/^\s+|\s+$/g, ''));
+                    }
+                };
                 for (i=0; i<mock.req_names.length; i++) {
                     this.roster.create({
                         jid: mock.req_names[i].replace(/ /g,'.').toLowerCase() + '@localhost',
                         subscription: 'none',
                         ask: null,
                         requesting: true,
-                        fullname: mock.req_names[i],
-                        is_last: i===(mock.req_names.length-1)
+                        fullname: mock.req_names[i]
                     });
-                    expect(this.rosterview.render).toHaveBeenCalled();
-                    // Check that they are sorted alphabetically
-                    children = this.rosterview.$el.find('dt#xmpp-contact-requests').siblings('dd.requesting-xmpp-contact').children('span');
-                    names = [];
-                    children.each(function (idx, item) {
-                        if (!$(item).hasClass('request-actions')) {
-                            names.push($(item).text().replace(/^\s+|\s+$/g, ''));
-                        }
-                    });
-                    expect(names.join('')).toEqual(mock.req_names.slice(0,i+1).sort().join(''));
+                    expect(this.rosterview.update).toHaveBeenCalled();
                     // When a requesting contact is added, the controlbox must
                     // be opened.
                     expect(this.controlboxtoggle.showControlBox).toHaveBeenCalled();
-                    expect(converse.emit).toHaveBeenCalledWith('rosterViewUpdated');
                 }
+                // Check that they are sorted alphabetically
+                children = this.rosterview.get('Contact requests').$el.siblings('dd.requesting-xmpp-contact').children('span');
+                names = [];
+                children.each(addName);
+                expect(names.join('')).toEqual(mock.req_names.slice(0,i+1).sort().join(''));
+            }, converse));
+
+            it("do not have a header if there aren't any", $.proxy(function () {
+                converse.rosterview.model.reset(); // We want to manually create users so that we can spy
+                var name = mock.req_names[0];
+                spyOn(window, 'confirm').andReturn(true);
+                this.roster.create({
+                    jid: name.replace(/ /g,'.').toLowerCase() + '@localhost',
+                    subscription: 'none',
+                    ask: null,
+                    requesting: true,
+                    fullname: name
+                });
+                expect(this.rosterview.get('Contact requests').$el.is(':visible')).toEqual(true);
+                converse.rosterview.$el.find(".req-contact-name:contains('"+name+"')")
+                    .siblings('.request-actions')
+                    .find('.decline-xmpp-request').click();
+                expect(window.confirm).toHaveBeenCalled();
+                expect(this.rosterview.get('Contact requests').$el.is(':visible')).toEqual(false);
             }, converse));
 
-            it("will have their own heading once they have been added", $.proxy(function () {
-                expect(this.rosterview.$el.find('dt#xmpp-contact-requests').css('display')).toEqual('block');
+            it("can be collapsed under their own header", $.proxy(function () {
+                checkHeaderToggling.apply(this, [this.rosterview.get('Contact requests').$el]);
             }, converse));
 
             it("can have their requests accepted by the user", $.proxy(function () {
                 // TODO: Testing can be more thorough here, the user is
                 // actually not accepted/authorized because of
                 // mock_connection.
-                var jid = mock.req_names.sort()[0].replace(/ /g,'.').toLowerCase() + '@localhost';
-                var view = this.rosterview.get(jid);
+                var name = mock.req_names.sort()[0];
+                var jid =  name.replace(/ /g,'.').toLowerCase() + '@localhost';
                 spyOn(this.connection.roster, 'authorize');
-                spyOn(view, 'acceptRequest').andCallThrough();
-                view.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
-                var accept_button = view.$el.find('.accept-xmpp-request');
-                accept_button.click();
-                expect(view.acceptRequest).toHaveBeenCalled();
+
+                converse.rosterview.$el.find(".req-contact-name:contains('"+name+"')")
+                    .siblings('.request-actions')
+                    .find('.accept-xmpp-request').click();
+
                 expect(this.connection.roster.authorize).toHaveBeenCalled();
             }, converse));
 
             it("can have their requests denied by the user", $.proxy(function () {
-                var jid = mock.req_names.sort()[1].replace(/ /g,'.').toLowerCase() + '@localhost';
-                var view = this.rosterview.get(jid);
+                this.rosterview.model.reset();
                 spyOn(converse, 'emit');
                 spyOn(this.connection.roster, 'unauthorize');
-                spyOn(this.rosterview, 'removeRosterItemView').andCallThrough();
                 spyOn(window, 'confirm').andReturn(true);
-                spyOn(view, 'declineRequest').andCallThrough();
-                view.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
-                var accept_button = view.$el.find('.decline-xmpp-request');
-                accept_button.click();
-                expect(view.declineRequest).toHaveBeenCalled();
+                utils.createContacts('requesting').openControlBox();
+                var name = mock.req_names.sort()[1];
+                var jid = name.replace(/ /g,'.').toLowerCase() + '@localhost';
+                converse.rosterview.$el.find(".req-contact-name:contains('"+name+"')")
+                    .siblings('.request-actions')
+                    .find('.decline-xmpp-request').click();
                 expect(window.confirm).toHaveBeenCalled();
-                expect(this.rosterview.removeRosterItemView).toHaveBeenCalled();
                 expect(this.connection.roster.unauthorize).toHaveBeenCalled();
-                expect(converse.emit).toHaveBeenCalledWith('rosterViewUpdated');
                 // There should now be one less contact
                 expect(this.roster.length).toEqual(mock.req_names.length-1);
             }, converse));
@@ -483,28 +661,19 @@
 
         describe("All Contacts", $.proxy(function () {
             beforeEach($.proxy(function () {
-                runs(function () {
-                    utils.clearBrowserStorage();
-                    converse.rosterview.model.reset();
-                    converse.rosterview.model.browserStorage._clear();
-                    utils.createContacts('all').openControlBox();
-                });
-                waits(50);
-                runs(function () {
-                    utils.openContactsPanel();
-                });
+                utils.clearBrowserStorage();
+                converse.rosterview.model.reset();
+                utils.createContacts('all').openControlBox();
+                utils.openContactsPanel();
             }, converse));
 
             it("are saved to, and can be retrieved from, browserStorage", $.proxy(function () {
                 var new_attrs, old_attrs, attrs, old_roster;
                 var num_contacts = this.roster.length;
-                new_roster = new this.RosterItems();
+                new_roster = new this.RosterContacts();
                 // Roster items are yet to be fetched from browserStorage
                 expect(new_roster.length).toEqual(0);
-
-                new_roster.browserStorage = new Backbone.BrowserStorage.session(
-                    b64_sha1('converse.rosteritems-dummy@localhost'));
-
+                new_roster.browserStorage = this.roster.browserStorage;
                 new_roster.fetch();
                 expect(new_roster.length).toEqual(num_contacts);
                 // Check that the roster items retrieved from browserStorage
@@ -518,7 +687,6 @@
                     // comparison
                     expect(_.isEqual(new_attrs.sort(), old_attrs.sort())).toEqual(true);
                 }
-                this.rosterview.render();
             }, converse));
 
             afterEach($.proxy(function () {
@@ -528,8 +696,7 @@
                 // we make some online now
                 for (i=0; i<5; i++) {
                     jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@localhost';
-                    view = this.rosterview.get(jid);
-                    view.model.set('chat_status', 'online');
+                    this.roster.get(jid).set('chat_status', 'online');
                 }
             }, converse));
         }, converse));

+ 15 - 15
src/templates.js

@@ -10,7 +10,6 @@ define("converse-templates", [
     "tpl!src/templates/chatrooms_tab",
     "tpl!src/templates/chats_panel",
     "tpl!src/templates/choose_status",
-    "tpl!src/templates/contacts",
     "tpl!src/templates/contacts_panel",
     "tpl!src/templates/contacts_tab",
     "tpl!src/templates/controlbox",
@@ -19,6 +18,7 @@ define("converse-templates", [
     "tpl!src/templates/form_checkbox",
     "tpl!src/templates/form_input",
     "tpl!src/templates/form_select",
+    "tpl!src/templates/group_header",
     "tpl!src/templates/info",
     "tpl!src/templates/login_panel",
     "tpl!src/templates/login_tab",
@@ -35,9 +35,9 @@ define("converse-templates", [
     "tpl!src/templates/roster_item",
     "tpl!src/templates/select_option",
     "tpl!src/templates/status_option",
+    "tpl!src/templates/toggle_chats",
     "tpl!src/templates/toolbar",
-    "tpl!src/templates/trimmed_chat",
-    "tpl!src/templates/toggle_chats"
+    "tpl!src/templates/trimmed_chat"
 ], function () {
     return {
         action:                 arguments[0],
@@ -51,15 +51,15 @@ define("converse-templates", [
         chatrooms_tab:          arguments[8],
         chats_panel:            arguments[9],
         choose_status:          arguments[10],
-        contacts:               arguments[11],
-        contacts_panel:         arguments[12],
-        contacts_tab:           arguments[13],
-        controlbox:             arguments[14],
-        controlbox_toggle:      arguments[15],
-        field:                  arguments[16],
-        form_checkbox:          arguments[17],
-        form_input:             arguments[18],
-        form_select:            arguments[19],
+        contacts_panel:         arguments[11],
+        contacts_tab:           arguments[12],
+        controlbox:             arguments[13],
+        controlbox_toggle:      arguments[14],
+        field:                  arguments[15],
+        form_checkbox:          arguments[16],
+        form_input:             arguments[17],
+        form_select:            arguments[18],
+        group_header:           arguments[19],
         info:                   arguments[20],
         login_panel:            arguments[21],
         login_tab:              arguments[22],
@@ -76,8 +76,8 @@ define("converse-templates", [
         roster_item:            arguments[33],
         select_option:          arguments[34],
         status_option:          arguments[35],
-        toolbar:                arguments[36],
-        trimmed_chat:           arguments[37],
-        toggle_chats:           arguments[38]
+        toggle_chats:           arguments[36],
+        toolbar:                arguments[37],
+        trimmed_chat:           arguments[38]
     };
 });

+ 0 - 1
src/templates/contacts.html

@@ -1 +0,0 @@
-<dt id="xmpp-contacts">{{label_contacts}}</dt>

+ 1 - 0
src/templates/group_header.html

@@ -0,0 +1 @@
+<a href="#" class="group-toggle icon-{{toggle_state}}" title="{{desc_group_toggle}}">{{label_group}}</a>

+ 1 - 1
src/templates/pending_contact.html

@@ -1 +1 @@
-<span>{{fullname}}</span> <a class="remove-xmpp-contact icon-remove" title="{{desc_remove}}" href="#"></a>
+<span class="pending-contact-name">{{fullname}}</span> <a class="remove-xmpp-contact icon-remove" title="{{desc_remove}}" href="#"></a>

+ 1 - 1
src/templates/pending_contacts.html

@@ -1 +1 @@
-<dt id="pending-xmpp-contacts">{{label_pending_contacts}}</dt>
+<dt id="pending-xmpp-contacts"><a href="#" class="group-toggle icon-{{toggle_state}}" title="{{desc_group_toggle}}">{{label_pending_contacts}}</a></dt>

+ 1 - 1
src/templates/requesting_contact.html

@@ -1,4 +1,4 @@
-<span>{{fullname}}</span>
+<span class="req-contact-name">{{fullname}}</span>
 <span class="request-actions">
     <a class="accept-xmpp-request icon-checkmark" title="{{desc_accept}}" href="#"></a>
     <a class="decline-xmpp-request icon-close" title="{{desc_decline}}" href="#"></a>

+ 1 - 1
src/templates/requesting_contacts.html

@@ -1 +1 @@
-<dt id="xmpp-contact-requests">{{label_contact_requests}}</dt>
+<dt id="xmpp-contact-requests"><a href="#" class="group-toggle icon-{{toggle_state}}" title="{{desc_group_toggle}}">{{label_contact_requests}}</a></dt>

+ 10 - 3
tests/utils.js

@@ -80,13 +80,21 @@
         var i = 0, jid, views = [];
         for (i; i<amount; i++) {
             jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@localhost';
-            views[i] = converse.rosterview.get(jid).openChat(mock.event);
+            views[i] = converse.roster.get(jid).trigger("open");
         }
         return views;
     };
 
     utils.openChatBoxFor = function (jid) {
-        return converse.rosterview.get(jid).openChat(mock.event);
+        return converse.roster.get(jid).trigger("open");
+    };
+
+    utils.removeRosterContacts = function () {
+        var model;
+        while (converse.rosterview.model.length) {
+            model = converse.rosterview.model.pop();
+            converse.rosterview.model.remove(model);
+        }
     };
 
     utils.clearBrowserStorage = function () {
@@ -129,7 +137,6 @@
             converse.roster.create({
                 ask: ask,
                 fullname: names[i],
-                is_last: i===(names.length-1),
                 jid: names[i].replace(/ /g,'.').toLowerCase() + '@localhost',
                 requesting: requesting,
                 subscription: subscription

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است