// Converse.js
// http://conversejs.org
//
// Copyright (c) 2012-2018, the Converse.js developers
// Licensed under the Mozilla Public License (MPLv2)

(function (root, factory) {
    define(["converse-core",
            "formdata-polyfill",
            "templates/add_contact_modal.html",
            "templates/group_header.html",
            "templates/pending_contact.html",
            "templates/requesting_contact.html",
            "templates/roster.html",
            "templates/roster_filter.html",
            "templates/roster_item.html",
            "templates/search_contact.html",
            "awesomplete",
            "converse-chatboxes",
            "converse-modal"
    ], factory);
}(this, function (
            converse, 
            _FormData,
            tpl_add_contact_modal,
            tpl_group_header,
            tpl_pending_contact,
            tpl_requesting_contact,
            tpl_roster,
            tpl_roster_filter,
            tpl_roster_item,
            tpl_search_contact,
            Awesomplete
    ) {
    "use strict";
    const { Backbone, Strophe, $iq, b64_sha1, sizzle, _ } = converse.env;
    const u = converse.env.utils;


    converse.plugins.add('converse-rosterview', {

        dependencies: ["converse-roster", "converse-modal"],

        overrides: {
            // Overrides mentioned here will be picked up by converse.js's
            // plugin architecture they will replace existing methods on the
            // relevant objects or classes.
            //
            // New functions which don't exist yet can also be added.
            afterReconnected () {
                this.__super__.afterReconnected.apply(this, arguments);
            },

            tearDown () {
                /* Remove the rosterview when tearing down. It gets created
                 * anew when reconnecting or logging in.
                 */
                this.__super__.tearDown.apply(this, arguments);
                if (!_.isUndefined(this.rosterview)) {
                    this.rosterview.remove();
                }
            },

            RosterGroups: {
                comparator () {
                    // RosterGroupsComparator only gets set later (once i18n is
                    // set up), so we need to wrap it in this nameless function.
                    const { _converse } = this.__super__;
                    return _converse.RosterGroupsComparator.apply(this, arguments);
                }
            }
        },


        initialize () {
            /* The initialize function gets called as soon as the plugin is
             * loaded by converse.js's plugin machinery.
             */
            const { _converse } = this,
                  { __ } = _converse;

            _converse.api.settings.update({
                'allow_chat_pending_contacts': true,
                'allow_contact_removal': true,
                'hide_offline_users': false,
                'roster_groups': true,
                'show_only_online_users': false,
                'show_toolbar': true,
                'xhr_user_search_url': null
            });
            _converse.api.promises.add('rosterViewInitialized');

            const STATUSES = {
                'dnd': __('This contact is busy'),
                'online': __('This contact is online'),
                'offline': __('This contact is offline'),
                'unavailable': __('This contact is unavailable'),
                'xa': __('This contact is away for an extended period'),
                'away': __('This contact is away')
            };
            const LABEL_GROUPS = __('Groups');
            const HEADER_CURRENT_CONTACTS =  __('My contacts');
            const HEADER_PENDING_CONTACTS = __('Pending contacts');
            const HEADER_REQUESTING_CONTACTS = __('Contact requests');
            const HEADER_UNGROUPED = __('Ungrouped');
            const HEADER_WEIGHTS = {};
            HEADER_WEIGHTS[HEADER_REQUESTING_CONTACTS] = 0;
            HEADER_WEIGHTS[HEADER_CURRENT_CONTACTS]    = 1;
            HEADER_WEIGHTS[HEADER_UNGROUPED]           = 2;
            HEADER_WEIGHTS[HEADER_PENDING_CONTACTS]    = 3;

            _converse.RosterGroupsComparator = 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');
                const special_groups = _.keys(HEADER_WEIGHTS);
                const a_is_special = _.includes(special_groups, a);
                const b_is_special = _.includes(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_REQUESTING_CONTACTS) ? 1 : -1;
                } else if (a_is_special && !b_is_special) {
                    return (a === HEADER_REQUESTING_CONTACTS) ? -1 : 1;
                }
            };


            _converse.AddContactModal = _converse.BootstrapModal.extend({
                events: {
                    'submit form': 'addContactFromForm'
                },

                initialize () {
                    _converse.BootstrapModal.prototype.initialize.apply(this, arguments);
                    this.model.on('change', this.render, this);
                },

                toHTML () {
                    const label_nickname = _converse.xhr_user_search_url ? __('Contact name') : __('Optional nickname');
                    return  tpl_add_contact_modal(_.extend(this.model.toJSON(), {
                        '_converse': _converse,
                        'heading_new_contact': __('Add a Contact'),
                        'label_xmpp_address': __('XMPP Address'),
                        'label_nickname': label_nickname,
                        'contact_placeholder': __('name@example.org'),
                        'label_add': __('Add'),
                        'error_message': __('Please enter a valid XMPP address')
                    }));
                },

                afterRender () {
                    if (_converse.xhr_user_search_url && _.isString(_converse.xhr_user_search_url)) {
                        this.initXHRAutoComplete(this.el);
                    } else {
                        this.initJIDAutoComplete(this.el);
                    }
                    const jid_input = this.el.querySelector('input[name="jid"]');
                    this.el.addEventListener('shown.bs.modal', () => {
                        jid_input.focus();
                    }, false);
                },

                initJIDAutoComplete (root) {
                    const jid_input = root.querySelector('input[name="jid"]');
                    const list = _.uniq(_converse.roster.map((item) => Strophe.getDomainFromJid(item.get('jid'))));
                    new Awesomplete(jid_input, {
                        'list': list,
                        'data': function (text, input) {
                            return input.slice(0, input.indexOf("@")) + "@" + text;
                        },
                        'filter': Awesomplete.FILTER_STARTSWITH
                    });
                },

                initXHRAutoComplete (root) {
                    const name_input = this.el.querySelector('input[name="name"]');
                    const jid_input = this.el.querySelector('input[name="jid"]');
                    const awesomplete = new Awesomplete(name_input, {
                        'minChars': 1,
                        'list': []
                    });
                    const xhr = new window.XMLHttpRequest();
                    // `open` must be called after `onload` for mock/testing purposes.
                    xhr.onload = function () {
                        if (xhr.responseText) {
                            awesomplete.list = JSON.parse(xhr.responseText).map((i) => { //eslint-disable-line arrow-body-style
                                return {'label': i.fullname || i.jid, 'value': i.jid};
                            });
                            awesomplete.evaluate();
                        }
                    };
                    name_input.addEventListener('input', _.debounce(() => {
                        xhr.open("GET", `${_converse.xhr_user_search_url}q=${name_input.value}`, true);
                        xhr.send()
                    } , 300));
                    this.el.addEventListener('awesomplete-selectcomplete', (ev) => {
                        jid_input.value = ev.text.value;
                        name_input.value = ev.text.label;
                    });
                },

                addContactFromForm (ev) {
                    ev.preventDefault();
                    const data = new FormData(ev.target),
                          jid = data.get('jid'),
                          name = data.get('name');
                    if (!jid || _.compact(jid.split('@')).length < 2) {
                        // XXX: we have to do this manually, instead of via
                        // toHTML because Awesomplete messes things up and
                        // confuses Snabbdom
                        u.addClass('is-invalid', this.el.querySelector('input[name="jid"]'));
                        u.addClass('d-block', this.el.querySelector('.invalid-feedback'));
                    } else {
                        ev.target.reset();
                        _converse.roster.addAndSubscribe(jid, name);
                        this.model.clear();
                        this.modal.hide();
                    }
                }
            });


            _converse.RosterFilter = Backbone.Model.extend({
                initialize () {
                    this.set({
                        'filter_text': '',
                        'filter_type': 'contacts',
                        'chat_state': ''
                    });
                },
            });

            _converse.RosterFilterView = Backbone.VDOMView.extend({
                tagName: 'form',
                className: 'roster-filter-form',
                events: {
                    "keydown .roster-filter": "liveFilter",
                    "submit form.roster-filter-form": "submitFilter",
                    "click .clear-input": "clearFilter",
                    "click .filter-by span": "changeTypeFilter",
                    "change .state-type": "changeChatStateFilter"
                },

                initialize () {
                    this.model.on('change:filter_type', this.render, this);
                    this.model.on('change:filter_text', this.render, this);
                },

                toHTML () {
                    return tpl_roster_filter(
                        _.extend(this.model.toJSON(), {
                            visible: this.shouldBeVisible(),
                            placeholder: __('Filter'),
                            title_contact_filter: __('Filter by contact name'),
                            title_group_filter: __('Filter by group name'),
                            title_status_filter: __('Filter by status'),
                            label_any: __('Any'),
                            label_unread_messages: __('Unread'),
                            label_online: __('Online'),
                            label_chatty: __('Chatty'),
                            label_busy: __('Busy'),
                            label_away: __('Away'),
                            label_xa: __('Extended Away'),
                            label_offline: __('Offline')
                        }));
                },

                changeChatStateFilter (ev) {
                    if (ev && ev.preventDefault) { ev.preventDefault(); }
                    this.model.save({
                        'chat_state': this.el.querySelector('.state-type').value
                    });
                },

                changeTypeFilter (ev) {
                    if (ev && ev.preventDefault) { ev.preventDefault(); }
                    const type = ev.target.dataset.type;
                    if (type === 'state') {
                        this.model.save({
                            'filter_type': type,
                            'chat_state': this.el.querySelector('.state-type').value
                        });
                    } else {
                        this.model.save({
                            'filter_type': type,
                            'filter_text': this.el.querySelector('.roster-filter').value
                        });
                    }
                },

                liveFilter: _.debounce(function (ev) {
                    this.model.save({
                        'filter_text': this.el.querySelector('.roster-filter').value
                    });
                }, 250),

                submitFilter (ev) {
                    if (ev && ev.preventDefault) { ev.preventDefault(); }
                    this.liveFilter();
                    this.render();
                },

                isActive () {
                    /* Returns true if the filter is enabled (i.e. if the user
                     * has added values to the filter).
                     */
                    if (this.model.get('filter_type') === 'state' ||
                        this.model.get('filter_text')) {
                        return true;
                    }
                    return false;
                },

                shouldBeVisible () {
                    return _converse.roster.length >= 5 || this.isActive();
                },

                showOrHide () {
                    if (this.shouldBeVisible()) {
                        this.show();
                    } else {
                        this.hide();
                    }
                },

                show () {
                    if (u.isVisible(this.el)) { return this; }
                    this.el.classList.add('fade-in');
                    this.el.classList.remove('hidden');
                    return this;
                },

                hide () {
                    if (!u.isVisible(this.el)) { return this; }
                    this.model.save({
                        'filter_text': '',
                        'chat_state': ''
                    });
                    this.el.classList.add('hidden');
                    return this;
                },

                clearFilter (ev) {
                    if (ev && ev.preventDefault) {
                        ev.preventDefault();
                        u.hideElement(this.el.querySelector('.clear-input'));
                    }
                    const roster_filter = this.el.querySelector('.roster-filter');
                    roster_filter.value = '';
                    this.model.save({'filter_text': ''});
                }
            });

            _converse.RosterContactView = Backbone.NativeView.extend({
                tagName: 'li',
                className: 'list-item d-flex hidden controlbox-padded',

                events: {
                    "click .accept-xmpp-request": "acceptRequest",
                    "click .decline-xmpp-request": "declineRequest",
                    "click .open-chat": "openChat",
                    "click .remove-xmpp-contact": "removeContact"
                },

                initialize () {
                    this.model.on("change", this.render, this);
                    this.model.on("highlight", this.highlight, this);
                    this.model.on("destroy", this.remove, this);
                    this.model.on("open", this.openChat, this);
                    this.model.on("remove", this.remove, this);

                    this.model.presence.on("change:show", this.render, this);
                    this.model.vcard.on('change:fullname', this.render, this);
                },

                render () {
                    const that = this;
                    if (!this.mayBeShown()) {
                        u.hideElement(this.el);
                        return this;
                    }
                    const ask = this.model.get('ask'),
                        show = this.model.presence.get('show'),
                        requesting  = this.model.get('requesting'),
                        subscription = this.model.get('subscription');

                    const classes_to_remove = [
                        'current-xmpp-contact',
                        'pending-xmpp-contact',
                        'requesting-xmpp-contact'
                        ].concat(_.keys(STATUSES));

                    _.each(classes_to_remove,
                        function (cls) {
                            if (_.includes(that.el.className, cls)) {
                                that.el.classList.remove(cls);
                            }
                        });
                    this.el.classList.add(show);
                    this.el.setAttribute('data-status', show);
                    this.highlight();

                    if (_converse.isSingleton()) {
                        const chatbox = _converse.chatboxes.get(this.model.get('jid'));
                        if (chatbox) {
                            if (chatbox.get('hidden')) {
                                this.el.classList.remove('open');
                            } else {
                                this.el.classList.add('open');
                            }
                        }
                    }

                    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.
                         */
                        const display_name = this.model.getDisplayName();
                        this.el.classList.add('pending-xmpp-contact');
                        this.el.innerHTML = tpl_pending_contact(
                            _.extend(this.model.toJSON(), {
                                'display_name': display_name,
                                'desc_remove': __('Click to remove %1$s as a contact', display_name),
                                'allow_chat_pending_contacts': _converse.allow_chat_pending_contacts
                            })
                        );
                    } else if (requesting === true) {
                        const display_name = this.model.getDisplayName();
                        this.el.classList.add('requesting-xmpp-contact');
                        this.el.innerHTML = tpl_requesting_contact(
                            _.extend(this.model.toJSON(), {
                                'display_name': display_name,
                                'desc_accept': __("Click to accept the contact request from %1$s", display_name),
                                'desc_decline': __("Click to decline the contact request from %1$s", display_name),
                                'allow_chat_pending_contacts': _converse.allow_chat_pending_contacts
                            })
                        );
                    } else if (subscription === 'both' || subscription === 'to') {
                        this.el.classList.add('current-xmpp-contact');
                        this.el.classList.remove(_.without(['both', 'to'], subscription)[0]);
                        this.el.classList.add(subscription);
                        this.renderRosterItem(this.model);
                    }
                    return this;
                },

                highlight () {
                    /* If appropriate, highlight the contact (by adding the 'open' class).
                     */
                    if (_converse.isSingleton()) {
                        const chatbox = _converse.chatboxes.get(this.model.get('jid'));
                        if (chatbox) {
                            if (chatbox.get('hidden')) {
                                this.el.classList.remove('open');
                            } else {
                                this.el.classList.add('open');
                            }
                        }
                    }
                },

                renderRosterItem (item) {
                    let status_icon = 'fa fa-times-circle';
                    const show = item.presence.get('show') || 'offline';
                    if (show === 'online') {
                        status_icon = 'fa fa-circle';
                    } else if (show === 'away') {
                        status_icon = 'fa fa-dot-circle';
                    } else if (show === 'xa') {
                        status_icon = 'far fa-circle';
                    } else if (show === 'dnd') {
                        status_icon = 'fa fa-minus-circle';
                    }
                    const display_name = item.getDisplayName();
                    this.el.innerHTML = tpl_roster_item(
                        _.extend(item.toJSON(), {
                            'display_name': display_name,
                            'desc_status': STATUSES[show],
                            'status_icon': status_icon,
                            'desc_chat': __('Click to chat with %1$s (JID: %2$s)', display_name, item.get('jid')),
                            'desc_remove': __('Click to remove %1$s as a contact', display_name),
                            'allow_contact_removal': _converse.allow_contact_removal,
                            'num_unread': item.get('num_unread') || 0
                        })
                    );
                    return this;
                },

                mayBeShown () {
                    /* Return a boolean indicating whether this contact should
                     * generally be visible in the roster.
                     *
                     * It doesn't check for the more specific case of whether
                     * the group it's in is collapsed.
                     */
                    const chatStatus = this.model.presence.get('show');
                    if ((_converse.show_only_online_users && chatStatus !== 'online') ||
                        (_converse.hide_offline_users && chatStatus === 'offline')) {
                        // If pending or requesting, show
                        if ((this.model.get('ask') === 'subscribe') ||
                                (this.model.get('subscription') === 'from') ||
                                (this.model.get('requesting') === true)) {
                            return true;
                        }
                        return false;
                    }
                    return true;
                },

                openChat (ev) {
                    if (ev && ev.preventDefault) { ev.preventDefault(); }
                    const attrs = this.model.attributes;
                    _converse.api.chats.open(attrs.jid, attrs);
                },

                removeContact (ev) {
                    if (ev && ev.preventDefault) { ev.preventDefault(); }
                    if (!_converse.allow_contact_removal) { return; }
                    const result = confirm(__("Are you sure you want to remove this contact?"));
                    if (result === true) {
                        this.model.removeFromRoster(
                            (iq) => {
                                this.model.destroy();
                                this.remove();
                            },
                            function (err) {
                                alert(__('Sorry, there was an error while trying to remove %1$s as a contact.', name));
                                _converse.log(err, Strophe.LogLevel.ERROR);
                            }
                        );
                    }
                },

                acceptRequest (ev) {
                    if (ev && ev.preventDefault) { ev.preventDefault(); }
                    _converse.roster.sendContactAddIQ(
                        this.model.get('jid'),
                        this.model.getFullname(),
                        [],
                        () => { this.model.authorize().subscribe(); }
                    );
                },

                declineRequest (ev) {
                    if (ev && ev.preventDefault) { ev.preventDefault(); }
                    const result = confirm(__("Are you sure you want to decline this contact request?"));
                    if (result === true) {
                        this.model.unauthorize().destroy();
                    }
                    return this;
                }
            });

            _converse.RosterGroupView = Backbone.OrderedListView.extend({
                tagName: 'div',
                className: 'roster-group hidden',
                events: {
                    "click a.group-toggle": "toggle"
                },

                ItemView: _converse.RosterContactView,
                listItems: 'model.contacts',
                listSelector: '.roster-group-contacts',
                sortEvent: 'presenceChanged',

                initialize () {
                    Backbone.OrderedListView.prototype.initialize.apply(this, arguments);
                    this.model.contacts.on("change:subscription", this.onContactSubscriptionChange, this);
                    this.model.contacts.on("change:requesting", this.onContactRequestChange, this);
                    this.model.contacts.on("remove", this.onRemove, this);
                    _converse.roster.on('change:groups', this.onContactGroupChange, this);

                    // This event gets triggered once *all* contacts (i.e. not
                    // just this group's) have been fetched from browser
                    // storage or the XMPP server and once they've been
                    // assigned to their various groups.
                    _converse.rosterview.on(
                        'rosterContactsFetchedAndProcessed',
                        this.sortAndPositionAllItems.bind(this)
                    );
                },

                render () {
                    this.el.setAttribute('data-group', this.model.get('name'));
                    this.el.innerHTML = tpl_group_header({
                        'label_group': this.model.get('name'),
                        'desc_group_toggle': this.model.get('description'),
                        'toggle_state': this.model.get('state'),
                        '_converse': _converse
                    });
                    this.contacts_el = this.el.querySelector('.roster-group-contacts');
                    return this;
                },

                show () {
                    u.showElement(this.el);
                    _.each(this.getAll(), (contact_view) => {
                        if (contact_view.mayBeShown() && this.model.get('state') === _converse.OPENED) {
                            u.showElement(contact_view.el);
                        }
                    });
                    return this;
                },

                collapse () {
                    return u.slideIn(this.contacts_el);
                },

                filterOutContacts (contacts=[]) {
                    /* Given a list of contacts, make sure they're filtered out
                     * (aka hidden) and that all other contacts are visible.
                     *
                     * If all contacts are hidden, then also hide the group
                     * title.
                     */
                    let shown = 0;
                    const all_contact_views = this.getAll();
                    _.each(this.model.contacts.models, (contact) => {
                        const contact_view = this.get(contact.get('id'));
                        if (_.includes(contacts, contact)) {
                            u.hideElement(contact_view.el);
                        } else if (contact_view.mayBeShown()) {
                            u.showElement(contact_view.el);
                            shown += 1;
                        }
                    });
                    if (shown) {
                        u.showElement(this.el);
                    } else {
                        u.hideElement(this.el);
                    }
                },

                getFilterMatches (q, type) {
                    /* Given the filter query "q" and the filter type "type",
                     * return a list of contacts that need to be filtered out.
                     */
                    if (q.length === 0) {
                        return [];
                    }
                    let matches;
                    q = q.toLowerCase();
                    if (type === 'state') {
                        if (this.model.get('name') === HEADER_REQUESTING_CONTACTS) {
                            // When filtering by chat state, we still want to
                            // show requesting contacts, even though they don't
                            // have the state in question.
                            matches = this.model.contacts.filter(
                                (contact) => !_.includes(contact.presence.get('show'), q) && !contact.get('requesting')
                            );
                        } else if (q === 'unread_messages') {
                            matches = this.model.contacts.filter({'num_unread': 0});
                        } else {
                            matches = this.model.contacts.filter(
                                (contact) => !_.includes(contact.presence.get('show'), q)
                            );
                        }
                    } else  {
                        matches = this.model.contacts.filter((contact) => {
                            return !_.includes(contact.getDisplayName().toLowerCase(), q.toLowerCase());
                        });
                    }
                    return matches;
                },

                filter (q, type) {
                    /* Filter the group's contacts based on the query "q".
                     * 
                     * If all contacts are filtered out (i.e. hidden), then the
                     * group must be filtered out as well.
                     */
                    if (_.isNil(q)) {
                        type = type || _converse.rosterview.filter_view.model.get('filter_type');
                        if (type === 'state') {
                            q = _converse.rosterview.filter_view.model.get('chat_state');
                        } else {
                            q = _converse.rosterview.filter_view.model.get('filter_text');
                        }
                    }
                    this.filterOutContacts(this.getFilterMatches(q, type));
                },

                toggle (ev) {
                    if (ev && ev.preventDefault) { ev.preventDefault(); }
                    const icon_el = ev.target.querySelector('.fa');
                    if (_.includes(icon_el.classList, "fa-caret-down")) {
                        this.model.save({state: _converse.CLOSED});
                        this.collapse().then(() => {
                            icon_el.classList.remove("fa-caret-down");
                            icon_el.classList.add("fa-caret-right");
                        });
                    } else {
                        icon_el.classList.remove("fa-caret-right");
                        icon_el.classList.add("fa-caret-down");
                        this.model.save({state: _converse.OPENED});
                        this.filter();
                        u.showElement(this.el);
                        u.slideOut(this.contacts_el);
                    }
                },

                onContactGroupChange (contact) {
                    const in_this_group = _.includes(contact.get('groups'), this.model.get('name'));
                    const cid = contact.get('id');
                    const in_this_overview = !this.get(cid);
                    if (in_this_group && !in_this_overview) {
                        this.items.trigger('add', contact);
                    } else if (!in_this_group) {
                        this.removeContact(contact);
                    }
                },

                onContactSubscriptionChange (contact) {
                    if ((this.model.get('name') === HEADER_PENDING_CONTACTS) && contact.get('subscription') !== 'from') {
                        this.removeContact(contact);
                    }
                },

                onContactRequestChange (contact) {
                    if ((this.model.get('name') === HEADER_REQUESTING_CONTACTS) && !contact.get('requesting')) {
                        this.removeContact(contact);
                    }
                },

                removeContact (contact) {
                    // We suppress events, otherwise the remove event will
                    // also cause the contact's view to be removed from the
                    // "Pending Contacts" group.
                    this.model.contacts.remove(contact, {'silent': true});
                    this.onRemove(contact);
                },

                onRemove (contact) {
                    this.remove(contact.get('jid'));
                    if (this.model.contacts.length === 0) {
                        this.remove();
                    }
                }
            });


            _converse.RosterView = Backbone.OrderedListView.extend({
                tagName: 'div',
                id: 'converse-roster',
                className: 'controlbox-section',

                ItemView: _converse.RosterGroupView,
                listItems: 'model',
                listSelector: '.roster-contacts',
                sortEvent: null, // Groups are immutable, so they don't get re-sorted
                subviewIndex: 'name',

                events: {
                    'click a.chatbox-btn.add-contact': 'showAddContactModal',
                },

                initialize () {
                    Backbone.OrderedListView.prototype.initialize.apply(this, arguments);

                    _converse.roster.on("add", this.onContactAdded, this);
                    _converse.roster.on('change:groups', this.onContactAdded, this);
                    _converse.roster.on('change', this.onContactChange, this);
                    _converse.roster.on("destroy", this.update, this);
                    _converse.roster.on("remove", this.update, this);
                    _converse.presences.on('change:show', () => {
                        this.update();
                        this.updateFilter();
                    });

                    this.model.on("reset", this.reset, this);

                    // This event gets triggered once *all* contacts (i.e. not
                    // just this group's) have been fetched from browser
                    // storage or the XMPP server and once they've been
                    // assigned to their various groups.
                    _converse.on('rosterGroupsFetched', this.sortAndPositionAllItems.bind(this));

                    _converse.on('rosterContactsFetched', () => {
                        _converse.roster.each((contact) => this.addRosterContact(contact, {'silent': true}));
                        this.update();
                        this.updateFilter();
                        this.trigger('rosterContactsFetchedAndProcessed');
                    });
                    this.createRosterFilter();
                },

                render () {
                    this.el.innerHTML = tpl_roster({
                        'heading_contacts': __('Contacts'),
                        'title_add_contact': __('Add a contact')
                    });
                    const form = this.el.querySelector('.roster-filter-form');
                    this.el.replaceChild(this.filter_view.render().el, form);
                    this.roster_el = this.el.querySelector('.roster-contacts');
                    return this;
                },

                showAddContactModal (ev) {
                    if (_.isUndefined(this.add_contact_modal)) {
                        this.add_contact_modal = new _converse.AddContactModal({'model': new Backbone.Model()});
                    }
                    this.add_contact_modal.show(ev);
                },

                createRosterFilter () {
                    // Create a model on which we can store filter properties
                    const model = new _converse.RosterFilter();
                    model.id = b64_sha1(`_converse.rosterfilter${_converse.bare_jid}`);
                    model.browserStorage = new Backbone.BrowserStorage.local(this.filter.id);
                    this.filter_view = new _converse.RosterFilterView({'model': model});
                    this.filter_view.model.on('change', this.updateFilter, this);
                    this.filter_view.model.fetch();
                },

                updateFilter: _.debounce(function () {
                    /* Filter the roster again.
                     * Called whenever the filter settings have been changed or
                     * when contacts have been added, removed or changed.
                     *
                     * Debounced so that it doesn't get called for every
                     * contact fetched from browser storage.
                     */
                    const type = this.filter_view.model.get('filter_type');
                    if (type === 'state') {
                        this.filter(this.filter_view.model.get('chat_state'), type);
                    } else {
                        this.filter(this.filter_view.model.get('filter_text'), type);
                    }
                }, 100),

                update: _.debounce(function () {
                    if (!u.isVisible(this.roster_el)) {
                        u.showElement(this.roster_el);
                    }
                    this.filter_view.showOrHide();
                    return this;
                }, _converse.animate ? 100 : 0),

                filter (query, type) {
                    // First we make sure the filter is restored to its
                    // original state
                    _.each(this.getAll(), function (view) {
                        if (view.model.contacts.length > 0) {
                            view.show().filter('');
                        }
                    });
                    // Now we can filter
                    query = query.toLowerCase();
                    if (type === 'groups') {
                        _.each(this.getAll(), function (view, idx) {
                            if (!_.includes(view.model.get('name').toLowerCase(), query.toLowerCase())) {
                                u.slideIn(view.el);
                            } else if (view.model.contacts.length > 0) {
                                u.slideOut(view.el);
                            }
                        });
                    } else {
                        _.each(this.getAll(), function (view) {
                            view.filter(query, type);
                        });
                    }
                },

                reset () {
                    _converse.roster.reset();
                    this.removeAll();
                    this.render().update();
                    return this;
                },

                onContactAdded (contact) {
                    this.addRosterContact(contact)
                    this.update();
                    this.updateFilter();
                },

                onContactChange (contact) {
                    this.updateChatBox(contact)
                    this.update();
                    if (_.has(contact.changed, 'subscription')) {
                        if (contact.changed.subscription === 'from') {
                            this.addContactToGroup(contact, HEADER_PENDING_CONTACTS);
                        } else if (_.includes(['both', 'to'], contact.get('subscription'))) {
                            this.addExistingContact(contact);
                        }
                    }
                    if (_.has(contact.changed, 'ask') && contact.changed.ask === 'subscribe') {
                        this.addContactToGroup(contact, HEADER_PENDING_CONTACTS);
                    }
                    if (_.has(contact.changed, 'subscription') && contact.changed.requesting === 'true') {
                        this.addContactToGroup(contact, HEADER_REQUESTING_CONTACTS);
                    }
                    this.updateFilter();
                },

                updateChatBox (contact) {
                    if (!this.model.chatbox) {
                        return this;
                    }
                    const changes = {};
                    if (_.has(contact.changed, 'status')) {
                        changes.status = contact.get('status');
                    }
                    this.model.chatbox.save(changes);
                    return this;
                },

                getGroup (name) {
                    /* Returns the group as specified by name.
                     * Creates the group if it doesn't exist.
                     */
                    const view =  this.get(name);
                    if (view) {
                        return view.model;
                    }
                    return this.model.create({name, id: b64_sha1(name)});
                },

                addContactToGroup (contact, name, options) {
                    this.getGroup(name).contacts.add(contact, options);
                    this.sortAndPositionAllItems();
                },

                addExistingContact (contact, options) {
                    let groups;
                    if (_converse.roster_groups) {
                        groups = contact.get('groups');
                        if (groups.length === 0) {
                            groups = [HEADER_UNGROUPED];
                        }
                    } else {
                        groups = [HEADER_CURRENT_CONTACTS];
                    }
                    _.each(groups, _.bind(this.addContactToGroup, this, contact, _, options));
                },

                addRosterContact (contact, options) {
                    if (contact.get('subscription') === 'both' || contact.get('subscription') === 'to') {
                        this.addExistingContact(contact, options);
                    } else {
                        if ((contact.get('ask') === 'subscribe') || (contact.get('subscription') === 'from')) {
                            this.addContactToGroup(contact, HEADER_PENDING_CONTACTS, options);
                        } else if (contact.get('requesting') === true) {
                            this.addContactToGroup(contact, HEADER_REQUESTING_CONTACTS, options);
                        }
                    }
                    return this;
                }
            });


            /* -------- Event Handlers ----------- */
            _converse.api.listen.on('chatBoxesInitialized', () => {

                _converse.chatboxes.on('change:hidden', (chatbox) => {
                    const contact = _converse.roster.findWhere({'jid': chatbox.get('jid')});
                    if (!_.isUndefined(contact)) {
                        contact.trigger('highlight', contact);
                    }
                });
            });

            function initRoster () {
                /* Create an instance of RosterView once the RosterGroups
                 * collection has been created (in converse-core.js)
                 */
                if (_converse.authentication === _converse.ANONYMOUS) {
                    return;
                }
                _converse.rosterview = new _converse.RosterView({
                    'model': _converse.rostergroups
                });
                _converse.rosterview.render();
                _converse.emit('rosterViewInitialized');
            }
            _converse.api.listen.on('rosterInitialized', initRoster);
            _converse.api.listen.on('rosterReadyAfterReconnection', initRoster);
        }
    });
}));