Browse Source

Add an occupants filter to the MUC sidebar

Adds a new component `converse-contacts-filter` which is used to filter
both roster contacts as well as MUC occupants.
JC Brand 1 year ago
parent
commit
1f588943cd

+ 1 - 0
CHANGES.md

@@ -3,6 +3,7 @@
 ## 11.0.0 (Unreleased)
 
 - #2716: Fix issue with chat display when opening via URL
+- Add an occupants filter to the MUC sidebar
 
 ### Breaking changes:
 

+ 1 - 0
karma.conf.js

@@ -92,6 +92,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/muc-views/tests/muc-registration.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/muc.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/nickname.js", type: 'module' },
+      { pattern: "src/plugins/muc-views/tests/occupants-filter.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/occupants.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/rai.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/retractions.js", type: 'module' },

+ 1 - 0
src/headless/shared/api/index.js

@@ -28,6 +28,7 @@ const api = {
     ...promise_api,
 
     disco: null,
+    elements: null,
 };
 
 export default api;

+ 10 - 4
src/plugins/muc-views/sidebar.js

@@ -2,6 +2,8 @@ import 'shared/autocomplete/index.js';
 import tplMUCSidebar from "./templates/muc-sidebar.js";
 import { CustomElement } from 'shared/components/element.js';
 import { _converse, api, converse } from "@converse/headless";
+import { RosterFilter } from 'headless/plugins/roster/filter.js';
+import { initStorage } from "headless/utils/storage";
 
 import 'shared/styles/status.scss';
 import './styles/muc-occupants.scss';
@@ -16,8 +18,13 @@ export default class MUCSidebar extends CustomElement {
         }
     }
 
-    connectedCallback () {
-        super.connectedCallback();
+    initialize() {
+        const filter_id = `_converse.occupants-filter-${this.jid}`;
+        this.filter = new RosterFilter();
+        this.filter.id = filter_id;
+        initStorage(this.filter, filter_id);
+        this.filter.fetch();
+
         this.model = _converse.chatboxes.get(this.jid);
         this.listenTo(this.model.occupants, 'add', () => this.requestUpdate());
         this.listenTo(this.model.occupants, 'remove', () => this.requestUpdate());
@@ -28,10 +35,9 @@ export default class MUCSidebar extends CustomElement {
     }
 
     render () {
-        const tpl = tplMUCSidebar(Object.assign(
+        const tpl = tplMUCSidebar(this, Object.assign(
             this.model.toJSON(), {
                 'occupants': [...this.model.occupants.models],
-                'closeSidebar': ev => this.closeSidebar(ev),
                 'onOccupantClicked': ev => this.onOccupantClicked(ev),
             }
         ));

+ 33 - 3
src/plugins/muc-views/templates/muc-sidebar.js

@@ -1,21 +1,51 @@
+import 'shared/components/contacts-filter.js';
 import tplOccupant from "./occupant.js";
+import tplOccupantsFilter from './occupants-filter.js';
 import { __ } from 'i18n';
 import { html } from "lit";
 import { repeat } from 'lit/directives/repeat.js';
 
+function isOccupantFiltered (el, occ) {
+    const type = el.filter.get('filter_type');
+    const q = (type === 'state') ?
+        el.filter.get('chat_state').toLowerCase() :
+        el.filter.get('filter_text').toLowerCase();
 
-export default (o) => {
+    if (!q) return false;
+
+    if (type === 'state') {
+        const show = occ.get('show');
+        return q === 'online' ? ["offline", "unavailable"].includes(show) : !show.includes(q);
+    } else if (type === 'contacts')  {
+        return !occ.getDisplayName().toLowerCase().includes(q);
+    }
+}
+
+function shouldShowOccupant (el, occ, o) {
+    return isOccupantFiltered(el, occ) ? '' : tplOccupant(occ, o);
+}
+
+export default (el, o) => {
     const i18n_participants = o.occupants.length === 1 ? __('Participant') : __('Participants');
     return html`
         <div class="occupants-header">
             <div class="occupants-header--title">
                 <span class="occupants-heading">${o.occupants.length} ${i18n_participants}</span>
-                <i class="hide-occupants" @click=${o.closeSidebar}>
+                <i class="hide-occupants" @click=${ev => el.closeSidebar(ev)}>
                     <converse-icon class="fa fa-times" size="1em"></converse-icon>
                 </i>
             </div>
         </div>
         <div class="dragresize dragresize-occupants-left"></div>
-        <ul class="occupant-list">${ repeat(o.occupants, (occ) => occ.get('jid'), (occ) => tplOccupant(occ, o)) }</ul>
+        <ul class="occupant-list">
+            <converse-contacts-filter
+                    @update=${() => el.requestUpdate()}
+                    .promise=${el.model.initialized}
+                    .contacts=${el.model.occupants}
+                    .template=${tplOccupantsFilter}
+                    .filter=${el.filter}></converse-contacts-filter>
+
+            ${ repeat(o.occupants, (occ) => occ.get('jid'), (occ) => shouldShowOccupant(el, occ, o)) }
+        </ul>
     `;
 }

+ 63 - 0
src/plugins/muc-views/templates/occupants-filter.js

@@ -0,0 +1,63 @@
+import { html } from "lit";
+import { __ } from 'i18n';
+
+/**
+ * @param {import('shared/components/contacts-filter').ContactsFilter} el
+ */
+export default (el) => {
+    const i18n_placeholder = __('Filter');
+    const title_contact_filter = __('Filter by name');
+    const title_status_filter = __('Filter by status');
+    const label_any = __('Any');
+    const label_online = __('Online');
+    const label_chatty = __('Chatty');
+    const label_busy = __('Busy');
+    const label_away = __('Away');
+    const label_xa = __('Extended Away');
+    const label_offline = __('Offline');
+
+    const chat_state = el.filter.get('chat_state');
+    const filter_text = el.filter.get('filter_text');
+    const filter_type = el.filter.get('filter_type');
+
+    return html`
+        <form class="contacts-filter-form input-button-group ${ (!el.shouldBeVisible()) ? 'hidden' : 'fade-in' }"
+              @submit=${ev => el.submitFilter(ev)}>
+            <div class="form-inline flex-nowrap">
+                <div class="filter-by d-flex flex-nowrap">
+                    <converse-icon
+                            size="1em"
+                            @click=${ev => el.changeTypeFilter(ev)}
+                            class="fa fa-user clickable ${ (filter_type === 'contacts') ? 'selected' : '' }"
+                            data-type="contacts"
+                            title="${title_contact_filter}"></converse-icon>
+                    <converse-icon
+                            size="1em"
+                            @click=${ev => el.changeTypeFilter(ev)}
+                            class="fa fa-circle clickable ${ (filter_type === 'state') ? 'selected' : '' }"
+                            data-type="state"
+                            title="${title_status_filter}"></converse-icon>
+                </div>
+                <div class="btn-group">
+                    <input .value="${filter_text || ''}"
+                        @keydown=${ev => el.liveFilter(ev)}
+                        class="contacts-filter form-control ${ (filter_type === 'state') ? 'hidden' : '' }"
+                        placeholder="${i18n_placeholder}"/>
+                    <converse-icon size="1em"
+                                   class="fa fa-times clear-input ${ (!filter_text || filter_type === 'state') ? 'hidden' : '' }"
+                                   @click=${ev => el.clearFilter(ev)}>
+                    </converse-icon>
+                </div>
+                <select class="form-control state-type ${ (filter_type !== 'state') ? 'hidden' : '' }"
+                        @change=${ev => el.changeChatStateFilter(ev)}>
+                    <option value="">${label_any}</option>
+                    <option ?selected=${chat_state === 'online'} value="online">${label_online}</option>
+                    <option ?selected=${chat_state === 'chat'} value="chat">${label_chatty}</option>
+                    <option ?selected=${chat_state === 'dnd'} value="dnd">${label_busy}</option>
+                    <option ?selected=${chat_state === 'away'} value="away">${label_away}</option>
+                    <option ?selected=${chat_state === 'xa'} value="xa">${label_xa}</option>
+                    <option ?selected=${chat_state === 'offline'} value="offline">${label_offline}</option>
+                </select>
+            </div>
+        </form>`
+};

+ 3 - 3
src/plugins/muc-views/tests/nickname.js

@@ -122,8 +122,8 @@ describe("A MUC", function () {
         const view = _converse.chatboxviews.get('lounge@montague.lit');
         await u.waitUntil(() => view.querySelectorAll('li .occupant-nick').length, 500);
         let occupants = view.querySelector('.occupant-list');
-        expect(occupants.childElementCount).toBe(1);
-        expect(occupants.firstElementChild.querySelector('.occupant-nick').textContent.trim()).toBe("oldnick");
+        expect(occupants.querySelectorAll('.occupant-nick').length).toBe(1);
+        expect(occupants.querySelector('.occupant-nick').textContent.trim()).toBe("oldnick");
 
         const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
         expect(csntext.trim()).toEqual("oldnick has entered the groupchat");
@@ -153,7 +153,7 @@ describe("A MUC", function () {
         expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED);
 
         occupants = view.querySelector('.occupant-list');
-        expect(occupants.childElementCount).toBe(1);
+        expect(occupants.querySelectorAll('.occupant-nick').length).toBe(1);
 
         presence = $pres().attrs({
                 from:'lounge@montague.lit/newnick',

+ 75 - 0
src/plugins/muc-views/tests/occupants-filter.js

@@ -0,0 +1,75 @@
+/* global mock, converse */
+
+const { $pres, u } = converse.env;
+
+describe("The MUC occupants filter", function () {
+
+    fit("can be used to filter which occupants are shown",
+        mock.initConverse(
+            [], {},
+            async function (_converse) {
+
+        const muc_jid = 'lounge@montague.lit'
+        const members = [{
+            'nick': 'juliet',
+            'jid': 'juliet@capulet.lit',
+            'affiliation': 'member'
+        }, {
+            'nick': 'tybalt',
+            'jid': 'tybalt@capulet.lit',
+            'affiliation': 'member'
+        }];
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
+        const view = _converse.chatboxviews.get(muc_jid);
+        await u.waitUntil(() => view.model.occupants.length === 3);
+
+        let filter_el = view.querySelector('converse-contacts-filter');
+        expect(u.isVisible(filter_el.firstElementChild)).toBe(false);
+
+        for (let i=0; i<mock.chatroom_names.length; i++) {
+            const name = mock.chatroom_names[i];
+            const role = mock.chatroom_roles[name].role;
+            // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres
+            const presence = $pres({
+                    to:'romeo@montague.lit/pda',
+                    from:'lounge@montague.lit/'+name
+            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+            .c('item').attrs({
+                affiliation: mock.chatroom_roles[name].affiliation,
+                jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+                role: role
+            });
+            _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
+        }
+
+        const occupants = view.querySelector('.occupant-list');
+        await u.waitUntil(() => occupants.querySelectorAll('li').length > 3);
+        expect(occupants.querySelectorAll('li').length).toBe(3+mock.chatroom_names.length);
+        expect(view.model.occupants.length).toBe(3+mock.chatroom_names.length);
+
+        mock.chatroom_names.forEach(name => {
+            const model = view.model.occupants.findWhere({'nick': name});
+            const index = view.model.occupants.indexOf(model);
+            expect(occupants.querySelectorAll('li .occupant-nick')[index].textContent.trim()).toBe(name);
+        });
+
+        filter_el = view.querySelector('converse-contacts-filter');
+        expect(u.isVisible(filter_el.firstElementChild)).toBe(true);
+
+        const filter = view.querySelector('.contacts-filter');
+        filter.value = "j";
+        u.triggerEvent(filter, "keydown", "KeyboardEvent");
+        await u.waitUntil(() => [...view.querySelectorAll('li')].filter(u.isVisible).length === 1);
+
+        filter_el.querySelector('.fa-times').click();
+        await u.waitUntil(() => [...view.querySelectorAll('li')].filter(u.isVisible).length === 3+mock.chatroom_names.length);
+
+        filter_el.querySelector('.fa-circle').click();
+        const state_select = view.querySelector('.state-type');
+        state_select.value = "dnd";
+        u.triggerEvent(state_select, 'change');
+        expect(state_select.value).toBe('dnd');
+        expect(state_select.options[state_select.selectedIndex].textContent).toBe('Busy');
+        await u.waitUntil(() => [...view.querySelectorAll('li')].filter(u.isVisible).length === 0);
+    }));
+});

+ 1 - 1
src/plugins/muc-views/tests/occupants.js

@@ -17,7 +17,6 @@ describe("The occupants sidebar", function () {
         const view = _converse.chatboxviews.get(muc_jid);
         await u.waitUntil(() => view.model.occupants.length === 2);
 
-        const occupants = view.querySelector('.occupant-list');
         for (let i=0; i<mock.chatroom_names.length; i++) {
             const name = mock.chatroom_names[i];
             const role = mock.chatroom_roles[name].role;
@@ -34,6 +33,7 @@ describe("The occupants sidebar", function () {
             _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
         }
 
+        const occupants = view.querySelector('.occupant-list');
         await u.waitUntil(() => occupants.querySelectorAll('li').length > 2, 500);
         expect(occupants.querySelectorAll('li').length).toBe(2+mock.chatroom_names.length);
         expect(view.model.occupants.length).toBe(2+mock.chatroom_names.length);

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

@@ -1,92 +0,0 @@
-import debounce from "lodash-es/debounce";
-import tplRosterFilter from "./templates/roster_filter.js";
-import { CustomElement } from 'shared/components/element.js';
-import { _converse, api } from "@converse/headless";
-import { ancestor } from 'utils/html.js';
-
-
-export class RosterFilterView extends CustomElement {
-
-    async initialize () {
-        await api.waitUntil('rosterInitialized')
-        this.model = _converse.roster_filter;
-
-        this.liveFilter = debounce(() => {
-            this.model.save({'filter_text': this.querySelector('.roster-filter').value});
-        }, 250);
-
-        this.listenTo(_converse, 'rosterContactsFetched', () => this.requestUpdate());
-        this.listenTo(_converse.presences, 'change:show', () => this.requestUpdate());
-        this.listenTo(_converse.roster, "add", () => this.requestUpdate());
-        this.listenTo(_converse.roster, "destroy", () => this.requestUpdate());
-        this.listenTo(_converse.roster, "remove", () => this.requestUpdate());
-        this.listenTo(this.model, 'change', this.dispatchUpdateEvent);
-        this.listenTo(this.model, 'change', () => this.requestUpdate());
-
-        this.requestUpdate();
-    }
-
-    render () {
-        return this.model ?
-        tplRosterFilter(
-            Object.assign(this.model.toJSON(), {
-                visible: this.shouldBeVisible(),
-                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),
-            })) : '';
-    }
-
-    dispatchUpdateEvent () {
-        this.dispatchEvent(new CustomEvent('update', { 'detail': this.model.changed }));
-    }
-
-    changeChatStateFilter (ev) {
-        ev && ev.preventDefault();
-        this.model.save({'chat_state': this.querySelector('.state-type').value});
-    }
-
-    changeTypeFilter (ev) {
-        ev && ev.preventDefault();
-        const type = ancestor(ev.target, 'converse-icon')?.dataset.type || 'contacts';
-        if (type === 'state') {
-            this.model.save({
-                'filter_type': type,
-                'chat_state': this.querySelector('.state-type').value
-            });
-        } else {
-            this.model.save({
-                'filter_type': type,
-                'filter_text': this.querySelector('.roster-filter').value
-            });
-        }
-    }
-
-    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?.length >= 5 || this.isActive();
-    }
-
-    clearFilter (ev) {
-        ev && ev.preventDefault();
-        this.model.save({'filter_text': ''});
-    }
-}
-
-api.elements.define('converse-roster-filter', RosterFilterView);

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

@@ -9,7 +9,6 @@ import "./modals/add-contact.js";
 import './rosterview.js';
 import RosterContactView from './contactview.js';
 import { RosterFilter } from '@converse/headless/plugins/roster/filter.js';
-import { RosterFilterView } from './filterview.js';
 import { _converse, api, converse } from "@converse/headless";
 import { highlightRosterItem } from './utils.js';
 
@@ -32,7 +31,6 @@ converse.plugins.add('converse-rosterview', {
         api.promises.add('rosterViewInitialized');
 
         _converse.RosterFilter = RosterFilter;
-        _converse.RosterFilterView = RosterFilterView;
         _converse.RosterContactView = RosterContactView;
 
         /* -------- Event Handlers ----------- */

+ 0 - 23
src/plugins/rosterview/styles/roster.scss

@@ -39,29 +39,6 @@
             }
         }
 
-        .roster-filter-form {
-            width: 100%;
-
-            .button-group {
-                padding: 0.2em;
-            }
-
-            converse-icon {
-                padding: 0.25em;
-            }
-
-            .roster-filter {
-                width: 100%;
-                margin: 0.2em;
-                font-size: calc(var(--font-size) - 2px);
-            }
-
-            .state-type {
-                font-size: calc(var(--font-size) - 2px);
-                width: 100%;
-            }
-        }
-
         .roster-contacts {
             padding: 0;
             margin: 0 0 0.2em 0;

+ 8 - 1
src/plugins/rosterview/templates/roster.js

@@ -1,4 +1,5 @@
 import tplGroup from "./group.js";
+import tplRosterFilter from "./roster_filter.js";
 import { __ } from 'i18n';
 import { _converse, api } from "@converse/headless";
 import { contactsComparator, groupsComparator } from '@converse/headless/plugins/roster/utils.js';
@@ -46,7 +47,13 @@ export default (el) => {
         </div>
 
         <div class="list-container roster-contacts ${ is_closed ? 'hidden' : '' }">
-            <converse-roster-filter @update=${() => el.requestUpdate()}></converse-roster-filter>
+            <converse-contacts-filter
+                    @update=${() => el.requestUpdate()}
+                    .promise=${api.waitUntil('rosterInitialized')}
+                    .contacts=${_converse.roster}
+                    .template=${tplRosterFilter}
+                    .filter=${_converse.roster_filter}></converse-contacts-filter>
+
             ${ repeat(groupnames, (n) => n, (name) => {
                 const contacts = contacts_map[name].filter(c => shouldShowContact(c, name));
                 contacts.sort(contactsComparator);

+ 28 - 21
src/plugins/rosterview/templates/roster_filter.js

@@ -1,8 +1,10 @@
 import { html } from "lit";
 import { __ } from 'i18n';
 
-
-export default (o) => {
+/**
+ * @param {import('shared/components/contacts-filter').ContactsFilter} el
+ */
+export default (el) => {
     const i18n_placeholder = __('Filter');
     const title_contact_filter = __('Filter by contact name');
     const title_group_filter = __('Filter by group name');
@@ -16,34 +18,39 @@ export default (o) => {
     const label_xa = __('Extended Away');
     const label_offline = __('Offline');
 
+    const chat_state = el.filter.get('chat_state');
+    const filter_text = el.filter.get('filter_text');
+    const filter_type = el.filter.get('filter_type');
+
     return html`
-        <form class="controlbox-padded roster-filter-form input-button-group ${ (!o.visible) ? 'hidden' : 'fade-in' }"
-            @submit=${o.submitFilter}>
+        <form class="controlbox-padded contacts-filter-form input-button-group ${ !el.shouldBeVisible() ? 'hidden' : 'fade-in' }"
+              @submit=${ev => el.submitFilter(ev)}>
             <div class="form-inline flex-nowrap">
                 <div class="filter-by d-flex flex-nowrap">
-                    <converse-icon size="1em" @click=${o.changeTypeFilter} class="fa fa-user clickable ${ (o.filter_type === 'contacts') ? 'selected' : '' }" data-type="contacts" title="${title_contact_filter}"></converse-icon>
-                    <converse-icon size="1em" @click=${o.changeTypeFilter} class="fa fa-users clickable ${ (o.filter_type === 'groups') ? 'selected' : '' }" data-type="groups" title="${title_group_filter}"></converse-icon>
-                    <converse-icon size="1em" @click=${o.changeTypeFilter} class="fa fa-circle clickable ${ (o.filter_type === 'state') ? 'selected' : '' }" data-type="state" title="${title_status_filter}"></converse-icon>
+                    <converse-icon size="1em" @click=${ev => el.changeTypeFilter(ev)} class="fa fa-user clickable ${ (filter_type === 'contacts') ? 'selected' : '' }" data-type="contacts" title="${title_contact_filter}"></converse-icon>
+                    <converse-icon size="1em" @click=${ev => el.changeTypeFilter(ev)} class="fa fa-users clickable ${ (filter_type === 'groups') ? 'selected' : '' }" data-type="groups" title="${title_group_filter}"></converse-icon>
+                    <converse-icon size="1em" @click=${ev => el.changeTypeFilter(ev)} class="fa fa-circle clickable ${ (filter_type === 'state') ? 'selected' : '' }" data-type="state" title="${title_status_filter}"></converse-icon>
                 </div>
                 <div class="btn-group">
-                    <input .value="${o.filter_text || ''}"
-                        @keydown=${o.liveFilter}
-                        class="roster-filter form-control ${ (o.filter_type === 'state') ? 'hidden' : '' }"
+                    <input .value="${filter_text || ''}"
+                        @keydown=${ev => el.liveFilter(ev)}
+                        class="contacts-filter form-control ${ (filter_type === 'state') ? 'hidden' : '' }"
                         placeholder="${i18n_placeholder}"/>
-                    <converse-icon size="1em" class="fa fa-times clear-input ${ (!o.filter_text || o.filter_type === 'state') ? 'hidden' : '' }"
-                        @click=${o.clearFilter}>
+                    <converse-icon size="1em"
+                                   class="fa fa-times clear-input ${ (!filter_text || filter_type === 'state') ? 'hidden' : '' }"
+                                   @click=${ev => el.clearFilter(ev)}>
                     </converse-icon>
                 </div>
-                <select class="form-control state-type ${ (o.filter_type !== 'state') ? 'hidden' : '' }"
-                        @change=${o.changeChatStateFilter}>
+                <select class="form-control state-type ${ (filter_type !== 'state') ? 'hidden' : '' }"
+                        @change=${ev => el.changeChatStateFilter(ev)}>
                     <option value="">${label_any}</option>
-                    <option ?selected=${o.chat_state === 'unread_messages'} value="unread_messages">${label_unread_messages}</option>
-                    <option ?selected=${o.chat_state === 'online'} value="online">${label_online}</option>
-                    <option ?selected=${o.chat_state === 'chat'} value="chat">${label_chatty}</option>
-                    <option ?selected=${o.chat_state === 'dnd'} value="dnd">${label_busy}</option>
-                    <option ?selected=${o.chat_state === 'away'} value="away">${label_away}</option>
-                    <option ?selected=${o.chat_state === 'xa'} value="xa">${label_xa}</option>
-                    <option ?selected=${o.chat_state === 'offline'} value="offline">${label_offline}</option>
+                    <option ?selected=${chat_state === 'unread_messages'} value="unread_messages">${label_unread_messages}</option>
+                    <option ?selected=${chat_state === 'online'} value="online">${label_online}</option>
+                    <option ?selected=${chat_state === 'chat'} value="chat">${label_chatty}</option>
+                    <option ?selected=${chat_state === 'dnd'} value="dnd">${label_busy}</option>
+                    <option ?selected=${chat_state === 'away'} value="away">${label_away}</option>
+                    <option ?selected=${chat_state === 'xa'} value="xa">${label_xa}</option>
+                    <option ?selected=${chat_state === 'offline'} value="offline">${label_offline}</option>
                 </select>
             </div>
         </form>`

+ 13 - 13
src/plugins/rosterview/tests/roster.js

@@ -223,7 +223,7 @@ describe("The Contacts Roster", function () {
         await mock.waitForRoster(_converse, 'current');
 
         const rosterview = document.querySelector('converse-roster');
-        const filter = rosterview.querySelector('.roster-filter');
+        const filter = rosterview.querySelector('.contacts-filter');
         const roster = rosterview.querySelector('.roster-contacts');
 
         await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 800);
@@ -275,7 +275,7 @@ describe("The Contacts Roster", function () {
                 return el.isConnected && flyout.offsetHeight < panel.scrollHeight;
             }
             const rosterview = document.querySelector('converse-roster');
-            const filter = rosterview.querySelector('.roster-filter');
+            const filter = rosterview.querySelector('.contacts-filter');
             const el = rosterview.querySelector('.roster-contacts');
             await u.waitUntil(() => hasScrollBar(el) ? u.isVisible(filter) : !u.isVisible(filter), 900);
         }));
@@ -288,7 +288,7 @@ describe("The Contacts Roster", function () {
             await mock.openControlBox(_converse);
             await mock.waitForRoster(_converse, 'current');
             const rosterview = document.querySelector('converse-roster');
-            let filter = rosterview.querySelector('.roster-filter');
+            let filter = rosterview.querySelector('.contacts-filter');
             const roster = rosterview.querySelector('.roster-contacts');
 
             await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
@@ -305,7 +305,7 @@ describe("The Contacts Roster", function () {
             const visible_group = sizzle('.roster-group', roster).filter(u.isVisible).pop();
             expect(visible_group.querySelector('a.group-toggle').textContent.trim()).toBe('friends & acquaintences');
 
-            filter = rosterview.querySelector('.roster-filter');
+            filter = rosterview.querySelector('.contacts-filter');
             filter.value = "j";
             u.triggerEvent(filter, "keydown", "KeyboardEvent");
             await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 2), 700);
@@ -318,14 +318,14 @@ describe("The Contacts Roster", function () {
             expect(visible_groups[0].textContent.trim()).toBe('friends & acquaintences');
             expect(visible_groups[1].textContent.trim()).toBe('Ungrouped');
 
-            filter = rosterview.querySelector('.roster-filter');
+            filter = rosterview.querySelector('.contacts-filter');
             filter.value = "xxx";
             u.triggerEvent(filter, "keydown", "KeyboardEvent");
             await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 0), 600);
             visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
             expect(visible_groups.length).toBe(0);
 
-            filter = rosterview.querySelector('.roster-filter');
+            filter = rosterview.querySelector('.contacts-filter');
             filter.value = "";
             u.triggerEvent(filter, "keydown", "KeyboardEvent");
             await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
@@ -344,7 +344,7 @@ describe("The Contacts Roster", function () {
             await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
             expect(sizzle('.roster-group', roster).filter(u.isVisible).length).toBe(5);
 
-            let filter = rosterview.querySelector('.roster-filter');
+            let filter = rosterview.querySelector('.contacts-filter');
             filter.value = "colleagues";
             u.triggerEvent(filter, "keydown", "KeyboardEvent");
 
@@ -354,13 +354,13 @@ describe("The Contacts Roster", function () {
             // Check that all contacts under the group are shown
             expect(sizzle('div.roster-group:not(.collapsed) li', roster).filter(l => !u.isVisible(l)).length).toBe(0);
 
-            filter = rosterview.querySelector('.roster-filter');
+            filter = rosterview.querySelector('.contacts-filter');
             filter.value = "xxx";
             u.triggerEvent(filter, "keydown", "KeyboardEvent");
 
             await u.waitUntil(() => (roster.querySelectorAll('.roster-group').length === 0), 700);
 
-            filter = rosterview.querySelector('.roster-filter');
+            filter = rosterview.querySelector('.contacts-filter');
             filter.value = ""; // Check that groups are shown again, when the filter string is cleared.
             u.triggerEvent(filter, "keydown", "KeyboardEvent");
             await u.waitUntil(() => (roster.querySelectorAll('div.roster-group.collapsed').length === 0), 700);
@@ -374,16 +374,16 @@ describe("The Contacts Roster", function () {
             await mock.waitForRoster(_converse, 'current');
 
             const rosterview = document.querySelector('converse-roster');
-            const filter = rosterview.querySelector('.roster-filter');
+            const filter = rosterview.querySelector('.contacts-filter');
             filter.value = "xxx";
             u.triggerEvent(filter, "keydown", "KeyboardEvent");
             expect(_.includes(filter.classList, "x")).toBeFalsy();
-            expect(u.hasClass('hidden', rosterview.querySelector('.roster-filter-form .clear-input'))).toBeTruthy();
+            expect(u.hasClass('hidden', rosterview.querySelector('.contacts-filter-form .clear-input'))).toBeTruthy();
 
             const isHidden = (el) => u.hasClass('hidden', el);
-            await u.waitUntil(() => !isHidden(rosterview.querySelector('.roster-filter-form .clear-input')), 900);
+            await u.waitUntil(() => !isHidden(rosterview.querySelector('.contacts-filter-form .clear-input')), 900);
             rosterview.querySelector('.clear-input').click();
-            await u.waitUntil(() => document.querySelector('.roster-filter').value == '');
+            await u.waitUntil(() => document.querySelector('.contacts-filter').value == '');
         }));
 
         // Disabling for now, because since recently this test consistently

+ 112 - 0
src/shared/components/contacts-filter.js

@@ -0,0 +1,112 @@
+import debounce from "lodash-es/debounce";
+import { CustomElement } from 'shared/components/element.js';
+import { api } from "@converse/headless";
+
+import './styles/contacts-filter.scss';
+
+
+export class ContactsFilter extends CustomElement {
+
+    constructor () {
+        super();
+        this.contacts = null;
+        this.filter = null;
+        this.template = null;
+        this.promise = Promise.resolve();
+    }
+
+    static get properties () {
+        return {
+            contacts: { type: Array },
+            filter: { type: Object },
+            promise: { type: Promise },
+            template: { type: Object },
+        }
+    }
+
+    initialize () {
+        this.liveFilter = debounce((ev) => this.filter.save({'filter_text': ev.target.value}), 250);
+
+        this.listenTo(this.contacts, "add", () => this.requestUpdate());
+        this.listenTo(this.contacts, "destroy", () => this.requestUpdate());
+        this.listenTo(this.contacts, "remove", () => this.requestUpdate());
+
+        this.listenTo(this.filter, 'change', () => {
+            this.dispatchUpdateEvent();
+            this.requestUpdate();
+        });
+
+        this.promise.then(() => this.requestUpdate());
+        this.requestUpdate();
+    }
+
+    render () {
+        return this.shouldBeVisible() ? this.template(this) : '';
+    }
+
+    dispatchUpdateEvent () {
+        this.dispatchEvent(new CustomEvent('update', { 'detail': this.filter.changed }));
+    }
+
+    /**
+     * @param {Event} ev
+     */
+    changeChatStateFilter (ev) {
+        ev && ev.preventDefault();
+        this.filter.save({'chat_state': /** @type {HTMLInputElement} */(this.querySelector('.state-type')).value});
+    }
+
+    /**
+     * @param {Event} ev
+     */
+    changeTypeFilter (ev) {
+        ev && ev.preventDefault();
+        const target = /** @type {HTMLInputElement} */(ev.target);
+        const type = /** @type {HTMLElement} */(target.closest('converse-icon'))?.dataset.type || 'contacts';
+        if (type === 'state') {
+            this.filter.save({
+                'filter_type': type,
+                'chat_state': /** @type {HTMLInputElement} */(this.querySelector('.state-type')).value
+            });
+        } else {
+            this.filter.save({
+                'filter_type': type,
+                'filter_text': /** @type {HTMLInputElement} */(this.querySelector('.contacts-filter')).value
+            });
+        }
+    }
+
+    /**
+     * @param {Event} ev
+     */
+    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).
+     * @returns {boolean}
+     */
+    isActive () {
+        return (this.filter.get('filter_type') === 'state' || this.filter.get('filter_text'));
+    }
+
+    /**
+     * @returns {boolean}
+     */
+    shouldBeVisible () {
+        return this.contacts?.length >= 5 || this.isActive();
+    }
+
+    /**
+     * @param {Event} ev
+     */
+    clearFilter (ev) {
+        ev && ev.preventDefault();
+        this.filter.save({'filter_text': ''});
+    }
+}
+
+api.elements.define('converse-contacts-filter', ContactsFilter);

+ 31 - 0
src/shared/components/styles/contacts-filter.scss

@@ -0,0 +1,31 @@
+converse-contacts-filter {
+    display: block;
+    margin-bottom: 1em;
+
+    .contacts-filter-form {
+        width: 100%;
+
+        .button-group {
+            padding: 0.2em;
+        }
+
+        converse-icon {
+            padding: 0.25em;
+        }
+
+        .contacts-filter {
+            width: 100%;
+            margin: 0.2em;
+            font-size: calc(var(--font-size) - 2px);
+
+            &.form-control {
+                width: 100%;
+            }
+        }
+
+        .state-type {
+            font-size: calc(var(--font-size) - 2px);
+            width: 100%;
+        }
+    }
+}