Kaynağa Gözat

Move converse-rosterview plugin into folder

JC Brand 4 yıl önce
ebeveyn
işleme
794a709690

+ 1 - 1
src/converse.js

@@ -29,7 +29,7 @@ import "./plugins/profile.js";
 import "./plugins/push.js";                 // XEP-0357 Push Notifications
 import "./plugins/register.js";             // XEP-0077 In-band registration
 import "./plugins/roomslist/index.js";      // Show currently open chat rooms
-import "./plugins/rosterview.js";
+import "./plugins/rosterview/index.js";
 import "./plugins/singleton.js";
 /* END: Removable components */
 

+ 2 - 3
src/plugins/chatboxviews/index.js

@@ -5,9 +5,8 @@
  */
 import '@converse/headless/plugins/chatboxes';
 import 'components/converse.js';
-import AvatarMixin from 'shared/avatar.js';
+import ViewWithAvatar from 'shared/avatar.js';
 import ChatBoxViews from './view.js';
-import { View } from '@converse/skeletor/src/view';
 import { _converse, api, converse } from '@converse/headless/core';
 
 function onChatBoxViewsInitialized () {
@@ -47,7 +46,7 @@ converse.plugins.add('converse-chatboxviews', {
             'theme': 'default'
         });
 
-        _converse.ViewWithAvatar = View.extend(AvatarMixin);
+        _converse.ViewWithAvatar = ViewWithAvatar;
         _converse.ChatBoxViews = ChatBoxViews;
 
         /************************ BEGIN Event Handlers ************************/

+ 0 - 848
src/plugins/rosterview.js

@@ -1,848 +0,0 @@
-/**
- * @module converse-rosterview
- * @copyright 2020, the Converse.js contributors
- * @license Mozilla Public License (MPLv2)
- */
-import "./modal";
-import "@converse/headless/plugins/chatboxes";
-import "@converse/headless/plugins/roster";
-import "../modals/add-contact.js";
-import log from "@converse/headless/log";
-import tpl_group_header from "../templates/group_header.html";
-import tpl_pending_contact from "../templates/pending_contact.html";
-import tpl_requesting_contact from "../templates/requesting_contact.html";
-import tpl_roster from "../templates/roster.html";
-import tpl_roster_filter from "../templates/roster_filter.js";
-import tpl_roster_item from "../templates/roster_item.html";
-import { Model } from '@converse/skeletor/src/model.js';
-import { OrderedListView } from "@converse/skeletor/src/overview";
-import { View } from '@converse/skeletor/src/view.js';
-import { __ } from '../i18n';
-import { _converse, api, converse } from "@converse/headless/core";
-import { debounce, has, without } from "lodash-es";
-
-const u = converse.env.utils;
-
-
-converse.plugins.add('converse-rosterview', {
-
-    dependencies: ["converse-roster", "converse-modal", "converse-chatboxviews"],
-
-    initialize () {
-        /* The initialize function gets called as soon as the plugin is
-         * loaded by converse.js's plugin machinery.
-         */
-
-        api.settings.extend({
-            'autocomplete_add_contact': true,
-            'allow_chat_pending_contacts': true,
-            'allow_contact_removal': true,
-            'hide_offline_users': false,
-            'roster_groups': true,
-            'xhr_user_search_url': null,
-        });
-        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')
-        };
-
-
-        _converse.RosterFilter = Model.extend({
-            initialize () {
-                this.set({
-                    'filter_text': '',
-                    'filter_type': 'contacts',
-                    'chat_state': 'online'
-                });
-            },
-        });
-
-
-        _converse.RosterFilterView = View.extend({
-            tagName: 'span',
-
-            initialize () {
-                this.listenTo(this.model, 'change:filter_type', this.render);
-                this.listenTo(this.model, 'change:filter_text', this.render);
-            },
-
-            toHTML () {
-                return tpl_roster_filter(
-                    Object.assign(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 => this.changeChatStateFilter(ev),
-                        changeTypeFilter: ev => this.changeTypeFilter(ev),
-                        clearFilter: ev => this.clearFilter(ev),
-                        liveFilter: ev => this.liveFilter(ev),
-                        submitFilter: ev => this.submitFilter(ev),
-                    }));
-            },
-
-            changeChatStateFilter (ev) {
-                ev && ev.preventDefault();
-                this.model.save({'chat_state': this.el.querySelector('.state-type').value});
-            },
-
-            changeTypeFilter (ev) {
-                ev && 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 () {
-                this.model.save({'filter_text': this.el.querySelector('.roster-filter').value});
-            }, 250),
-
-            submitFilter (ev) {
-                ev && ev.preventDefault();
-                this.liveFilter();
-            },
-
-            /**
-             * Returns true if the filter is enabled (i.e. if the user
-             * has added values to the filter).
-             * @private
-             * @method _converse.RosterFilterView#isActive
-             */
-            isActive () {
-                return (this.model.get('filter_type') === 'state' || this.model.get('filter_text'));
-            },
-
-            shouldBeVisible () {
-                return _converse.roster && _converse.roster.length >= 5 || this.isActive();
-            },
-
-            clearFilter (ev) {
-                ev && ev.preventDefault();
-                this.model.save({'filter_text': ''});
-            }
-        });
-
-
-        _converse.RosterContactView = _converse.ViewWithAvatar.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"
-            },
-
-            async initialize () {
-                await this.model.initialized;
-                this.debouncedRender = debounce(this.render, 50);
-                this.listenTo(this.model, "change", this.debouncedRender);
-                this.listenTo(this.model, "destroy", this.remove);
-                this.listenTo(this.model, "highlight", this.highlight);
-                this.listenTo(this.model, "remove", this.remove);
-                this.listenTo(this.model, 'vcard:change', this.debouncedRender);
-                this.listenTo(this.model.presence, "change:show", this.debouncedRender);
-                this.render();
-            },
-
-            render () {
-                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'),
-                    jid = this.model.get('jid');
-
-                const classes_to_remove = [
-                    'current-xmpp-contact',
-                    'pending-xmpp-contact',
-                    'requesting-xmpp-contact'
-                    ].concat(Object.keys(STATUSES));
-                classes_to_remove.forEach(c => u.removeClass(c, this.el));
-
-                this.el.classList.add(show);
-                this.el.setAttribute('data-status', show);
-                this.highlight();
-
-                if (_converse.isUniView()) {
-                    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(
-                        Object.assign(this.model.toJSON(), {
-                            display_name,
-                            'desc_remove': __('Click to remove %1$s as a contact', display_name),
-                            'allow_chat_pending_contacts': api.settings.get('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(
-                        Object.assign(this.model.toJSON(), {
-                            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': api.settings.get('allow_chat_pending_contacts')
-                        })
-                    );
-                } else if (subscription === 'both' || subscription === 'to' || _converse.rosterview.isSelf(jid)) {
-                    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;
-            },
-
-            /**
-             * If appropriate, highlight the contact (by adding the 'open' class).
-             * @private
-             * @method _converse.RosterContactView#highlight
-             */
-            highlight () {
-                if (_converse.isUniView()) {
-                    const chatbox = _converse.chatboxes.get(this.model.get('jid'));
-                    if ((chatbox && chatbox.get('hidden')) || !chatbox) {
-                        this.el.classList.remove('open');
-                    } else {
-                        this.el.classList.add('open');
-                    }
-                }
-            },
-
-            renderRosterItem (item) {
-                const show = item.presence.get('show') || 'offline';
-                let status_icon;
-                if (show === 'online') {
-                    status_icon = 'fa fa-circle chat-status chat-status--online';
-                } else if (show === 'away') {
-                    status_icon = 'fa fa-circle chat-status chat-status--away';
-                } else if (show === 'xa') {
-                    status_icon = 'far fa-circle chat-status chat-status-xa';
-                } else if (show === 'dnd') {
-                    status_icon = 'fa fa-minus-circle chat-status chat-status--busy';
-                } else {
-                    status_icon = 'fa fa-times-circle chat-status chat-status--offline';
-                }
-                const display_name = item.getDisplayName();
-                this.el.innerHTML = tpl_roster_item(
-                    Object.assign(item.toJSON(), {
-                        show,
-                        display_name,
-                        status_icon,
-                        'desc_status': STATUSES[show],
-                        'desc_chat': __('Click to chat with %1$s (XMPP address: %2$s)', display_name, item.get('jid')),
-                        'desc_remove': __('Click to remove %1$s as a contact', display_name),
-                        'allow_contact_removal': api.settings.get('allow_contact_removal'),
-                        'num_unread': item.get('num_unread') || 0,
-                        classes: ''
-                    })
-                );
-                this.renderAvatar();
-                return this;
-            },
-
-            /**
-             * Returns 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.
-             * @private
-             * @method _converse.RosterContactView#mayBeShown
-             */
-            mayBeShown () {
-                const chatStatus = this.model.presence.get('show');
-                if (api.settings.get('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(); }
-                this.model.openChat();
-            },
-
-            async removeContact (ev) {
-                if (ev && ev.preventDefault) { ev.preventDefault(); }
-                if (!api.settings.get('allow_contact_removal')) { return; }
-                if (!confirm(__("Are you sure you want to remove this contact?"))) { return; }
-
-                try {
-                    await this.model.removeFromRoster();
-                    this.remove();
-                    if (this.model.collection) {
-                        // The model might have already been removed as
-                        // result of a roster push.
-                        this.model.destroy();
-                    }
-                } catch (e) {
-                    log.error(e);
-                    api.alert('error', __('Error'),
-                        [__('Sorry, there was an error while trying to remove %1$s as a contact.', this.model.getDisplayName())]
-                    );
-                }
-            },
-
-            async acceptRequest (ev) {
-                if (ev && ev.preventDefault) { ev.preventDefault(); }
-
-                await _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;
-            }
-        });
-
-        /**
-         * @class
-         * @namespace _converse.RosterGroupView
-         * @memberOf _converse
-         */
-        _converse.RosterGroupView = OrderedListView.extend({
-            tagName: 'div',
-            className: 'roster-group hidden',
-            events: {
-                "click a.group-toggle": "toggle"
-            },
-
-            sortImmediatelyOnAdd: true,
-            ItemView: _converse.RosterContactView,
-            listItems: 'model.contacts',
-            listSelector: '.roster-group-contacts',
-            sortEvent: 'presenceChanged',
-
-            initialize () {
-                OrderedListView.prototype.initialize.apply(this, arguments);
-
-                if (this.model.get('name') === _converse.HEADER_UNREAD) {
-                    this.listenTo(this.model.contacts, "change:num_unread",
-                        c => !this.model.get('unread_messages') && this.removeContact(c)
-                    );
-                }
-                if (this.model.get('name') === _converse.HEADER_REQUESTING_CONTACTS) {
-                    this.listenTo(this.model.contacts, "change:requesting",
-                        c => !c.get('requesting') && this.removeContact(c)
-                    );
-                }
-                if (this.model.get('name') === _converse.HEADER_PENDING_CONTACTS) {
-                    this.listenTo(this.model.contacts, "change:subscription",
-                        c => (c.get('subscription') !== 'from') && this.removeContact(c)
-                    );
-                }
-
-                this.listenTo(this.model.contacts, "remove", this.onRemove);
-                this.listenTo(_converse.roster, 'change:groups', this.onContactGroupChange);
-
-                // 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()
-                );
-            },
-
-            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);
-                if (this.model.get('state') === _converse.OPENED) {
-                    Object.values(this.getAll())
-                        .filter(v => v.mayBeShown())
-                        .forEach(v => u.showElement(v.el));
-                }
-                return this;
-            },
-
-            collapse () {
-                return u.slideIn(this.contacts_el);
-            },
-
-            /* 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.
-             * @private
-             * @method _converse.RosterGroupView#filterOutContacts
-             * @param { Array } contacts
-             */
-            filterOutContacts (contacts=[]) {
-                let shown = 0;
-                this.model.contacts.forEach(contact => {
-                    const contact_view = this.get(contact.get('id'));
-                    if (contacts.includes(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);
-                }
-            },
-
-            /**
-             * Given the filter query "q" and the filter type "type",
-             * return a list of contacts that need to be filtered out.
-             * @private
-             * @method _converse.RosterGroupView#getFilterMatches
-             * @param { String } q - The filter query
-             * @param { String } type - The filter type
-             */
-            getFilterMatches (q, type) {
-                if (q.length === 0) {
-                    return [];
-                }
-                q = q.toLowerCase();
-                const contacts = this.model.contacts;
-                if (type === 'state') {
-                    const sticky_groups = [_converse.HEADER_REQUESTING_CONTACTS, _converse.HEADER_UNREAD];
-                    if (sticky_groups.includes(this.model.get('name'))) {
-                        // When filtering by chat state, we still want to
-                        // show sticky groups, even though they don't
-                        // match the state in question.
-                        return [];
-                    } else if (q === 'unread_messages') {
-                        return contacts.filter({'num_unread': 0});
-                    } else if (q === 'online') {
-                        return contacts.filter(c => ["offline", "unavailable"].includes(c.presence.get('show')));
-                    } else {
-                        return contacts.filter(c => !c.presence.get('show').includes(q));
-                    }
-                } else  {
-                    return contacts.filter(c => !c.getFilterCriteria().includes(q));
-                }
-            },
-
-            /**
-             * 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.
-             * @private
-             * @method _converse.RosterGroupView#filter
-             * @param { string } q - The query to filter against
-             * @param { string } type
-             */
-            filter (q, type) {
-                if (q === null || q === undefined) {
-                    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));
-            },
-
-            async toggle (ev) {
-                if (ev && ev.preventDefault) { ev.preventDefault(); }
-                const icon_el = ev.target.matches('.fa') ? ev.target : ev.target.querySelector('.fa');
-                if (u.hasClass("fa-caret-down", icon_el)) {
-                    this.model.save({state: _converse.CLOSED});
-                    await this.collapse();
-                    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 = contact.get('groups').includes(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);
-                }
-            },
-
-            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();
-                }
-            }
-        });
-
-
-        /**
-         * @class
-         * @namespace _converse.RosterView
-         * @memberOf _converse
-         */
-        _converse.RosterView = 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',
-            sortImmediatelyOnAdd: true,
-
-            events: {
-                'click a.controlbox-heading__btn.add-contact': 'showAddContactModal',
-                'click a.controlbox-heading__btn.sync-contacts': 'syncContacts'
-            },
-
-            initialize () {
-                OrderedListView.prototype.initialize.apply(this, arguments);
-
-                this.listenTo(_converse.roster, "add", this.onContactAdded);
-                this.listenTo(_converse.roster, 'change:groups', this.onContactAdded);
-                this.listenTo(_converse.roster, 'change', this.onContactChange);
-                this.listenTo(_converse.roster, "destroy", this.update);
-                this.listenTo(_converse.roster, "remove", this.update);
-                _converse.presences.on('change:show', () => {
-                    this.update();
-                    this.updateFilter();
-                });
-
-                this.listenTo(this.model, "reset", this.reset);
-
-                // 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.
-                api.listen.on('rosterGroupsFetched', this.sortAndPositionAllItems.bind(this));
-
-                api.listen.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({
-                    'allow_contact_requests': _converse.allow_contact_requests,
-                    'heading_contacts': __('Contacts'),
-                    'title_add_contact': __('Add a contact'),
-                    'title_sync_contacts': __('Re-sync your contacts')
-                });
-                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) {
-                api.modal.show(_converse.AddContactModal, {'model': new Model()}, ev);
-            },
-
-            createRosterFilter () {
-                // Create a model on which we can store filter properties
-                const model = new _converse.RosterFilter();
-                model.id = `_converse.rosterfilter-${_converse.bare_jid}`;
-                model.browserStorage = _converse.createStore(model.id);
-                this.filter_view = new _converse.RosterFilterView({model});
-                this.listenTo(this.filter_view.model, 'change', this.updateFilter);
-                this.filter_view.model.fetch();
-            },
-
-            /**
-             * Called whenever the filter settings have been changed or
-             * when contacts have been added, removed or changed.
-             *
-             * Debounced for 100ms so that it doesn't get called for every
-             * contact fetched from browser storage.
-             */
-            updateFilter: debounce(function () {
-                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 () {
-                if (!u.isVisible(this.roster_el)) {
-                    u.showElement(this.roster_el);
-                }
-                this.filter_view.render();
-                return this;
-            },
-
-            filter (query, type) {
-                const views = Object.values(this.getAll());
-                // First ensure the filter is restored to its original state
-                views.forEach(v => (v.model.contacts.length > 0) && v.show().filter(''));
-                // Now we can filter
-                query = query.toLowerCase();
-                if (type === 'groups') {
-                    views.forEach(view => {
-                        if (!view.model.get('name').toLowerCase().includes(query)) {
-                            u.slideIn(view.el);
-                        } else if (view.model.contacts.length > 0) {
-                            u.slideOut(view.el);
-                        }
-                    });
-                } else {
-                    views.forEach(v => v.filter(query, type));
-                }
-            },
-
-            async syncContacts (ev) {
-                ev.preventDefault();
-                u.addClass('fa-spin', ev.target);
-                _converse.roster.data.save('version', null);
-                await _converse.roster.fetchFromServer();
-                api.user.presence.send();
-                u.removeClass('fa-spin', ev.target);
-            },
-
-            reset () {
-                this.removeAll();
-                this.render().update();
-                return this;
-            },
-
-            onContactAdded (contact) {
-                this.addRosterContact(contact)
-                this.update();
-                this.updateFilter();
-            },
-
-            onContactChange (contact) {
-                this.update();
-                if (has(contact.changed, 'subscription')) {
-                    if (contact.changed.subscription === 'from') {
-                        this.addContactToGroup(contact, _converse.HEADER_PENDING_CONTACTS);
-                    } else if (['both', 'to'].includes(contact.get('subscription'))) {
-                        this.addExistingContact(contact);
-                    }
-                }
-                if (has(contact.changed, 'num_unread') && contact.get('num_unread')) {
-                    this.addContactToGroup(contact, _converse.HEADER_UNREAD);
-                }
-                if (has(contact.changed, 'ask') && contact.changed.ask === 'subscribe') {
-                    this.addContactToGroup(contact, _converse.HEADER_PENDING_CONTACTS);
-                }
-                if (has(contact.changed, 'subscription') && contact.changed.requesting === 'true') {
-                    this.addContactToGroup(contact, _converse.HEADER_REQUESTING_CONTACTS);
-                }
-                this.updateFilter();
-            },
-
-            /**
-             * Returns the group as specified by name.
-             * Creates the group if it doesn't exist.
-             * @method _converse.RosterView#getGroup
-             * @private
-             * @param {string} name
-             */
-            getGroup (name) {
-                const view =  this.get(name);
-                if (view) {
-                    return view.model;
-                }
-                return this.model.create({name});
-            },
-
-            addContactToGroup (contact, name, options) {
-                this.getGroup(name).contacts.add(contact, options);
-                this.sortAndPositionAllItems();
-            },
-
-            addExistingContact (contact, options) {
-                let groups;
-                if (api.settings.get('roster_groups')) {
-                    groups = contact.get('groups');
-                    groups = (groups.length === 0) ? [_converse.HEADER_UNGROUPED] : groups;
-                } else {
-                    groups = [_converse.HEADER_CURRENT_CONTACTS];
-                }
-                if (contact.get('num_unread')) {
-                    groups.push(_converse.HEADER_UNREAD);
-                }
-                groups.forEach(g => this.addContactToGroup(contact, g, options));
-            },
-
-            isSelf (jid) {
-                return u.isSameBareJID(jid, _converse.connection.jid);
-            },
-
-            addRosterContact (contact, options) {
-                const jid = contact.get('jid');
-                if (contact.get('subscription') === 'both' || contact.get('subscription') === 'to' || this.isSelf(jid)) {
-                    this.addExistingContact(contact, options);
-                } else {
-                    if (!_converse.allow_contact_requests) {
-                        log.debug(
-                            `Not adding requesting or pending contact ${jid} `+
-                            `because allow_contact_requests is false`
-                        );
-                        return;
-                    }
-                    if ((contact.get('ask') === 'subscribe') || (contact.get('subscription') === 'from')) {
-                        this.addContactToGroup(contact, _converse.HEADER_PENDING_CONTACTS, options);
-                    } else if (contact.get('requesting') === true) {
-                        this.addContactToGroup(contact, _converse.HEADER_REQUESTING_CONTACTS, options);
-                    }
-                }
-                return this;
-            }
-        });
-
-        /* -------- Event Handlers ----------- */
-        api.listen.on('chatBoxesInitialized', () => {
-            function highlightRosterItem (chatbox) {
-                const contact = _converse.roster && _converse.roster.findWhere({'jid': chatbox.get('jid')});
-                if (contact !== undefined) {
-                    contact.trigger('highlight');
-                }
-            }
-            _converse.chatboxes.on('destroy', chatbox => highlightRosterItem(chatbox));
-            _converse.chatboxes.on('change:hidden', chatbox => highlightRosterItem(chatbox));
-        });
-
-
-        api.listen.on('controlBoxInitialized', (view) => {
-            function insertRoster () {
-                if (!view.model.get('connected') || api.settings.get("authentication") === _converse.ANONYMOUS) {
-                    return;
-                }
-                /* Place the rosterview inside the "Contacts" panel. */
-                api.waitUntil('rosterViewInitialized')
-                    .then(() => view.controlbox_pane.el.insertAdjacentElement('beforeEnd', _converse.rosterview.el))
-                    .catch(e => log.fatal(e));
-            }
-            insertRoster();
-            view.model.on('change:connected', insertRoster);
-        });
-
-
-        function initRosterView () {
-            /* Create an instance of RosterView once the RosterGroups
-             * collection has been created (in @converse/headless/core.js)
-             */
-            if (api.settings.get("authentication") === _converse.ANONYMOUS) {
-                return;
-            }
-            _converse.rosterview = new _converse.RosterView({
-                'model': _converse.rostergroups
-            });
-            _converse.rosterview.render();
-            /**
-             * Triggered once the _converse.RosterView instance has been created and initialized.
-             * @event _converse#rosterViewInitialized
-             * @example _converse.api.listen.on('rosterViewInitialized', () => { ... });
-             */
-            api.trigger('rosterViewInitialized');
-        }
-        api.listen.on('rosterInitialized', initRosterView);
-        api.listen.on('rosterReadyAfterReconnection', initRosterView);
-
-        api.listen.on('afterTearDown', () => {
-            if (converse.rosterview) {
-                converse.rosterview.model.off().reset();
-                converse.rosterview.each(groupview => groupview.removeAll().remove());
-                converse.rosterview.removeAll().remove();
-                delete converse.rosterview;
-            }
-        });
-    }
-});

+ 236 - 0
src/plugins/rosterview/contactview.js

@@ -0,0 +1,236 @@
+import ViewWithAvatar from 'shared/avatar.js';
+import log from "@converse/headless/log";
+import tpl_pending_contact from "./templates/pending_contact.html";
+import tpl_requesting_contact from "./templates/requesting_contact.html";
+import tpl_roster_item from "./templates/roster_item.html";
+import { __ } from 'i18n';
+import { _converse, api, converse } from "@converse/headless/core";
+import { debounce, without } from "lodash-es";
+
+const u = converse.env.utils;
+
+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 RosterContactView = ViewWithAvatar.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"
+    },
+
+    async initialize () {
+        await this.model.initialized;
+        this.debouncedRender = debounce(this.render, 50);
+        this.listenTo(this.model, "change", this.debouncedRender);
+        this.listenTo(this.model, "destroy", this.remove);
+        this.listenTo(this.model, "highlight", this.highlight);
+        this.listenTo(this.model, "remove", this.remove);
+        this.listenTo(this.model, 'vcard:change', this.debouncedRender);
+        this.listenTo(this.model.presence, "change:show", this.debouncedRender);
+        this.render();
+    },
+
+    render () {
+        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'),
+            jid = this.model.get('jid');
+
+        const classes_to_remove = [
+            'current-xmpp-contact',
+            'pending-xmpp-contact',
+            'requesting-xmpp-contact'
+            ].concat(Object.keys(STATUSES));
+        classes_to_remove.forEach(c => u.removeClass(c, this.el));
+
+        this.el.classList.add(show);
+        this.el.setAttribute('data-status', show);
+        this.highlight();
+
+        if (_converse.isUniView()) {
+            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(
+                Object.assign(this.model.toJSON(), {
+                    display_name,
+                    'desc_remove': __('Click to remove %1$s as a contact', display_name),
+                    'allow_chat_pending_contacts': api.settings.get('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(
+                Object.assign(this.model.toJSON(), {
+                    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': api.settings.get('allow_chat_pending_contacts')
+                })
+            );
+        } else if (subscription === 'both' || subscription === 'to' || _converse.rosterview.isSelf(jid)) {
+            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;
+    },
+
+    /**
+     * If appropriate, highlight the contact (by adding the 'open' class).
+     * @private
+     * @method _converse.RosterContactView#highlight
+     */
+    highlight () {
+        if (_converse.isUniView()) {
+            const chatbox = _converse.chatboxes.get(this.model.get('jid'));
+            if ((chatbox && chatbox.get('hidden')) || !chatbox) {
+                this.el.classList.remove('open');
+            } else {
+                this.el.classList.add('open');
+            }
+        }
+    },
+
+    renderRosterItem (item) {
+        const show = item.presence.get('show') || 'offline';
+        let status_icon;
+        if (show === 'online') {
+            status_icon = 'fa fa-circle chat-status chat-status--online';
+        } else if (show === 'away') {
+            status_icon = 'fa fa-circle chat-status chat-status--away';
+        } else if (show === 'xa') {
+            status_icon = 'far fa-circle chat-status chat-status-xa';
+        } else if (show === 'dnd') {
+            status_icon = 'fa fa-minus-circle chat-status chat-status--busy';
+        } else {
+            status_icon = 'fa fa-times-circle chat-status chat-status--offline';
+        }
+        const display_name = item.getDisplayName();
+        this.el.innerHTML = tpl_roster_item(
+            Object.assign(item.toJSON(), {
+                show,
+                display_name,
+                status_icon,
+                'desc_status': STATUSES[show],
+                'desc_chat': __('Click to chat with %1$s (XMPP address: %2$s)', display_name, item.get('jid')),
+                'desc_remove': __('Click to remove %1$s as a contact', display_name),
+                'allow_contact_removal': api.settings.get('allow_contact_removal'),
+                'num_unread': item.get('num_unread') || 0,
+                classes: ''
+            })
+        );
+        this.renderAvatar();
+        return this;
+    },
+
+    /**
+     * Returns 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.
+     * @private
+     * @method _converse.RosterContactView#mayBeShown
+     */
+    mayBeShown () {
+        const chatStatus = this.model.presence.get('show');
+        if (api.settings.get('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(); }
+        this.model.openChat();
+    },
+
+    async removeContact (ev) {
+        if (ev && ev.preventDefault) { ev.preventDefault(); }
+        if (!api.settings.get('allow_contact_removal')) { return; }
+        if (!confirm(__("Are you sure you want to remove this contact?"))) { return; }
+
+        try {
+            await this.model.removeFromRoster();
+            this.remove();
+            if (this.model.collection) {
+                // The model might have already been removed as
+                // result of a roster push.
+                this.model.destroy();
+            }
+        } catch (e) {
+            log.error(e);
+            api.alert('error', __('Error'),
+                [__('Sorry, there was an error while trying to remove %1$s as a contact.', this.model.getDisplayName())]
+            );
+        }
+    },
+
+    async acceptRequest (ev) {
+        if (ev && ev.preventDefault) { ev.preventDefault(); }
+
+        await _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;
+    }
+});
+
+export default RosterContactView;

+ 100 - 0
src/plugins/rosterview/filterview.js

@@ -0,0 +1,100 @@
+import tpl_roster_filter from "./templates/roster_filter.js";
+import { Model } from '@converse/skeletor/src/model.js';
+import { View } from '@converse/skeletor/src/view.js';
+import { __ } from 'i18n';
+import { _converse } from "@converse/headless/core";
+import { debounce } from "lodash-es";
+
+
+export const RosterFilter = Model.extend({
+    initialize () {
+        this.set({
+            'filter_text': '',
+            'filter_type': 'contacts',
+            'chat_state': 'online'
+        });
+    },
+});
+
+
+export const RosterFilterView = View.extend({
+    tagName: 'span',
+
+    initialize () {
+        this.listenTo(this.model, 'change:filter_type', this.render);
+        this.listenTo(this.model, 'change:filter_text', this.render);
+    },
+
+    toHTML () {
+        return tpl_roster_filter(
+            Object.assign(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 => this.changeChatStateFilter(ev),
+                changeTypeFilter: ev => this.changeTypeFilter(ev),
+                clearFilter: ev => this.clearFilter(ev),
+                liveFilter: ev => this.liveFilter(ev),
+                submitFilter: ev => this.submitFilter(ev),
+            }));
+    },
+
+    changeChatStateFilter (ev) {
+        ev && ev.preventDefault();
+        this.model.save({'chat_state': this.el.querySelector('.state-type').value});
+    },
+
+    changeTypeFilter (ev) {
+        ev && 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 () {
+        this.model.save({'filter_text': this.el.querySelector('.roster-filter').value});
+    }, 250),
+
+    submitFilter (ev) {
+        ev && ev.preventDefault();
+        this.liveFilter();
+    },
+
+    /**
+     * Returns true if the filter is enabled (i.e. if the user
+     * has added values to the filter).
+     * @private
+     * @method _converse.RosterFilterView#isActive
+     */
+    isActive () {
+        return (this.model.get('filter_type') === 'state' || this.model.get('filter_text'));
+    },
+
+    shouldBeVisible () {
+        return _converse.roster && _converse.roster.length >= 5 || this.isActive();
+    },
+
+    clearFilter (ev) {
+        ev && ev.preventDefault();
+        this.model.save({'filter_text': ''});
+    }
+});

+ 209 - 0
src/plugins/rosterview/groupview.js

@@ -0,0 +1,209 @@
+import RosterContactView from './contactview.js';
+import tpl_group_header from "./templates/group_header.html";
+import { OrderedListView } from "@converse/skeletor/src/overview";
+import { _converse, converse } from "@converse/headless/core";
+
+const u = converse.env.utils;
+
+/**
+ * @class
+ * @namespace _converse.RosterGroupView
+ * @memberOf _converse
+ */
+const RosterGroupView = OrderedListView.extend({
+    tagName: 'div',
+    className: 'roster-group hidden',
+    events: {
+        "click a.group-toggle": "toggle"
+    },
+
+    sortImmediatelyOnAdd: true,
+    ItemView: RosterContactView,
+    listItems: 'model.contacts',
+    listSelector: '.roster-group-contacts',
+    sortEvent: 'presenceChanged',
+
+    initialize () {
+        OrderedListView.prototype.initialize.apply(this, arguments);
+
+        if (this.model.get('name') === _converse.HEADER_UNREAD) {
+            this.listenTo(this.model.contacts, "change:num_unread",
+                c => !this.model.get('unread_messages') && this.removeContact(c)
+            );
+        }
+        if (this.model.get('name') === _converse.HEADER_REQUESTING_CONTACTS) {
+            this.listenTo(this.model.contacts, "change:requesting",
+                c => !c.get('requesting') && this.removeContact(c)
+            );
+        }
+        if (this.model.get('name') === _converse.HEADER_PENDING_CONTACTS) {
+            this.listenTo(this.model.contacts, "change:subscription",
+                c => (c.get('subscription') !== 'from') && this.removeContact(c)
+            );
+        }
+
+        this.listenTo(this.model.contacts, "remove", this.onRemove);
+        this.listenTo(_converse.roster, 'change:groups', this.onContactGroupChange);
+
+        // 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()
+        );
+    },
+
+    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);
+        if (this.model.get('state') === _converse.OPENED) {
+            Object.values(this.getAll())
+                .filter(v => v.mayBeShown())
+                .forEach(v => u.showElement(v.el));
+        }
+        return this;
+    },
+
+    collapse () {
+        return u.slideIn(this.contacts_el);
+    },
+
+    /* 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.
+     * @private
+     * @method _converse.RosterGroupView#filterOutContacts
+     * @param { Array } contacts
+     */
+    filterOutContacts (contacts=[]) {
+        let shown = 0;
+        this.model.contacts.forEach(contact => {
+            const contact_view = this.get(contact.get('id'));
+            if (contacts.includes(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);
+        }
+    },
+
+    /**
+     * Given the filter query "q" and the filter type "type",
+     * return a list of contacts that need to be filtered out.
+     * @private
+     * @method _converse.RosterGroupView#getFilterMatches
+     * @param { String } q - The filter query
+     * @param { String } type - The filter type
+     */
+    getFilterMatches (q, type) {
+        if (q.length === 0) {
+            return [];
+        }
+        q = q.toLowerCase();
+        const contacts = this.model.contacts;
+        if (type === 'state') {
+            const sticky_groups = [_converse.HEADER_REQUESTING_CONTACTS, _converse.HEADER_UNREAD];
+            if (sticky_groups.includes(this.model.get('name'))) {
+                // When filtering by chat state, we still want to
+                // show sticky groups, even though they don't
+                // match the state in question.
+                return [];
+            } else if (q === 'unread_messages') {
+                return contacts.filter({'num_unread': 0});
+            } else if (q === 'online') {
+                return contacts.filter(c => ["offline", "unavailable"].includes(c.presence.get('show')));
+            } else {
+                return contacts.filter(c => !c.presence.get('show').includes(q));
+            }
+        } else  {
+            return contacts.filter(c => !c.getFilterCriteria().includes(q));
+        }
+    },
+
+    /**
+     * 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.
+     * @private
+     * @method _converse.RosterGroupView#filter
+     * @param { string } q - The query to filter against
+     * @param { string } type
+     */
+    filter (q, type) {
+        if (q === null || q === undefined) {
+            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));
+    },
+
+    async toggle (ev) {
+        if (ev && ev.preventDefault) { ev.preventDefault(); }
+        const icon_el = ev.target.matches('.fa') ? ev.target : ev.target.querySelector('.fa');
+        if (u.hasClass("fa-caret-down", icon_el)) {
+            this.model.save({state: _converse.CLOSED});
+            await this.collapse();
+            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 = contact.get('groups').includes(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);
+        }
+    },
+
+    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();
+        }
+    }
+});
+
+export default RosterGroupView;

+ 101 - 0
src/plugins/rosterview/index.js

@@ -0,0 +1,101 @@
+/**
+ * @module converse-rosterview
+ * @copyright 2020, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import "../modal";
+import "@converse/headless/plugins/chatboxes";
+import "@converse/headless/plugins/roster";
+import "modals/add-contact.js";
+import RosterContactView from './contactview.js';
+import RosterGroupView from './groupview.js';
+import RosterView from './rosterview.js';
+import log from "@converse/headless/log";
+import { RosterFilter, RosterFilterView } from './filterview.js';
+import { _converse, api, converse } from "@converse/headless/core";
+
+
+converse.plugins.add('converse-rosterview', {
+
+    dependencies: ["converse-roster", "converse-modal", "converse-chatboxviews"],
+
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+
+        api.settings.extend({
+            'autocomplete_add_contact': true,
+            'allow_chat_pending_contacts': true,
+            'allow_contact_removal': true,
+            'hide_offline_users': false,
+            'roster_groups': true,
+            'xhr_user_search_url': null,
+        });
+        api.promises.add('rosterViewInitialized');
+
+        _converse.RosterFilter = RosterFilter;
+        _converse.RosterFilterView = RosterFilterView;
+        _converse.RosterContactView = RosterContactView;
+        _converse.RosterGroupView = RosterGroupView;
+        _converse.RosterView = RosterView;
+
+        /* -------- Event Handlers ----------- */
+        api.listen.on('chatBoxesInitialized', () => {
+            function highlightRosterItem (chatbox) {
+                const contact = _converse.roster && _converse.roster.findWhere({'jid': chatbox.get('jid')});
+                if (contact !== undefined) {
+                    contact.trigger('highlight');
+                }
+            }
+            _converse.chatboxes.on('destroy', chatbox => highlightRosterItem(chatbox));
+            _converse.chatboxes.on('change:hidden', chatbox => highlightRosterItem(chatbox));
+        });
+
+
+        api.listen.on('controlBoxInitialized', (view) => {
+            function insertRoster () {
+                if (!view.model.get('connected') || api.settings.get("authentication") === _converse.ANONYMOUS) {
+                    return;
+                }
+                /* Place the rosterview inside the "Contacts" panel. */
+                api.waitUntil('rosterViewInitialized')
+                    .then(() => view.controlbox_pane.el.insertAdjacentElement('beforeEnd', _converse.rosterview.el))
+                    .catch(e => log.fatal(e));
+            }
+            insertRoster();
+            view.model.on('change:connected', insertRoster);
+        });
+
+
+        function initRosterView () {
+            /* Create an instance of RosterView once the RosterGroups
+             * collection has been created (in @converse/headless/core.js)
+             */
+            if (api.settings.get("authentication") === _converse.ANONYMOUS) {
+                return;
+            }
+            _converse.rosterview = new _converse.RosterView({
+                'model': _converse.rostergroups
+            });
+            _converse.rosterview.render();
+            /**
+             * Triggered once the _converse.RosterView instance has been created and initialized.
+             * @event _converse#rosterViewInitialized
+             * @example _converse.api.listen.on('rosterViewInitialized', () => { ... });
+             */
+            api.trigger('rosterViewInitialized');
+        }
+        api.listen.on('rosterInitialized', initRosterView);
+        api.listen.on('rosterReadyAfterReconnection', initRosterView);
+
+        api.listen.on('afterTearDown', () => {
+            if (converse.rosterview) {
+                converse.rosterview.model.off().reset();
+                converse.rosterview.each(groupview => groupview.removeAll().remove());
+                converse.rosterview.removeAll().remove();
+                delete converse.rosterview;
+            }
+        });
+    }
+});

+ 238 - 0
src/plugins/rosterview/rosterview.js

@@ -0,0 +1,238 @@
+import RosterGroupView from './groupview.js';
+import log from "@converse/headless/log";
+import tpl_roster from "./templates/roster.html";
+import { Model } from '@converse/skeletor/src/model.js';
+import { OrderedListView } from "@converse/skeletor/src/overview";
+import { __ } from 'i18n';
+import { _converse, api, converse } from "@converse/headless/core";
+import { debounce, has } from "lodash-es";
+
+const u = converse.env.utils;
+
+
+/**
+ * @class
+ * @namespace _converse.RosterView
+ * @memberOf _converse
+ */
+const RosterView = OrderedListView.extend({
+    tagName: 'div',
+    id: 'converse-roster',
+    className: 'controlbox-section',
+
+    ItemView: RosterGroupView,
+    listItems: 'model',
+    listSelector: '.roster-contacts',
+    sortEvent: null, // Groups are immutable, so they don't get re-sorted
+    subviewIndex: 'name',
+    sortImmediatelyOnAdd: true,
+
+    events: {
+        'click a.controlbox-heading__btn.add-contact': 'showAddContactModal',
+        'click a.controlbox-heading__btn.sync-contacts': 'syncContacts'
+    },
+
+    initialize () {
+        OrderedListView.prototype.initialize.apply(this, arguments);
+
+        this.listenTo(_converse.roster, "add", this.onContactAdded);
+        this.listenTo(_converse.roster, 'change:groups', this.onContactAdded);
+        this.listenTo(_converse.roster, 'change', this.onContactChange);
+        this.listenTo(_converse.roster, "destroy", this.update);
+        this.listenTo(_converse.roster, "remove", this.update);
+        _converse.presences.on('change:show', () => {
+            this.update();
+            this.updateFilter();
+        });
+
+        this.listenTo(this.model, "reset", this.reset);
+
+        // 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.
+        api.listen.on('rosterGroupsFetched', this.sortAndPositionAllItems.bind(this));
+
+        api.listen.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({
+            'allow_contact_requests': _converse.allow_contact_requests,
+            'heading_contacts': __('Contacts'),
+            'title_add_contact': __('Add a contact'),
+            'title_sync_contacts': __('Re-sync your contacts')
+        });
+        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) {
+        api.modal.show(_converse.AddContactModal, {'model': new Model()}, ev);
+    },
+
+    createRosterFilter () {
+        // Create a model on which we can store filter properties
+        const model = new _converse.RosterFilter();
+        model.id = `_converse.rosterfilter-${_converse.bare_jid}`;
+        model.browserStorage = _converse.createStore(model.id);
+        this.filter_view = new _converse.RosterFilterView({model});
+        this.listenTo(this.filter_view.model, 'change', this.updateFilter);
+        this.filter_view.model.fetch();
+    },
+
+    /**
+     * Called whenever the filter settings have been changed or
+     * when contacts have been added, removed or changed.
+     *
+     * Debounced for 100ms so that it doesn't get called for every
+     * contact fetched from browser storage.
+     */
+    updateFilter: debounce(function () {
+        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 () {
+        if (!u.isVisible(this.roster_el)) {
+            u.showElement(this.roster_el);
+        }
+        this.filter_view.render();
+        return this;
+    },
+
+    filter (query, type) {
+        const views = Object.values(this.getAll());
+        // First ensure the filter is restored to its original state
+        views.forEach(v => (v.model.contacts.length > 0) && v.show().filter(''));
+        // Now we can filter
+        query = query.toLowerCase();
+        if (type === 'groups') {
+            views.forEach(view => {
+                if (!view.model.get('name').toLowerCase().includes(query)) {
+                    u.slideIn(view.el);
+                } else if (view.model.contacts.length > 0) {
+                    u.slideOut(view.el);
+                }
+            });
+        } else {
+            views.forEach(v => v.filter(query, type));
+        }
+    },
+
+    async syncContacts (ev) {
+        ev.preventDefault();
+        u.addClass('fa-spin', ev.target);
+        _converse.roster.data.save('version', null);
+        await _converse.roster.fetchFromServer();
+        api.user.presence.send();
+        u.removeClass('fa-spin', ev.target);
+    },
+
+    reset () {
+        this.removeAll();
+        this.render().update();
+        return this;
+    },
+
+    onContactAdded (contact) {
+        this.addRosterContact(contact)
+        this.update();
+        this.updateFilter();
+    },
+
+    onContactChange (contact) {
+        this.update();
+        if (has(contact.changed, 'subscription')) {
+            if (contact.changed.subscription === 'from') {
+                this.addContactToGroup(contact, _converse.HEADER_PENDING_CONTACTS);
+            } else if (['both', 'to'].includes(contact.get('subscription'))) {
+                this.addExistingContact(contact);
+            }
+        }
+        if (has(contact.changed, 'num_unread') && contact.get('num_unread')) {
+            this.addContactToGroup(contact, _converse.HEADER_UNREAD);
+        }
+        if (has(contact.changed, 'ask') && contact.changed.ask === 'subscribe') {
+            this.addContactToGroup(contact, _converse.HEADER_PENDING_CONTACTS);
+        }
+        if (has(contact.changed, 'subscription') && contact.changed.requesting === 'true') {
+            this.addContactToGroup(contact, _converse.HEADER_REQUESTING_CONTACTS);
+        }
+        this.updateFilter();
+    },
+
+    /**
+     * Returns the group as specified by name.
+     * Creates the group if it doesn't exist.
+     * @method _converse.RosterView#getGroup
+     * @private
+     * @param {string} name
+     */
+    getGroup (name) {
+        const view =  this.get(name);
+        if (view) {
+            return view.model;
+        }
+        return this.model.create({name});
+    },
+
+    addContactToGroup (contact, name, options) {
+        this.getGroup(name).contacts.add(contact, options);
+        this.sortAndPositionAllItems();
+    },
+
+    addExistingContact (contact, options) {
+        let groups;
+        if (api.settings.get('roster_groups')) {
+            groups = contact.get('groups');
+            groups = (groups.length === 0) ? [_converse.HEADER_UNGROUPED] : groups;
+        } else {
+            groups = [_converse.HEADER_CURRENT_CONTACTS];
+        }
+        if (contact.get('num_unread')) {
+            groups.push(_converse.HEADER_UNREAD);
+        }
+        groups.forEach(g => this.addContactToGroup(contact, g, options));
+    },
+
+    isSelf (jid) {
+        return u.isSameBareJID(jid, _converse.connection.jid);
+    },
+
+    addRosterContact (contact, options) {
+        const jid = contact.get('jid');
+        if (contact.get('subscription') === 'both' || contact.get('subscription') === 'to' || this.isSelf(jid)) {
+            this.addExistingContact(contact, options);
+        } else {
+            if (!_converse.allow_contact_requests) {
+                log.debug(
+                    `Not adding requesting or pending contact ${jid} `+
+                    `because allow_contact_requests is false`
+                );
+                return;
+            }
+            if ((contact.get('ask') === 'subscribe') || (contact.get('subscription') === 'from')) {
+                this.addContactToGroup(contact, _converse.HEADER_PENDING_CONTACTS, options);
+            } else if (contact.get('requesting') === true) {
+                this.addContactToGroup(contact, _converse.HEADER_REQUESTING_CONTACTS, options);
+            }
+        }
+        return this;
+    }
+});
+
+
+export default RosterView;

+ 0 - 0
src/templates/group_header.html → src/plugins/rosterview/templates/group_header.html


+ 0 - 0
src/templates/pending_contact.html → src/plugins/rosterview/templates/pending_contact.html


+ 0 - 0
src/templates/requesting_contact.html → src/plugins/rosterview/templates/requesting_contact.html


+ 0 - 0
src/templates/roster.html → src/plugins/rosterview/templates/roster.html


+ 0 - 0
src/templates/roster_filter.js → src/plugins/rosterview/templates/roster_filter.js


+ 0 - 0
src/templates/roster_item.html → src/plugins/rosterview/templates/roster_item.html


+ 4 - 3
src/shared/avatar.js

@@ -1,9 +1,10 @@
 import tpl_avatar from 'templates/avatar.js';
+import { View } from '@converse/skeletor/src/view';
 import { converse } from '@converse/headless/core';
 
 const u = converse.env.utils;
 
-const AvatarMixin = {
+const ViewWithAvatar = View.extend({
     renderAvatar (el) {
         el = el || this.el;
         const avatar_el = el.querySelector('canvas.avatar, svg.avatar');
@@ -21,6 +22,6 @@ const AvatarMixin = {
             avatar_el.outerHTML = u.getElementFromTemplateResult(tpl_avatar(data)).outerHTML;
         }
     }
-};
+});
 
-export default AvatarMixin;
+export default ViewWithAvatar;