From 1f588943cde2bd212e4545597ff182d41acb7249 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Wed, 18 Oct 2023 11:52:31 +0200 Subject: [PATCH] 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. --- CHANGES.md | 1 + karma.conf.js | 1 + src/headless/shared/api/index.js | 1 + src/plugins/muc-views/sidebar.js | 14 ++- .../muc-views/templates/muc-sidebar.js | 36 +++++- .../muc-views/templates/occupants-filter.js | 63 ++++++++++ src/plugins/muc-views/tests/nickname.js | 6 +- .../muc-views/tests/occupants-filter.js | 75 ++++++++++++ src/plugins/muc-views/tests/occupants.js | 2 +- src/plugins/rosterview/filterview.js | 92 -------------- src/plugins/rosterview/index.js | 2 - src/plugins/rosterview/styles/roster.scss | 23 ---- src/plugins/rosterview/templates/roster.js | 9 +- .../rosterview/templates/roster_filter.js | 49 ++++---- src/plugins/rosterview/tests/roster.js | 26 ++-- src/shared/components/contacts-filter.js | 112 ++++++++++++++++++ .../components/styles/contacts-filter.scss | 31 +++++ 17 files changed, 380 insertions(+), 163 deletions(-) create mode 100644 src/plugins/muc-views/templates/occupants-filter.js create mode 100644 src/plugins/muc-views/tests/occupants-filter.js delete mode 100644 src/plugins/rosterview/filterview.js create mode 100644 src/shared/components/contacts-filter.js create mode 100644 src/shared/components/styles/contacts-filter.scss diff --git a/CHANGES.md b/CHANGES.md index b4d4310c24..2b986aa35a 100644 --- a/CHANGES.md +++ b/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: diff --git a/karma.conf.js b/karma.conf.js index 5e5717fe6e..26f824f01d 100644 --- a/karma.conf.js +++ b/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' }, diff --git a/src/headless/shared/api/index.js b/src/headless/shared/api/index.js index af83dba846..1a9cb6f66a 100644 --- a/src/headless/shared/api/index.js +++ b/src/headless/shared/api/index.js @@ -28,6 +28,7 @@ const api = { ...promise_api, disco: null, + elements: null, }; export default api; diff --git a/src/plugins/muc-views/sidebar.js b/src/plugins/muc-views/sidebar.js index c8d79ea961..95ffacdd4f 100644 --- a/src/plugins/muc-views/sidebar.js +++ b/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), } )); diff --git a/src/plugins/muc-views/templates/muc-sidebar.js b/src/plugins/muc-views/templates/muc-sidebar.js index defdf361d6..1788254dcd 100644 --- a/src/plugins/muc-views/templates/muc-sidebar.js +++ b/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`
${o.occupants.length} ${i18n_participants} - + el.closeSidebar(ev)}>
- + `; } diff --git a/src/plugins/muc-views/templates/occupants-filter.js b/src/plugins/muc-views/templates/occupants-filter.js new file mode 100644 index 0000000000..91f9b31056 --- /dev/null +++ b/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` +
el.submitFilter(ev)}> +
+
+ el.changeTypeFilter(ev)} + class="fa fa-user clickable ${ (filter_type === 'contacts') ? 'selected' : '' }" + data-type="contacts" + title="${title_contact_filter}"> + el.changeTypeFilter(ev)} + class="fa fa-circle clickable ${ (filter_type === 'state') ? 'selected' : '' }" + data-type="state" + title="${title_status_filter}"> +
+
+ el.liveFilter(ev)} + class="contacts-filter form-control ${ (filter_type === 'state') ? 'hidden' : '' }" + placeholder="${i18n_placeholder}"/> + el.clearFilter(ev)}> + +
+ +
+
` +}; diff --git a/src/plugins/muc-views/tests/nickname.js b/src/plugins/muc-views/tests/nickname.js index e0b0185058..1740790971 100644 --- a/src/plugins/muc-views/tests/nickname.js +++ b/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', diff --git a/src/plugins/muc-views/tests/occupants-filter.js b/src/plugins/muc-views/tests/occupants-filter.js new file mode 100644 index 0000000000..53e2080be3 --- /dev/null +++ b/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 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); + })); +}); diff --git a/src/plugins/muc-views/tests/occupants.js b/src/plugins/muc-views/tests/occupants.js index 6771995ecd..9d794e93e1 100644 --- a/src/plugins/muc-views/tests/occupants.js +++ b/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 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); diff --git a/src/plugins/rosterview/filterview.js b/src/plugins/rosterview/filterview.js deleted file mode 100644 index 95f33ea89b..0000000000 --- a/src/plugins/rosterview/filterview.js +++ /dev/null @@ -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); diff --git a/src/plugins/rosterview/index.js b/src/plugins/rosterview/index.js index 4dff5a0584..ef55a4d847 100644 --- a/src/plugins/rosterview/index.js +++ b/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 ----------- */ diff --git a/src/plugins/rosterview/styles/roster.scss b/src/plugins/rosterview/styles/roster.scss index 207daff2da..f61344945c 100644 --- a/src/plugins/rosterview/styles/roster.scss +++ b/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; diff --git a/src/plugins/rosterview/templates/roster.js b/src/plugins/rosterview/templates/roster.js index b7e6560836..03c2382bf3 100644 --- a/src/plugins/rosterview/templates/roster.js +++ b/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) => {
- el.requestUpdate()}> + el.requestUpdate()} + .promise=${api.waitUntil('rosterInitialized')} + .contacts=${_converse.roster} + .template=${tplRosterFilter} + .filter=${_converse.roster_filter}> + ${ repeat(groupnames, (n) => n, (name) => { const contacts = contacts_map[name].filter(c => shouldShowContact(c, name)); contacts.sort(contactsComparator); diff --git a/src/plugins/rosterview/templates/roster_filter.js b/src/plugins/rosterview/templates/roster_filter.js index 44e7ce88a5..0440ec674c 100644 --- a/src/plugins/rosterview/templates/roster_filter.js +++ b/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` -
+ el.submitFilter(ev)}>
- - - + el.changeTypeFilter(ev)} class="fa fa-user clickable ${ (filter_type === 'contacts') ? 'selected' : '' }" data-type="contacts" title="${title_contact_filter}"> + el.changeTypeFilter(ev)} class="fa fa-users clickable ${ (filter_type === 'groups') ? 'selected' : '' }" data-type="groups" title="${title_group_filter}"> + el.changeTypeFilter(ev)} class="fa fa-circle clickable ${ (filter_type === 'state') ? 'selected' : '' }" data-type="state" title="${title_status_filter}">
- el.liveFilter(ev)} + class="contacts-filter form-control ${ (filter_type === 'state') ? 'hidden' : '' }" placeholder="${i18n_placeholder}"/> - + el.clearFilter(ev)}>
- el.changeChatStateFilter(ev)}> - - - - - - - + + + + + + +
` diff --git a/src/plugins/rosterview/tests/roster.js b/src/plugins/rosterview/tests/roster.js index 36dcf62590..9b065d3e16 100644 --- a/src/plugins/rosterview/tests/roster.js +++ b/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 diff --git a/src/shared/components/contacts-filter.js b/src/shared/components/contacts-filter.js new file mode 100644 index 0000000000..51d004a3e7 --- /dev/null +++ b/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); diff --git a/src/shared/components/styles/contacts-filter.scss b/src/shared/components/styles/contacts-filter.scss new file mode 100644 index 0000000000..f85db4d668 --- /dev/null +++ b/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%; + } + } +}