summaryrefslogtreecommitdiffstats
path: root/roles/reverseproxy/files/conversejs/src/plugins/rosterview
diff options
context:
space:
mode:
Diffstat (limited to 'roles/reverseproxy/files/conversejs/src/plugins/rosterview')
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/constants.js10
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/contactview.js91
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/filterview.js92
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/index.js46
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/add-contact.js155
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/templates/add-contact.js48
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/rosterview.js75
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/styles/roster.scss191
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/group.js63
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/requesting_contact.js19
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster.js57
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_filter.js50
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_item.js50
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/add-contact-modal.js195
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/presence.js54
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/protocol.js537
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/roster.js1365
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/rosterview/utils.js114
18 files changed, 3212 insertions, 0 deletions
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/constants.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/constants.js
new file mode 100644
index 0000000..6d96c80
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/constants.js
@@ -0,0 +1,10 @@
+import { __ } from 'i18n';
+
+export 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')
+};
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/contactview.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/contactview.js
new file mode 100644
index 0000000..b4ac5fe
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/contactview.js
@@ -0,0 +1,91 @@
+import log from "@converse/headless/log.js";
+import tplRequestingContact from "./templates/requesting_contact.js";
+import tplRosterItem from "./templates/roster_item.js";
+import { CustomElement } from 'shared/components/element.js';
+import { __ } from 'i18n';
+import { _converse, api } from "@converse/headless/core";
+
+
+export default class RosterContact extends CustomElement {
+
+ static get properties () {
+ return {
+ model: { type: Object }
+ }
+ }
+
+ initialize () {
+ this.listenTo(this.model, 'change', () => this.requestUpdate());
+ this.listenTo(this.model, 'highlight', () => this.requestUpdate());
+ this.listenTo(this.model, 'vcard:add', () => this.requestUpdate());
+ this.listenTo(this.model, 'vcard:change', () => this.requestUpdate());
+ this.listenTo(this.model, 'presenceChanged', () => this.requestUpdate());
+ }
+
+ render () {
+ if (this.model.get('requesting') === true) {
+ const display_name = this.model.getDisplayName();
+ return tplRequestingContact(
+ Object.assign(this.model.toJSON(), {
+ display_name,
+ 'openChat': ev => this.openChat(ev),
+ 'acceptRequest': ev => this.acceptRequest(ev),
+ 'declineRequest': ev => this.declineRequest(ev),
+ '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),
+ })
+ );
+ } else {
+ return tplRosterItem(this, this.model);
+ }
+ }
+
+ openChat (ev) {
+ ev?.preventDefault?.();
+ this.model.openChat();
+ }
+
+ async removeContact (ev) {
+ ev?.preventDefault?.();
+ if (!api.settings.get('allow_contact_removal')) { return; }
+
+ const result = await api.confirm(__("Are you sure you want to remove this contact?"));
+ if (!result) return;
+
+ try {
+ this.model.removeFromRoster();
+ 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) {
+ ev?.preventDefault?.();
+
+ await _converse.roster.sendContactAddIQ(
+ this.model.get('jid'),
+ this.model.getFullname(),
+ []
+ );
+ this.model.authorize().subscribe();
+ }
+
+ async declineRequest (ev) {
+ if (ev && ev.preventDefault) { ev.preventDefault(); }
+ const result = await api.confirm(__("Are you sure you want to decline this contact request?"));
+ if (result) {
+ this.model.unauthorize().destroy();
+ }
+ return this;
+ }
+}
+
+api.elements.define('converse-roster-contact', RosterContact);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/filterview.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/filterview.js
new file mode 100644
index 0000000..55f5208
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/filterview.js
@@ -0,0 +1,92 @@
+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/core";
+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/roles/reverseproxy/files/conversejs/src/plugins/rosterview/index.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/index.js
new file mode 100644
index 0000000..53c5f82
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/index.js
@@ -0,0 +1,46 @@
+/**
+ * @copyright 2022, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import "../modal";
+import "@converse/headless/plugins/chatboxes/index.js";
+import "@converse/headless/plugins/roster/index.js";
+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/core";
+import { highlightRosterItem } from './utils.js';
+
+import 'shared/styles/status.scss';
+import './styles/roster.scss';
+
+
+converse.plugins.add('converse-rosterview', {
+
+ dependencies: ["converse-roster", "converse-modal", "converse-chatboxviews"],
+
+ initialize () {
+ api.settings.extend({
+ 'autocomplete_add_contact': 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;
+
+ /* -------- Event Handlers ----------- */
+ api.listen.on('chatBoxesInitialized', () => {
+ _converse.chatboxes.on('destroy', chatbox => highlightRosterItem(chatbox));
+ _converse.chatboxes.on('change:hidden', chatbox => highlightRosterItem(chatbox));
+ });
+
+ api.listen.on('afterTearDown', () => _converse.rotergroups?.off().reset());
+ }
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/add-contact.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/add-contact.js
new file mode 100644
index 0000000..dcfff99
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/add-contact.js
@@ -0,0 +1,155 @@
+import 'shared/autocomplete/index.js';
+import BaseModal from "plugins/modal/modal.js";
+import api from '@converse/headless/shared/api';
+import compact from 'lodash-es/compact';
+import debounce from 'lodash-es/debounce';
+import tplAddContactModal from "./templates/add-contact.js";
+import { Strophe } from 'strophe.js/src/core.js';
+import { __ } from 'i18n';
+import { _converse } from "@converse/headless/core";
+import { addClass, removeClass } from 'utils/html.js';
+
+export default class AddContactModal extends BaseModal {
+
+ initialize () {
+ super.initialize();
+ this.listenTo(this.model, 'change', () => this.render());
+ this.render();
+ this.addEventListener('shown.bs.modal', () => this.querySelector('input[name="jid"]')?.focus(), false);
+ }
+
+ renderModal () {
+ return tplAddContactModal(this);
+ }
+
+ getModalTitle () { // eslint-disable-line class-methods-use-this
+ return __('Add a Contact');
+ }
+
+ afterRender () {
+ if (typeof api.settings.get('xhr_user_search_url') === 'string') {
+ this.initXHRAutoComplete();
+ } else {
+ this.initJIDAutoComplete();
+ }
+ }
+
+ initJIDAutoComplete () {
+ if (!api.settings.get('autocomplete_add_contact')) {
+ return;
+ }
+ const el = this.querySelector('.suggestion-box__jid').parentElement;
+ this.jid_auto_complete = new _converse.AutoComplete(el, {
+ 'data': (text, input) => `${input.slice(0, input.indexOf("@"))}@${text}`,
+ 'filter': _converse.FILTER_STARTSWITH,
+ 'list': [...new Set(_converse.roster.map(item => Strophe.getDomainFromJid(item.get('jid'))))]
+ });
+ }
+
+ initGroupAutoComplete () {
+ if (!api.settings.get('autocomplete_add_contact')) {
+ return;
+ }
+ const el = this.querySelector('.suggestion-box__jid').parentElement;
+ this.jid_auto_complete = new _converse.AutoComplete(el, {
+ 'data': (text, input) => `${input.slice(0, input.indexOf("@"))}@${text}`,
+ 'filter': _converse.FILTER_STARTSWITH,
+ 'list': [...new Set(_converse.roster.map(item => Strophe.getDomainFromJid(item.get('jid'))))]
+ });
+ }
+
+ initXHRAutoComplete () {
+ if (!api.settings.get('autocomplete_add_contact')) {
+ return this.initXHRFetch();
+ }
+ const el = this.querySelector('.suggestion-box__name').parentElement;
+ this.name_auto_complete = new _converse.AutoComplete(el, {
+ 'auto_evaluate': false,
+ 'filter': _converse.FILTER_STARTSWITH,
+ 'list': []
+ });
+ const xhr = new window.XMLHttpRequest();
+ // `open` must be called after `onload` for mock/testing purposes.
+ xhr.onload = () => {
+ if (xhr.responseText) {
+ const r = xhr.responseText;
+ this.name_auto_complete.list = JSON.parse(r).map(i => ({'label': i.fullname || i.jid, 'value': i.jid}));
+ this.name_auto_complete.auto_completing = true;
+ this.name_auto_complete.evaluate();
+ }
+ };
+ const input_el = this.querySelector('input[name="name"]');
+ input_el.addEventListener('input', debounce(() => {
+ xhr.open("GET", `${api.settings.get('xhr_user_search_url')}q=${encodeURIComponent(input_el.value)}`, true);
+ xhr.send()
+ } , 300));
+ this.name_auto_complete.on('suggestion-box-selectcomplete', ev => {
+ this.querySelector('input[name="name"]').value = ev.text.label;
+ this.querySelector('input[name="jid"]').value = ev.text.value;
+ });
+ }
+
+ initXHRFetch () {
+ this.xhr = new window.XMLHttpRequest();
+ this.xhr.onload = () => {
+ if (this.xhr.responseText) {
+ const r = this.xhr.responseText;
+ const list = JSON.parse(r).map(i => ({'label': i.fullname || i.jid, 'value': i.jid}));
+ if (list.length !== 1) {
+ const el = this.querySelector('.invalid-feedback');
+ el.textContent = __('Sorry, could not find a contact with that name')
+ addClass('d-block', el);
+ return;
+ }
+ const jid = list[0].value;
+ if (this.validateSubmission(jid)) {
+ const form = this.querySelector('form');
+ const name = list[0].label;
+ this.afterSubmission(form, jid, name);
+ }
+ }
+ };
+ }
+
+ validateSubmission (jid) {
+ const el = this.querySelector('.invalid-feedback');
+ if (!jid || compact(jid.split('@')).length < 2) {
+ addClass('is-invalid', this.querySelector('input[name="jid"]'));
+ addClass('d-block', el);
+ return false;
+ } else if (_converse.roster.get(Strophe.getBareJidFromJid(jid))) {
+ el.textContent = __('This contact has already been added')
+ addClass('d-block', el);
+ return false;
+ }
+ removeClass('d-block', el);
+ return true;
+ }
+
+ afterSubmission (_form, jid, name, group) {
+ if (group && !Array.isArray(group)) {
+ group = [group];
+ }
+ _converse.roster.addAndSubscribe(jid, name, group);
+ this.model.clear();
+ this.modal.hide();
+ }
+
+ addContactFromForm (ev) {
+ ev.preventDefault();
+ const data = new FormData(ev.target);
+ const jid = (data.get('jid') || '').trim();
+
+ if (!jid && typeof api.settings.get('xhr_user_search_url') === 'string') {
+ const input_el = this.querySelector('input[name="name"]');
+ this.xhr.open("GET", `${api.settings.get('xhr_user_search_url')}q=${encodeURIComponent(input_el.value)}`, true);
+ this.xhr.send()
+ return;
+ }
+ if (this.validateSubmission(jid)) {
+ this.afterSubmission(ev.target, jid, data.get('name'), data.get('group'));
+ }
+ }
+}
+
+api.elements.define('converse-add-contact-modal', AddContactModal);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/templates/add-contact.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/templates/add-contact.js
new file mode 100644
index 0000000..f18dc14
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/templates/add-contact.js
@@ -0,0 +1,48 @@
+import { __ } from 'i18n';
+import { api } from '@converse/headless/core.js';
+import { getGroupsAutoCompleteList } from '@converse/headless/plugins/roster/utils.js';
+import { html } from "lit";
+
+
+export default (el) => {
+ const i18n_add = __('Add');
+ const i18n_contact_placeholder = __('name@example.org');
+ const i18n_error_message = __('Please enter a valid XMPP address');
+ const i18n_group = __('Group');
+ const i18n_nickname = __('Name');
+ const i18n_xmpp_address = __('XMPP Address');
+
+ return html`
+ <form class="converse-form add-xmpp-contact" @submit=${ev => el.addContactFromForm(ev)}>
+ <div class="modal-body">
+ <span class="modal-alert"></span>
+ <div class="form-group add-xmpp-contact__jid">
+ <label class="clearfix" for="jid">${i18n_xmpp_address}:</label>
+ <div class="suggestion-box suggestion-box__jid">
+ <ul class="suggestion-box__results suggestion-box__results--below" hidden=""></ul>
+ <input type="text" name="jid" ?required=${(!api.settings.get('xhr_user_search_url'))}
+ value="${el.model.get('jid') || ''}"
+ class="form-control suggestion-box__input"
+ placeholder="${i18n_contact_placeholder}"/>
+ <span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span>
+ </div>
+ </div>
+
+ <div class="form-group add-xmpp-contact__name">
+ <label class="clearfix" for="name">${i18n_nickname}:</label>
+ <div class="suggestion-box suggestion-box__name">
+ <ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul>
+ <input type="text" name="name" value="${el.model.get('nickname') || ''}"
+ class="form-control suggestion-box__input"/>
+ <span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span>
+ </div>
+ </div>
+ <div class="form-group add-xmpp-contact__group">
+ <label class="clearfix" for="name">${i18n_group}:</label>
+ <converse-autocomplete .list=${getGroupsAutoCompleteList()} name="group"></converse-autocomplete>
+ </div>
+ <div class="form-group"><div class="invalid-feedback">${i18n_error_message}</div></div>
+ <button type="submit" class="btn btn-primary">${i18n_add}</button>
+ </div>
+ </form>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/rosterview.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/rosterview.js
new file mode 100644
index 0000000..f79da2b
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/rosterview.js
@@ -0,0 +1,75 @@
+import tplRoster from "./templates/roster.js";
+import { CustomElement } from 'shared/components/element.js';
+import { Model } from '@converse/skeletor/src/model.js';
+import { _converse, api } from "@converse/headless/core";
+import { initStorage } from '@converse/headless/utils/storage.js';
+import { slideIn, slideOut } from 'utils/html.js';
+
+
+/**
+ * @class
+ * @namespace _converse.RosterView
+ * @memberOf _converse
+ */
+export default class RosterView extends CustomElement {
+
+ async initialize () {
+ const id = `converse.contacts-panel${_converse.bare_jid}`;
+ this.model = new Model({ id });
+ initStorage(this.model, id);
+ this.model.fetch();
+
+ await api.waitUntil('rosterInitialized')
+
+ const { chatboxes, presences, roster } = _converse;
+ this.listenTo(_converse, 'rosterContactsFetched', () => this.requestUpdate());
+ this.listenTo(presences, 'change:show', () => this.requestUpdate());
+ this.listenTo(chatboxes, 'change:hidden', () => this.requestUpdate());
+ this.listenTo(roster, 'add', () => this.requestUpdate());
+ this.listenTo(roster, 'destroy', () => this.requestUpdate());
+ this.listenTo(roster, 'remove', () => this.requestUpdate());
+ this.listenTo(roster, 'change', () => this.requestUpdate());
+ this.listenTo(roster.state, 'change', () => this.requestUpdate());
+ this.listenTo(this.model, 'change', () => this.requestUpdate());
+ /**
+ * Triggered once the _converse.RosterView instance has been created and initialized.
+ * @event _converse#rosterViewInitialized
+ * @example _converse.api.listen.on('rosterViewInitialized', () => { ... });
+ */
+ api.trigger('rosterViewInitialized');
+ }
+
+ render () {
+ return tplRoster(this);
+ }
+
+ showAddContactModal (ev) { // eslint-disable-line class-methods-use-this
+ api.modal.show('converse-add-contact-modal', {'model': new Model()}, ev);
+ }
+
+ async syncContacts (ev) { // eslint-disable-line class-methods-use-this
+ ev.preventDefault();
+ const { roster } = _converse;
+ this.syncing_contacts = true;
+ this.requestUpdate();
+
+ roster.data.save('version', null);
+ await roster.fetchFromServer();
+ api.user.presence.send();
+
+ this.syncing_contacts = false;
+ this.requestUpdate();
+ }
+
+ toggleRoster (ev) {
+ ev?.preventDefault?.();
+ const list_el = this.querySelector('.list-container.roster-contacts');
+ if (this.model.get('toggle_state') === _converse.CLOSED) {
+ slideOut(list_el).then(() => this.model.save({'toggle_state': _converse.OPENED}));
+ } else {
+ slideIn(list_el).then(() => this.model.save({'toggle_state': _converse.CLOSED}));
+ }
+ }
+}
+
+api.elements.define('converse-roster', RosterView);
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/styles/roster.scss b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/styles/roster.scss
new file mode 100644
index 0000000..207daff
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/styles/roster.scss
@@ -0,0 +1,191 @@
+.conversejs {
+
+ #controlbox {
+ .open-contacts-toggle, .open-contacts-toggle .fa {
+ color: var(--chat-color) !important;
+ &:hover {
+ color: var(--chat-color) !important;
+ }
+ }
+
+ .open-contacts-toggle {
+ white-space: nowrap;
+ }
+
+ }
+
+ #converse-roster {
+ text-align: left;
+ width: 100%;
+ position: relative;
+ margin: 0;
+ height: var(--roster-height);
+ padding: 0;
+ overflow: hidden;
+ // XXX: FIXME
+ height: calc(100% - 70px);
+
+
+ /* Custom addition for CSP */
+ #online-count {
+ display: none;
+ }
+
+ .search-xmpp {
+ ul {
+ li.chat-info {
+ padding-left: 10px;
+ }
+ }
+ }
+
+ .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;
+ height: 100%;
+ overflow-x: hidden;
+ overflow-y: auto;
+ color: var(--text-color);
+
+ .roster-group-contacts {
+ .list-item {
+ &:hover {
+ .list-item-action {
+ opacity: 1;
+ }
+ }
+ }
+ }
+
+ converse-roster-contact {
+ width: 100%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ display: flex;
+ justify-content: space-between;
+
+ .list-item-action {
+ line-height: 2em;
+ }
+
+ &:hover {
+ .list-item-action {
+ opacity: 1;
+ }
+ }
+ }
+
+ .group-toggle {
+ font-family: var(--heading-font);
+ display: block;
+ width: 100%;
+ margin: 0.75em 0 0.25em 0;
+ }
+
+ .group-toggle, .group-toggle .fa {
+ color: var(--chat-head-color-dark) !important;
+ &:hover {
+ color: var(--chat-head-color-darker) !important;
+ }
+ }
+
+ .current-xmpp-contact {
+ margin: 0.25em 0;
+ }
+
+ .list-item {
+ &.requesting-xmpp-contact {
+ a {
+ line-height: var(--line-height);
+ }
+ .req-contact-name {
+ padding: 0 0.2em 0 0;
+ }
+ }
+
+ .open-chat {
+ margin: 0;
+ padding: 0;
+ &.unread-msgs {
+ font-weight: bold;
+ color: var(--unread-msgs-color);
+ .contact-name {
+ width: 70%;
+ }
+ }
+
+ .msgs-indicator {
+ color: var(--text-color-invert);
+ background-color: var(--chat-color);
+ opacity: 1;
+ border-radius: 10%;
+ padding: 0.2em 0.4em;
+ font-size: var(--font-size-small);
+ margin-right: 0;
+ }
+
+ .contact-name {
+ padding: 0;
+ margin: 0;
+ max-width: 85%;
+ float: none;
+ height: 100%;
+ &.unread-msgs {
+ max-width: 60%;
+ }
+ &.contact-name--offline {
+ margin-left: 0.25em;
+ }
+ }
+ }
+ &.odd {
+ background-color: #DCEAC5;
+ /* Make this difference */
+ }
+ a, span {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ .span {
+ display: inline-block;
+ }
+ .decline-xmpp-request {
+ margin-left: 5px;
+ }
+ &:hover {
+ background-color: var(--controlbox-pane-bg-hover-color);
+ }
+ }
+ }
+ span {
+ &.pending-contact-name {
+ line-height: var(--line-height);
+ width: 100%;
+ }
+ }
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/group.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/group.js
new file mode 100644
index 0000000..562bfe6
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/group.js
@@ -0,0 +1,63 @@
+import 'shared/components/icons.js';
+import { __ } from 'i18n';
+import { _converse, converse } from "@converse/headless/core";
+import { html } from "lit";
+import { isUniView } from '@converse/headless/utils/core.js';
+import { repeat } from 'lit/directives/repeat.js';
+import { toggleGroup } from '../utils.js';
+
+const { u } = converse.env;
+
+
+function renderContact (contact) {
+ const jid = contact.get('jid');
+ const extra_classes = [];
+ if (isUniView()) {
+ const chatbox = _converse.chatboxes.get(jid);
+ if (chatbox && !chatbox.get('hidden')) {
+ extra_classes.push('open');
+ }
+ }
+ const ask = contact.get('ask');
+ const requesting = contact.get('requesting');
+ const subscription = contact.get('subscription');
+ if ((ask === 'subscribe') || (subscription === 'from')) {
+ /* ask === 'subscribe'
+ * Means we have asked to subscribe to them.
+ *
+ * subscription === 'from'
+ * They are subscribed to us, 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.
+ */
+ extra_classes.push('pending-xmpp-contact');
+ } else if (requesting === true) {
+ extra_classes.push('requesting-xmpp-contact');
+ } else if (subscription === 'both' || subscription === 'to' || u.isSameBareJID(jid, _converse.connection.jid)) {
+ extra_classes.push('current-xmpp-contact');
+ extra_classes.push(subscription);
+ extra_classes.push(contact.presence.get('show'));
+ }
+ return html`
+ <li class="list-item d-flex controlbox-padded ${extra_classes.join(' ')}" data-status="${contact.presence.get('show')}">
+ <converse-roster-contact .model=${contact}></converse-roster-contact>
+ </li>`;
+}
+
+
+export default (o) => {
+ const i18n_title = __('Click to hide these contacts');
+ const collapsed = _converse.roster.state.get('collapsed_groups');
+ return html`
+ <div class="roster-group" data-group="${o.name}">
+ <a href="#" class="list-toggle group-toggle controlbox-padded" title="${i18n_title}" @click=${ev => toggleGroup(ev, o.name)}>
+ <converse-icon color="var(--chat-head-color-dark)" size="1em" class="fa ${ (collapsed.includes(o.name)) ? 'fa-caret-right' : 'fa-caret-down' }"></converse-icon> ${o.name}
+ </a>
+ <ul class="items-list roster-group-contacts ${ (collapsed.includes(o.name)) ? 'collapsed' : '' }" data-group="${o.name}">
+ ${ repeat(o.contacts, (c) => c.get('jid'), renderContact) }
+ </ul>
+ </div>`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/requesting_contact.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/requesting_contact.js
new file mode 100644
index 0000000..513fb46
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/requesting_contact.js
@@ -0,0 +1,19 @@
+import { html } from "lit";
+
+export default (o) => html`
+ <a class="open-chat w-100" href="#" @click=${o.openChat}>
+ <span class="req-contact-name w-100" title="JID: ${o.jid}">${o.display_name}</span>
+ </a>
+ <a class="accept-xmpp-request list-item-action list-item-action--visible"
+ @click=${o.acceptRequest}
+ aria-label="${o.desc_accept}" title="${o.desc_accept}" href="#">
+
+ <converse-icon class="fa fa-check" size="1em"></converse-icon>
+ </a>
+
+ <a class="decline-xmpp-request list-item-action list-item-action--visible"
+ @click=${o.declineRequest}
+ aria-label="${o.desc_decline}" title="${o.desc_decline}" href="#">
+
+ <converse-icon class="fa fa-times" size="1em"></converse-icon>
+ </a>`;
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster.js
new file mode 100644
index 0000000..d347f2a
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster.js
@@ -0,0 +1,57 @@
+import tplGroup from "./group.js";
+import { __ } from 'i18n';
+import { _converse, api } from "@converse/headless/core";
+import { contactsComparator, groupsComparator } from '@converse/headless/plugins/roster/utils.js';
+import { html } from "lit";
+import { repeat } from 'lit/directives/repeat.js';
+import { shouldShowContact, shouldShowGroup, populateContactsMap } from '../utils.js';
+
+
+export default (el) => {
+ const i18n_heading_contacts = __('Contacts');
+ const i18n_toggle_contacts = __('Click to toggle contacts');
+ const i18n_title_add_contact = __('Add a contact');
+ const i18n_title_sync_contacts = __('Re-sync your contacts');
+ const roster = _converse.roster || [];
+ const contacts_map = roster.reduce((acc, contact) => populateContactsMap(acc, contact), {});
+ const groupnames = Object.keys(contacts_map).filter(shouldShowGroup);
+ const is_closed = el.model.get('toggle_state') === _converse.CLOSED;
+ groupnames.sort(groupsComparator);
+
+ return html`
+ <div class="d-flex controlbox-padded">
+ <span class="w-100 controlbox-heading controlbox-heading--contacts">
+ <a class="list-toggle open-contacts-toggle" title="${i18n_toggle_contacts}" @click=${el.toggleRoster}>
+ <converse-icon
+ class="fa ${ is_closed ? 'fa-caret-right' : 'fa-caret-down' }"
+ size="1em"
+ color="var(--chat-color)"></converse-icon>
+ ${i18n_heading_contacts}
+ </a>
+ </span>
+ <a class="controlbox-heading__btn sync-contacts"
+ @click=${ev => el.syncContacts(ev)}
+ title="${i18n_title_sync_contacts}">
+
+ <converse-icon class="fa fa-sync right ${el.syncing_contacts ? 'fa-spin' : ''}" size="1em"></converse-icon>
+ </a>
+ ${ api.settings.get('allow_contact_requests') ? html`
+ <a class="controlbox-heading__btn add-contact"
+ @click=${ev => el.showAddContactModal(ev)}
+ title="${i18n_title_add_contact}"
+ data-toggle="modal"
+ data-target="#add-contact-modal">
+ <converse-icon class="fa fa-user-plus right" size="1.25em"></converse-icon>
+ </a>` : '' }
+ </div>
+
+ <div class="list-container roster-contacts ${ is_closed ? 'hidden' : '' }">
+ <converse-roster-filter @update=${() => el.requestUpdate()}></converse-roster-filter>
+ ${ repeat(groupnames, (n) => n, (name) => {
+ const contacts = contacts_map[name].filter(c => shouldShowContact(c, name));
+ contacts.sort(contactsComparator);
+ return contacts.length ? tplGroup({ contacts, name }) : '';
+ }) }
+ </div>
+ `;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_filter.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_filter.js
new file mode 100644
index 0000000..44e7ce8
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_filter.js
@@ -0,0 +1,50 @@
+import { html } from "lit";
+import { __ } from 'i18n';
+
+
+export default (o) => {
+ const i18n_placeholder = __('Filter');
+ const title_contact_filter = __('Filter by contact name');
+ const title_group_filter = __('Filter by group name');
+ const title_status_filter = __('Filter by status');
+ const label_any = __('Any');
+ const label_unread_messages = __('Unread');
+ 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');
+
+ return html`
+ <form class="controlbox-padded roster-filter-form input-button-group ${ (!o.visible) ? 'hidden' : 'fade-in' }"
+ @submit=${o.submitFilter}>
+ <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>
+ </div>
+ <div class="btn-group">
+ <input .value="${o.filter_text || ''}"
+ @keydown=${o.liveFilter}
+ class="roster-filter form-control ${ (o.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>
+ </div>
+ <select class="form-control state-type ${ (o.filter_type !== 'state') ? 'hidden' : '' }"
+ @change=${o.changeChatStateFilter}>
+ <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>
+ </select>
+ </div>
+ </form>`
+};
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_item.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_item.js
new file mode 100644
index 0000000..86b6b94
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_item.js
@@ -0,0 +1,50 @@
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core.js";
+import { html } from "lit";
+import { STATUSES } from '../constants.js';
+
+const tplRemoveLink = (el, item) => {
+ const display_name = item.getDisplayName();
+ const i18n_remove = __('Click to remove %1$s as a contact', display_name);
+ return html`
+ <a class="list-item-action remove-xmpp-contact" @click=${el.removeContact} title="${i18n_remove}" href="#">
+ <converse-icon class="fa fa-trash-alt" size="1.5em"></converse-icon>
+ </a>
+ `;
+}
+
+export default (el, item) => {
+ const show = item.presence.get('show') || 'offline';
+ let classes, color;
+ if (show === 'online') {
+ [classes, color] = ['fa fa-circle', 'chat-status-online'];
+ } else if (show === 'dnd') {
+ [classes, color] = ['fa fa-minus-circle', 'chat-status-busy'];
+ } else if (show === 'away') {
+ [classes, color] = ['fa fa-circle', 'chat-status-away'];
+ } else {
+ [classes, color] = ['fa fa-circle', 'subdued-color'];
+ }
+ const desc_status = STATUSES[show];
+ const num_unread = item.get('num_unread') || 0;
+ const display_name = item.getDisplayName();
+ const i18n_chat = __('Click to chat with %1$s (XMPP address: %2$s)', display_name, el.model.get('jid'));
+ return html`
+ <a class="list-item-link cbox-list-item open-chat ${ num_unread ? 'unread-msgs' : '' }" title="${i18n_chat}" href="#" @click=${el.openChat}>
+ <span>
+ <converse-avatar
+ class="avatar"
+ .data=${el.model.vcard?.attributes}
+ nonce=${el.model.vcard?.get('vcard_updated')}
+ height="30" width="30"></converse-avatar>
+ <converse-icon
+ title="${desc_status}"
+ color="var(--${color})"
+ size="1em"
+ class="${classes} chat-status chat-status--avatar"></converse-icon>
+ </span>
+ ${ num_unread ? html`<span class="msgs-indicator">${ num_unread }</span>` : '' }
+ <span class="contact-name contact-name--${el.show} ${ num_unread ? 'unread-msgs' : ''}">${display_name}</span>
+ </a>
+ ${ api.settings.get('allow_contact_removal') ? tplRemoveLink(el, item) : '' }`;
+}
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/add-contact-modal.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/add-contact-modal.js
new file mode 100644
index 0000000..a809cd7
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/add-contact-modal.js
@@ -0,0 +1,195 @@
+/*global mock, converse */
+
+const u = converse.env.utils;
+const Strophe = converse.env.Strophe;
+const sizzle = converse.env.sizzle;
+
+describe("The 'Add Contact' widget", function () {
+
+ it("opens up an add modal when you click on it",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'all');
+ await mock.openControlBox(_converse);
+
+ const cbview = _converse.chatboxviews.get('controlbox');
+ cbview.querySelector('.add-contact').click()
+ const modal = _converse.api.modal.get('converse-add-contact-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ expect(modal.querySelector('form.add-xmpp-contact')).not.toBe(null);
+
+ const input_jid = modal.querySelector('input[name="jid"]');
+ const input_name = modal.querySelector('input[name="name"]');
+ input_jid.value = 'someone@';
+
+ const evt = new Event('input');
+ input_jid.dispatchEvent(evt);
+ expect(modal.querySelector('.suggestion-box li').textContent).toBe('someone@montague.lit');
+ input_jid.value = 'someone@montague.lit';
+ input_name.value = 'Someone';
+ modal.querySelector('button[type="submit"]').click();
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
+ expect(Strophe.serialize(sent_stanza)).toEqual(
+ `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit" name="Someone"/></query>`+
+ `</iq>`);
+ }));
+
+ it("can be configured to not provide search suggestions",
+ mock.initConverse([], {'autocomplete_add_contact': false}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'all', 0);
+ await mock.openControlBox(_converse);
+ const cbview = _converse.chatboxviews.get('controlbox');
+ cbview.querySelector('.add-contact').click()
+ const modal = _converse.api.modal.get('converse-add-contact-modal');
+ expect(modal.jid_auto_complete).toBe(undefined);
+ expect(modal.name_auto_complete).toBe(undefined);
+
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ expect(modal.querySelector('form.add-xmpp-contact')).not.toBe(null);
+ const input_jid = modal.querySelector('input[name="jid"]');
+ input_jid.value = 'someone@montague.lit';
+ modal.querySelector('button[type="submit"]').click();
+
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ const sent_stanza = await u.waitUntil(
+ () => IQ_stanzas.filter(s => sizzle(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`, s).length).pop()
+ );
+ expect(Strophe.serialize(sent_stanza)).toEqual(
+ `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit"/></query>`+
+ `</iq>`
+ );
+ }));
+
+ it("integrates with xhr_user_search_url to search for contacts",
+ mock.initConverse([], { 'xhr_user_search_url': 'http://example.org/?' },
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'all', 0);
+
+ class MockXHR extends XMLHttpRequest {
+ open () {} // eslint-disable-line
+ responseText = ''
+ send () {
+ this.responseText = JSON.stringify([
+ {"jid": "marty@mcfly.net", "fullname": "Marty McFly"},
+ {"jid": "doc@brown.com", "fullname": "Doc Brown"}
+ ]);
+ this.onload();
+ }
+ }
+ const XMLHttpRequestBackup = window.XMLHttpRequest;
+ window.XMLHttpRequest = MockXHR;
+
+ await mock.openControlBox(_converse);
+ const cbview = _converse.chatboxviews.get('controlbox');
+ cbview.querySelector('.add-contact').click()
+ const modal = _converse.api.modal.get('converse-add-contact-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+
+ // We only have autocomplete for the name input
+ expect(modal.jid_auto_complete).toBe(undefined);
+ expect(modal.name_auto_complete instanceof _converse.AutoComplete).toBe(true);
+
+ const input_el = modal.querySelector('input[name="name"]');
+ input_el.value = 'marty';
+ input_el.dispatchEvent(new Event('input'));
+ await u.waitUntil(() => modal.querySelector('.suggestion-box li'), 1000);
+ expect(modal.querySelectorAll('.suggestion-box li').length).toBe(1);
+ const suggestion = modal.querySelector('.suggestion-box li');
+ expect(suggestion.textContent).toBe('Marty McFly');
+
+ // Mock selection
+ modal.name_auto_complete.select(suggestion);
+
+ expect(input_el.value).toBe('Marty McFly');
+ expect(modal.querySelector('input[name="jid"]').value).toBe('marty@mcfly.net');
+ modal.querySelector('button[type="submit"]').click();
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
+ expect(Strophe.serialize(sent_stanza)).toEqual(
+ `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"><item jid="marty@mcfly.net" name="Marty McFly"/></query>`+
+ `</iq>`);
+ window.XMLHttpRequest = XMLHttpRequestBackup;
+ }));
+
+ it("can be configured to not provide search suggestions for XHR search results",
+ mock.initConverse([],
+ { 'autocomplete_add_contact': false,
+ 'xhr_user_search_url': 'http://example.org/?' },
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'all');
+ await mock.openControlBox(_converse);
+
+ class MockXHR extends XMLHttpRequest {
+ open () {} // eslint-disable-line
+ responseText = ''
+ send () {
+ const value = modal.querySelector('input[name="name"]').value;
+ if (value === 'existing') {
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ this.responseText = JSON.stringify([{"jid": contact_jid, "fullname": mock.cur_names[0]}]);
+ } else if (value === 'romeo') {
+ this.responseText = JSON.stringify([{"jid": "romeo@montague.lit", "fullname": "Romeo Montague"}]);
+ } else if (value === 'ambiguous') {
+ this.responseText = JSON.stringify([
+ {"jid": "marty@mcfly.net", "fullname": "Marty McFly"},
+ {"jid": "doc@brown.com", "fullname": "Doc Brown"}
+ ]);
+ } else if (value === 'insufficient') {
+ this.responseText = JSON.stringify([]);
+ } else {
+ this.responseText = JSON.stringify([{"jid": "marty@mcfly.net", "fullname": "Marty McFly"}]);
+ }
+ this.onload();
+ }
+ }
+
+ const XMLHttpRequestBackup = window.XMLHttpRequest;
+ window.XMLHttpRequest = MockXHR;
+
+ const cbview = _converse.chatboxviews.get('controlbox');
+ cbview.querySelector('.add-contact').click()
+ const modal = _converse.api.modal.get('converse-add-contact-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+
+ expect(modal.jid_auto_complete).toBe(undefined);
+ expect(modal.name_auto_complete).toBe(undefined);
+
+ const input_el = modal.querySelector('input[name="name"]');
+ input_el.value = 'ambiguous';
+ modal.querySelector('button[type="submit"]').click();
+ let feedback_el = modal.querySelector('.invalid-feedback');
+ expect(feedback_el.textContent).toBe('Sorry, could not find a contact with that name');
+ feedback_el.textContent = '';
+
+ input_el.value = 'insufficient';
+ modal.querySelector('button[type="submit"]').click();
+ feedback_el = modal.querySelector('.invalid-feedback');
+ expect(feedback_el.textContent).toBe('Sorry, could not find a contact with that name');
+ feedback_el.textContent = '';
+
+ input_el.value = 'existing';
+ modal.querySelector('button[type="submit"]').click();
+ feedback_el = modal.querySelector('.invalid-feedback');
+ expect(feedback_el.textContent).toBe('This contact has already been added');
+
+ input_el.value = 'Marty McFly';
+ modal.querySelector('button[type="submit"]').click();
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
+ expect(Strophe.serialize(sent_stanza)).toEqual(
+ `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"><item jid="marty@mcfly.net" name="Marty McFly"/></query>`+
+ `</iq>`);
+ window.XMLHttpRequest = XMLHttpRequestBackup;
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/presence.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/presence.js
new file mode 100644
index 0000000..2ef07a0
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/presence.js
@@ -0,0 +1,54 @@
+/*global mock, converse */
+
+const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
+
+describe("A sent presence stanza", function () {
+
+ beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
+ afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
+
+ it("includes the saved status message",
+ mock.initConverse([], {}, async (_converse) => {
+
+ const { u, Strophe } = converse.env;
+ mock.openControlBox(_converse);
+ spyOn(_converse.connection, 'send').and.callThrough();
+
+ const cbview = _converse.chatboxviews.get('controlbox');
+ const change_status_el = await u.waitUntil(() => cbview.querySelector('.change-status'));
+ change_status_el.click()
+ let modal = _converse.api.modal.get('converse-chat-status-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ const msg = 'My custom status';
+ modal.querySelector('input[name="status_message"]').value = msg;
+ modal.querySelector('[type="submit"]').click();
+
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ let sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop());
+ expect(Strophe.serialize(sent_presence))
+ .toBe(`<presence xmlns="jabber:client">`+
+ `<status>My custom status</status>`+
+ `<priority>0</priority>`+
+ `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
+ `</presence>`)
+ await u.waitUntil(() => modal.getAttribute('aria-hidden') === "true");
+ await u.waitUntil(() => !u.isVisible(modal));
+
+ cbview.querySelector('.change-status').click()
+ modal = _converse.api.modal.get('converse-chat-status-modal');
+ await u.waitUntil(() => modal.getAttribute('aria-hidden') === "false", 1000);
+ modal.querySelector('label[for="radio-busy"]').click(); // Change status to "dnd"
+ modal.querySelector('[type="submit"]').click();
+
+ await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).length === 2);
+ sent_presence = sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop();
+ expect(Strophe.serialize(sent_presence))
+ .toBe(
+ `<presence xmlns="jabber:client">`+
+ `<show>dnd</show>`+
+ `<status>My custom status</status>`+
+ `<priority>0</priority>`+
+ `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
+ `</presence>`)
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/protocol.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/protocol.js
new file mode 100644
index 0000000..01439c5
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/protocol.js
@@ -0,0 +1,537 @@
+/*global mock, converse */
+
+// See: https://xmpp.org/rfcs/rfc3921.html
+
+const { Strophe, stx } = converse.env;
+
+describe("The Protocol", function () {
+
+ beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+ describe("Integration of Roster Items and Presence Subscriptions", function () {
+ /* Some level of integration between roster items and presence
+ * subscriptions is normally expected by an instant messaging user
+ * regarding the user's subscriptions to and from other contacts. This
+ * section describes the level of integration that MUST be supported
+ * within an XMPP instant messaging applications.
+ *
+ * There are four primary subscription states:
+ *
+ * None -- the user does not have a subscription to the contact's
+ * presence information, and the contact does not have a subscription
+ * to the user's presence information
+ * To -- the user has a subscription to the contact's presence
+ * information, but the contact does not have a subscription to the
+ * user's presence information
+ * From -- the contact has a subscription to the user's presence
+ * information, but the user does not have a subscription to the
+ * contact's presence information
+ * Both -- both the user and the contact have subscriptions to each
+ * other's presence information (i.e., the union of 'from' and 'to')
+ *
+ * Each of these states is reflected in the roster of both the user and
+ * the contact, thus resulting in durable subscription states.
+ *
+ * The 'from' and 'to' addresses are OPTIONAL in roster pushes; if
+ * included, their values SHOULD be the full JID of the resource for
+ * that session. A client MUST acknowledge each roster push with an IQ
+ * stanza of type "result".
+ */
+ it("Subscribe to contact, contact accepts and subscribes back",
+ mock.initConverse([], { roster_groups: false }, async function (_converse) {
+
+ const { u, $iq, $pres, sizzle, Strophe } = converse.env;
+ let stanza;
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
+ await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'), 300);
+ /* The process by which a user subscribes to a contact, including
+ * the interaction between roster items and subscription states.
+ */
+ mock.openControlBox(_converse);
+ const cbview = _converse.chatboxviews.get('controlbox');
+
+ spyOn(_converse.roster, "addAndSubscribe").and.callThrough();
+ spyOn(_converse.roster, "addContactToRoster").and.callThrough();
+ spyOn(_converse.roster, "sendContactAddIQ").and.callThrough();
+ spyOn(_converse.api.vcard, "get").and.callThrough();
+
+ cbview.querySelector('.add-contact').click()
+ const modal = _converse.api.modal.get('converse-add-contact-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ modal.delegateEvents();
+
+ // Fill in the form and submit
+ const form = modal.querySelector('form.add-xmpp-contact');
+ form.querySelector('input[name="jid"]').value = 'contact@example.org';
+ form.querySelector('input[name="name"]').value = 'Chris Contact';
+ form.querySelector('input[name="group"]').value = 'My Buddies';
+ form.querySelector('[type="submit"]').click();
+
+ /* In preparation for being able to render the contact in the
+ * user's client interface and for the server to keep track of the
+ * subscription, the user's client SHOULD perform a "roster set"
+ * for the new roster item.
+ */
+ expect(_converse.roster.addAndSubscribe).toHaveBeenCalled();
+ expect(_converse.roster.addContactToRoster).toHaveBeenCalled();
+
+ /* The request consists of sending an IQ
+ * stanza of type='set' containing a <query/> element qualified by
+ * the 'jabber:iq:roster' namespace, which in turn contains an
+ * <item/> element that defines the new roster item; the <item/>
+ * element MUST possess a 'jid' attribute, MAY possess a 'name'
+ * attribute, MUST NOT possess a 'subscription' attribute, and MAY
+ * contain one or more <group/> child elements:
+ *
+ * <iq type='set' id='set1'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item
+ * jid='contact@example.org'
+ * name='MyContact'>
+ * <group>MyBuddies</group>
+ * </item>
+ * </query>
+ * </iq>
+ */
+ await mock.waitForRoster(_converse, 'all', 0);
+ expect(_converse.roster.sendContactAddIQ).toHaveBeenCalled();
+
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ const roster_set_stanza = IQ_stanzas.filter(s => sizzle('query[xmlns="jabber:iq:roster"]', s)).pop();
+
+ expect(Strophe.serialize(roster_set_stanza)).toBe(
+ `<iq id="${roster_set_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster">`+
+ `<item jid="contact@example.org" name="Chris Contact">`+
+ `<group>My Buddies</group>`+
+ `</item>`+
+ `</query>`+
+ `</iq>`
+ );
+
+ const sent_stanzas = [];
+ let sent_stanza;
+ spyOn(_converse.connection, 'send').and.callFake(function (stanza) {
+ sent_stanza = stanza;
+ sent_stanzas.push(stanza);
+ });
+
+ /* As a result, the user's server (1) MUST initiate a roster push
+ * for the new roster item to all available resources associated
+ * with the user that have requested the roster, setting the
+ * 'subscription' attribute to a value of "none"; and (2) MUST
+ * reply to the sending resource with an IQ result indicating the
+ * success of the roster set:
+ *
+ * <iq type='set'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item
+ * jid='contact@example.org'
+ * subscription='none'
+ * name='MyContact'>
+ * <group>MyBuddies</group>
+ * </item>
+ * </query>
+ * </iq>
+ */
+ _converse.connection._dataRecv(mock.createRequest(
+ $iq({'type': 'set'})
+ .c('query', {'xmlns': 'jabber:iq:roster'})
+ .c('item', {
+ 'jid': 'contact@example.org',
+ 'subscription': 'none',
+ 'name': 'Chris Contact'
+ }).c('group').t('My Buddies')
+ ));
+
+ _converse.connection._dataRecv(mock.createRequest(
+ $iq({'type': 'result', 'id': roster_set_stanza.getAttribute('id')})
+ ));
+
+ await u.waitUntil(() => _converse.roster.length === 1);
+
+ // A contact should now have been created
+ const contact = _converse.roster.at(0);
+ expect(contact.get('jid')).toBe('contact@example.org');
+ expect(contact.get('nickname')).toBe('Chris Contact');
+ expect(contact.get('groups')).toEqual(['My Buddies']);
+ await u.waitUntil(() => contact.initialized);
+
+ /* To subscribe to the contact's presence information,
+ * the user's client MUST send a presence stanza of
+ * type='subscribe' to the contact:
+ *
+ * <presence to='contact@example.org' type='subscribe'/>
+ */
+ const sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => s.matches('presence')).pop());
+ expect(sent_presence).toEqualStanza(stx`
+ <presence to="contact@example.org" type="subscribe" xmlns="jabber:client">
+ <nick xmlns="http://jabber.org/protocol/nick">Romeo</nick>
+ <priority>0</priority>
+ <c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>
+ </presence>
+ `);
+
+ /* As a result, the user's server MUST initiate a second roster
+ * push to all of the user's available resources that have
+ * requested the roster, setting the contact to the pending
+ * sub-state of the 'none' subscription state; The pending
+ * sub-state is denoted by the inclusion of the ask='subscribe'
+ * attribute in the roster item:
+ *
+ * <iq type='set'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item
+ * jid='contact@example.org'
+ * subscription='none'
+ * ask='subscribe'
+ * name='MyContact'>
+ * <group>MyBuddies</group>
+ * </item>
+ * </query>
+ * </iq>
+ */
+ _converse.connection._dataRecv(mock.createRequest(
+ $iq({'type': 'set', 'from': _converse.bare_jid})
+ .c('query', {'xmlns': 'jabber:iq:roster'})
+ .c('item', {
+ 'jid': 'contact@example.org',
+ 'subscription': 'none',
+ 'ask': 'subscribe',
+ 'name': 'Chris Contact'
+ }).c('group').t('My Buddies')
+ ));
+
+ const rosterview = document.querySelector('converse-roster');
+
+ // Check that the user is now properly shown as a pending contact in the roster.
+ await u.waitUntil(() => {
+ const header = sizzle('a:contains("Pending contacts")', rosterview).pop();
+ const contacts = Array.from(header?.parentElement.querySelectorAll('li') ?? []).filter(u.isVisible);
+ return contacts.length;
+ }, 600);
+
+ let header = sizzle('a:contains("Pending contacts")', rosterview).pop();
+ let contacts = header.parentElement.querySelectorAll('li');
+ expect(contacts.length).toBe(1);
+ expect(u.isVisible(contacts[0])).toBe(true);
+ sent_stanza = ""; // Reset
+
+ spyOn(contact, "ackSubscribe").and.callThrough();
+
+ /* Here we assume the "happy path" that the contact
+ * approves the subscription request
+ *
+ * <presence
+ * to='user@example.com'
+ * from='contact@example.org'
+ * type='subscribed'/>
+ */
+ _converse.connection._dataRecv(mock.createRequest(
+ stanza = $pres({
+ 'to': _converse.bare_jid,
+ 'from': 'contact@example.org',
+ 'type': 'subscribed'
+ })
+ ));
+
+ /* Upon receiving the presence stanza of type "subscribed",
+ * the user SHOULD acknowledge receipt of that
+ * subscription state notification by sending a presence
+ * stanza of type "subscribe".
+ */
+ expect(contact.ackSubscribe).toHaveBeenCalled();
+ expect(Strophe.serialize(sent_stanza)).toBe( // Strophe adds the xmlns attr (although not in spec)
+ `<presence to="contact@example.org" type="subscribe" xmlns="jabber:client"/>`
+ );
+
+ /* The user's server MUST initiate a roster push to all of the user's
+ * available resources that have requested the roster,
+ * containing an updated roster item for the contact with
+ * the 'subscription' attribute set to a value of "to";
+ *
+ * <iq type='set'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item
+ * jid='contact@example.org'
+ * subscription='to'
+ * name='MyContact'>
+ * <group>MyBuddies</group>
+ * </item>
+ * </query>
+ * </iq>
+ */
+ const IQ_id = _converse.connection.getUniqueId('roster');
+ stanza = $iq({'type': 'set', 'id': IQ_id})
+ .c('query', {'xmlns': 'jabber:iq:roster'})
+ .c('item', {
+ 'jid': 'contact@example.org',
+ 'subscription': 'to',
+ 'name': 'Nicky'});
+
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ // Check that the IQ set was acknowledged.
+ expect(Strophe.serialize(sent_stanza)).toBe( // Strophe adds the xmlns attr (although not in spec)
+ `<iq from="romeo@montague.lit/orchard" id="${IQ_id}" type="result" xmlns="jabber:client"/>`
+ );
+
+ // The contact should now be visible as an existing contact (but still offline).
+ await u.waitUntil(() => {
+ const header = sizzle('a:contains("My contacts")', rosterview).pop();
+ return sizzle('li', header?.parentNode).filter(l => u.isVisible(l)).length;
+ }, 600);
+ header = sizzle('a:contains("My contacts")', rosterview);
+ expect(header.length).toBe(1);
+ expect(u.isVisible(header[0])).toBeTruthy();
+ contacts = header[0].parentNode.querySelectorAll('li');
+ expect(contacts.length).toBe(1);
+ // Check that it has the right classes and text
+ expect(u.hasClass('to', contacts[0])).toBeTruthy();
+ expect(u.hasClass('both', contacts[0])).toBeFalsy();
+ expect(u.hasClass('current-xmpp-contact', contacts[0])).toBeTruthy();
+
+ await u.waitUntil(() => contacts[0].textContent.trim() === 'Nicky');
+
+ expect(contact.presence.get('show')).toBe('offline');
+
+ /* <presence
+ * from='contact@example.org/resource'
+ * to='user@example.com/resource'/>
+ */
+ stanza = $pres({'to': _converse.bare_jid, 'from': 'contact@example.org/resource'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ // Now the contact should also be online.
+ expect(contact.presence.get('show')).toBe('online');
+
+ /* Section 8.3. Creating a Mutual Subscription
+ *
+ * If the contact wants to create a mutual subscription,
+ * the contact MUST send a subscription request to the
+ * user.
+ *
+ * <presence from='contact@example.org' to='user@example.com' type='subscribe'/>
+ */
+ spyOn(contact, 'authorize').and.callThrough();
+ spyOn(_converse.roster, 'handleIncomingSubscription').and.callThrough();
+ stanza = $pres({
+ 'to': _converse.bare_jid,
+ 'from': 'contact@example.org/resource',
+ 'type': 'subscribe'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ expect(_converse.roster.handleIncomingSubscription).toHaveBeenCalled();
+
+ /* The user's client MUST send a presence stanza of type
+ * "subscribed" to the contact in order to approve the
+ * subscription request.
+ *
+ * <presence to='contact@example.org' type='subscribed'/>
+ */
+ expect(contact.authorize).toHaveBeenCalled();
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<presence to="contact@example.org" type="subscribed" xmlns="jabber:client"/>`
+ );
+
+ /* As a result, the user's server MUST initiate a
+ * roster push containing a roster item for the
+ * contact with the 'subscription' attribute set to
+ * a value of "both".
+ *
+ * <iq type='set'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item
+ * jid='contact@example.org'
+ * subscription='both'
+ * name='MyContact'>
+ * <group>MyBuddies</group>
+ * </item>
+ * </query>
+ * </iq>
+ */
+ _converse.connection._dataRecv(mock.createRequest(
+ $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'})
+ .c('item', {
+ 'jid': 'contact@example.org',
+ 'subscription': 'both',
+ 'name': 'contact@example.org'})
+ ));
+
+ // The class on the contact will now have switched.
+ await u.waitUntil(() => !u.hasClass('to', contacts[0]));
+ expect(u.hasClass('both', contacts[0])).toBe(true);
+
+ }));
+
+ it("Alternate Flow: Contact Declines Subscription Request",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const { $iq, $pres } = converse.env;
+ /* The process by which a user subscribes to a contact, including
+ * the interaction between roster items and subscription states.
+ */
+ var contact, stanza, sent_stanza, sent_IQ;
+ await mock.waitForRoster(_converse, 'current', 0);
+ mock.openControlBox(_converse);
+ // Add a new roster contact via roster push
+ stanza = $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'})
+ .c('item', {
+ 'jid': 'contact@example.org',
+ 'subscription': 'none',
+ 'ask': 'subscribe',
+ 'name': 'contact@example.org'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ // A pending contact should now exist.
+ contact = _converse.roster.get('contact@example.org');
+ expect(_converse.roster.get('contact@example.org') instanceof _converse.RosterContact).toBeTruthy();
+ spyOn(contact, "ackUnsubscribe").and.callThrough();
+
+ spyOn(_converse.connection, 'send').and.callFake(stanza => { sent_stanza = stanza });
+ spyOn(_converse.connection, 'sendIQ').and.callFake(iq => { sent_IQ = iq });
+ /* We now assume the contact declines the subscription
+ * requests.
+ *
+ * Upon receiving the presence stanza of type "unsubscribed"
+ * addressed to the user, the user's server (1) MUST deliver
+ * that presence stanza to the user and (2) MUST initiate a
+ * roster push to all of the user's available resources that
+ * have requested the roster, containing an updated roster
+ * item for the contact with the 'subscription' attribute
+ * set to a value of "none" and with no 'ask' attribute:
+ *
+ * <presence
+ * from='contact@example.org'
+ * to='user@example.com'
+ * type='unsubscribed'/>
+ *
+ * <iq type='set'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item
+ * jid='contact@example.org'
+ * subscription='none'
+ * name='MyContact'>
+ * <group>MyBuddies</group>
+ * </item>
+ * </query>
+ * </iq>
+ */
+ // FIXME: also add the <iq>
+ stanza = $pres({
+ 'to': _converse.bare_jid,
+ 'from': 'contact@example.org',
+ 'type': 'unsubscribed'
+ });
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ /* Upon receiving the presence stanza of type "unsubscribed",
+ * the user SHOULD acknowledge receipt of that subscription
+ * state notification through either "affirming" it by
+ * sending a presence stanza of type "unsubscribe
+ */
+ expect(contact.ackUnsubscribe).toHaveBeenCalled();
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<presence to="contact@example.org" type="unsubscribe" xmlns="jabber:client"/>`
+ );
+
+ /* _converse.js will then also automatically remove the
+ * contact from the user's roster.
+ */
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster">`+
+ `<item jid="contact@example.org" subscription="remove"/>`+
+ `</query>`+
+ `</iq>`
+ );
+ }));
+
+ it("Unsubscribe to a contact when subscription is mutual",
+ mock.initConverse([], { roster_groups: false }, async function (_converse) {
+
+ const { u, $iq, sizzle, Strophe } = converse.env;
+ const jid = 'abram@montague.lit';
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+ spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
+ // We now have a contact we want to remove
+ expect(_converse.roster.get(jid) instanceof _converse.RosterContact).toBeTruthy();
+
+ const rosterview = document.querySelector('converse-roster');
+ const header = sizzle('a:contains("My contacts")', rosterview).pop();
+ await u.waitUntil(() => header.parentElement.querySelectorAll('li').length);
+
+ // remove the first user
+ header.parentElement.querySelector('li .remove-xmpp-contact').click();
+ expect(_converse.api.confirm).toHaveBeenCalled();
+
+ /* Section 8.6 Removing a Roster Item and Cancelling All
+ * Subscriptions
+ *
+ * First the user is removed from the roster
+ * Because there may be many steps involved in completely
+ * removing a roster item and cancelling subscriptions in
+ * both directions, the roster management protocol includes
+ * a "shortcut" method for doing so. The process may be
+ * initiated no matter what the current subscription state
+ * is by sending a roster set containing an item for the
+ * contact with the 'subscription' attribute set to a value
+ * of "remove":
+ *
+ * <iq type='set' id='remove1'>
+ * <query xmlns='jabber:iq:roster'>
+ * <item jid='contact@example.org' subscription='remove'/>
+ * </query>
+ * </iq>
+ */
+ const iq_stanzas = _converse.connection.IQ_stanzas;
+ await u.waitUntil(() => Strophe.serialize(iq_stanzas.at(-1)) ===
+ `<iq id="${iq_stanzas.at(-1).getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster">`+
+ `<item jid="abram@montague.lit" subscription="remove"/>`+
+ `</query>`+
+ `</iq>`);
+ const sent_iq = iq_stanzas.at(-1);
+
+ // Receive confirmation from the contact's server
+ // <iq type='result' id='remove1'/>
+ const stanza = $iq({'type': 'result', 'id': sent_iq.getAttribute('id')});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ // Our contact has now been removed
+ await u.waitUntil(() => typeof _converse.roster.get(jid) === "undefined");
+ }));
+
+ it("Receiving a subscription request", mock.initConverse(
+ [], {}, async function (_converse) {
+
+ const { u, $pres, sizzle, Strophe } = converse.env;
+ spyOn(_converse.api, "trigger").and.callThrough();
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+ /* <presence
+ * from='user@example.com'
+ * to='contact@example.org'
+ * type='subscribe'/>
+ */
+ const stanza = $pres({
+ 'to': _converse.bare_jid,
+ 'from': 'contact@example.org',
+ 'type': 'subscribe'
+ }).c('nick', {
+ 'xmlns': Strophe.NS.NICK,
+ }).t('Clint Contact');
+
+
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => {
+ const header = sizzle('a:contains("Contact requests")', rosterview).pop();
+ return Array.from(header?.parentElement.querySelectorAll('li') ?? []).filter(u.isVisible)?.length;
+ }, 500);
+ expect(_converse.api.trigger).toHaveBeenCalledWith('contactRequest', jasmine.any(Object));
+
+ const header = sizzle('a:contains("Contact requests")', rosterview).pop();
+ expect(u.isVisible(header)).toBe(true);
+ const contacts = header.nextElementSibling.querySelectorAll('li');
+ expect(contacts.length).toBe(1);
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/roster.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/roster.js
new file mode 100644
index 0000000..e52c833
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/roster.js
@@ -0,0 +1,1365 @@
+/*global mock, converse, _ */
+
+const $iq = converse.env.$iq;
+const $pres = converse.env.$pres;
+const Strophe = converse.env.Strophe;
+const sizzle = converse.env.sizzle;
+const u = converse.env.utils;
+
+const checkHeaderToggling = async function (group) {
+ const toggle = group.querySelector('a.group-toggle');
+ expect(u.isVisible(group)).toBeTruthy();
+ expect(group.querySelectorAll('ul.collapsed').length).toBe(0);
+ expect(u.hasClass('fa-caret-right', toggle.firstElementChild)).toBeFalsy();
+ expect(u.hasClass('fa-caret-down', toggle.firstElementChild)).toBeTruthy();
+ toggle.click();
+
+ await u.waitUntil(() => group.querySelectorAll('ul.collapsed').length === 1);
+ expect(u.hasClass('fa-caret-right', toggle.firstElementChild)).toBeTruthy();
+ expect(u.hasClass('fa-caret-down', toggle.firstElementChild)).toBeFalsy();
+ toggle.click();
+ await u.waitUntil(() => group.querySelectorAll('li').length === _.filter(group.querySelectorAll('li'), u.isVisible).length);
+ expect(u.hasClass('fa-caret-right', toggle.firstElementChild)).toBeFalsy();
+ expect(u.hasClass('fa-caret-down', toggle.firstElementChild)).toBeTruthy();
+};
+
+
+describe("The Contacts Roster", function () {
+
+ it("verifies the origin of roster pushes", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ // See: https://gultsch.de/gajim_roster_push_and_message_interception.html
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.waitForRoster(_converse, 'current', 1);
+ expect(_converse.roster.models.length).toBe(1);
+ expect(_converse.roster.at(0).get('jid')).toBe(contact_jid);
+
+ spyOn(converse.env.log, 'warn');
+ let roster_push = u.toStanza(`
+ <iq type="set" to="${_converse.jid}" from="eve@siacs.eu">
+ <query xmlns='jabber:iq:roster'>
+ <item subscription="remove" jid="${contact_jid}"/>
+ </query>
+ </iq>`);
+ _converse.connection._dataRecv(mock.createRequest(roster_push));
+ expect(converse.env.log.warn.calls.count()).toBe(1);
+ expect(converse.env.log.warn).toHaveBeenCalledWith(
+ `Ignoring roster illegitimate roster push message from ${roster_push.getAttribute('from')}`
+ );
+ roster_push = u.toStanza(`
+ <iq type="set" to="${_converse.jid}" from="eve@siacs.eu">
+ <query xmlns='jabber:iq:roster'>
+ <item subscription="both" jid="eve@siacs.eu" name="${mock.cur_names[0]}" />
+ </query>
+ </iq>`);
+ _converse.connection._dataRecv(mock.createRequest(roster_push));
+ expect(converse.env.log.warn.calls.count()).toBe(2);
+ expect(converse.env.log.warn).toHaveBeenCalledWith(
+ `Ignoring roster illegitimate roster push message from ${roster_push.getAttribute('from')}`
+ );
+ expect(_converse.roster.models.length).toBe(1);
+ expect(_converse.roster.at(0).get('jid')).toBe(contact_jid);
+ }));
+
+ it("is populated once we have registered a presence handler", mock.initConverse([], {}, async function (_converse) {
+ const IQs = _converse.connection.IQ_stanzas;
+ const stanza = await u.waitUntil(
+ () => _.filter(IQs, iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop());
+
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq id="${stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"/>`+
+ `</iq>`);
+ const result = $iq({
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ 'id': stanza.getAttribute('id')
+ }).c('query', {
+ 'xmlns': 'jabber:iq:roster'
+ }).c('item', {'jid': 'nurse@example.com'}).up()
+ .c('item', {'jid': 'romeo@example.com'})
+ _converse.connection._dataRecv(mock.createRequest(result));
+ await u.waitUntil(() => _converse.promises['rosterContactsFetched'].isResolved === true);
+ }));
+
+ it("supports roster versioning", mock.initConverse([], {}, async function (_converse) {
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ let stanza = await u.waitUntil(
+ () => _.filter(IQ_stanzas, iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop()
+ );
+ expect(_converse.roster.data.get('version')).toBeUndefined();
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq id="${stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"/>`+
+ `</iq>`);
+ let result = $iq({
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ 'id': stanza.getAttribute('id')
+ }).c('query', {
+ 'xmlns': 'jabber:iq:roster',
+ 'ver': 'ver7'
+ }).c('item', {'jid': 'nurse@example.com'}).up()
+ .c('item', {'jid': 'romeo@example.com'})
+ _converse.connection._dataRecv(mock.createRequest(result));
+
+ await u.waitUntil(() => _converse.roster.models.length > 1);
+ expect(_converse.roster.data.get('version')).toBe('ver7');
+ expect(_converse.roster.models.length).toBe(2);
+
+ _converse.roster.fetchFromServer();
+ stanza = _converse.connection.IQ_stanzas.pop();
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq id="${stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+
+ `<query ver="ver7" xmlns="jabber:iq:roster"/>`+
+ `</iq>`);
+
+ result = $iq({
+ 'to': _converse.connection.jid,
+ 'type': 'result',
+ 'id': stanza.getAttribute('id')
+ });
+ _converse.connection._dataRecv(mock.createRequest(result));
+
+ const roster_push = $iq({
+ 'to': _converse.connection.jid,
+ 'type': 'set',
+ }).c('query', {'xmlns': 'jabber:iq:roster', 'ver': 'ver34'})
+ .c('item', {'jid': 'romeo@example.com', 'subscription': 'remove'});
+ _converse.connection._dataRecv(mock.createRequest(roster_push));
+ expect(_converse.roster.data.get('version')).toBe('ver34');
+ expect(_converse.roster.models.length).toBe(1);
+ expect(_converse.roster.at(0).get('jid')).toBe('nurse@example.com');
+ }));
+
+ it("also contains contacts with subscription of none", mock.initConverse(
+ [], {}, async function (_converse) {
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop());
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ to: _converse.connection.jid,
+ type: 'result',
+ id: stanza.getAttribute('id')
+ }).c('query', {
+ xmlns: 'jabber:iq:roster',
+ }).c('item', {
+ jid: 'juliet@example.net',
+ name: 'Juliet',
+ subscription:'both'
+ }).c('group').t('Friends').up().up()
+ .c('item', {
+ jid: 'mercutio@example.net',
+ name: 'Mercutio',
+ subscription: 'from'
+ }).c('group').t('Friends').up().up()
+ .c('item', {
+ jid: 'lord.capulet@example.net',
+ name: 'Lord Capulet',
+ subscription:'none'
+ }).c('group').t('Acquaintences')));
+
+ while (sent_IQs.length) sent_IQs.pop();
+
+ await u.waitUntil(() => _converse.roster.length === 3);
+ expect(_converse.roster.pluck('jid')).toEqual(['juliet@example.net', 'mercutio@example.net', 'lord.capulet@example.net']);
+ expect(_converse.roster.get('lord.capulet@example.net').get('subscription')).toBe('none');
+ }));
+
+ it("can be refreshed", mock.initConverse(
+ [], {}, async function (_converse) {
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ let stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop());
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ to: _converse.connection.jid,
+ type: 'result',
+ id: stanza.getAttribute('id')
+ }).c('query', {
+ xmlns: 'jabber:iq:roster',
+ }).c('item', {
+ jid: 'juliet@example.net',
+ name: 'Juliet',
+ subscription:'both'
+ }).c('group').t('Friends').up().up()
+ .c('item', {
+ jid: 'mercutio@example.net',
+ name: 'Mercutio',
+ subscription:'from'
+ }).c('group').t('Friends')));
+
+ while (sent_IQs.length) sent_IQs.pop();
+
+ await u.waitUntil(() => _converse.roster.length === 2);
+ expect(_converse.roster.pluck('jid')).toEqual(['juliet@example.net', 'mercutio@example.net']);
+
+ const rosterview = document.querySelector('converse-roster');
+ const sync_button = rosterview.querySelector('.sync-contacts');
+ sync_button.click();
+
+ stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop());
+ _converse.connection._dataRecv(mock.createRequest($iq({
+ to: _converse.connection.jid,
+ type: 'result',
+ id: stanza.getAttribute('id')
+ }).c('query', {
+ xmlns: 'jabber:iq:roster',
+ }).c('item', {
+ jid: 'juliet@example.net',
+ name: 'Juliet',
+ subscription:'both'
+ }).c('group').t('Friends').up().up()
+ .c('item', {
+ jid: 'lord.capulet@example.net',
+ name: 'Lord Capulet',
+ subscription:'from'
+ }).c('group').t('Acquaintences')));
+
+ await u.waitUntil(() => _converse.roster.pluck('jid').includes('lord.capulet@example.net'));
+ expect(_converse.roster.pluck('jid')).toEqual(['juliet@example.net', 'lord.capulet@example.net']);
+ }));
+
+ it("will also show contacts added afterwards", mock.initConverse([], {}, async function (_converse) {
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+
+ const rosterview = document.querySelector('converse-roster');
+ const filter = rosterview.querySelector('.roster-filter');
+ const roster = rosterview.querySelector('.roster-contacts');
+
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 800);
+ filter.value = "la";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 4), 800);
+
+ // Five roster contact is now visible
+ const visible_contacts = sizzle('li', roster).filter(u.isVisible);
+ expect(visible_contacts.length).toBe(4);
+ let visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
+ expect(visible_groups.length).toBe(4);
+ expect(visible_groups[0].textContent.trim()).toBe('Colleagues');
+ expect(visible_groups[1].textContent.trim()).toBe('Family');
+ expect(visible_groups[2].textContent.trim()).toBe('friends & acquaintences');
+ expect(visible_groups[3].textContent.trim()).toBe('ænemies');
+
+ _converse.roster.create({
+ jid: 'lad@montague.lit',
+ subscription: 'both',
+ ask: null,
+ groups: ['newgroup'],
+ fullname: 'Lad'
+ });
+ await u.waitUntil(() => sizzle('.roster-group[data-group="newgroup"] li', roster).length, 300);
+ visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
+ expect(visible_groups.length).toBe(5);
+ expect(visible_groups[0].textContent.trim()).toBe('Colleagues');
+ expect(visible_groups[1].textContent.trim()).toBe('Family');
+ expect(visible_groups[2].textContent.trim()).toBe('friends & acquaintences');
+ expect(visible_groups[3].textContent.trim()).toBe('newgroup');
+ expect(visible_groups[4].textContent.trim()).toBe('ænemies');
+ expect(roster.querySelectorAll('.roster-group').length).toBe(5);
+ }));
+
+ describe("The live filter", function () {
+
+ it("will only appear when roster contacts flow over the visible area",
+ mock.initConverse([], {}, async function (_converse) {
+
+ expect(document.querySelector('converse-roster')).toBe(null);
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const view = _converse.chatboxviews.get('controlbox');
+ const flyout = view.querySelector('.box-flyout');
+ const panel = flyout.querySelector('.controlbox-pane');
+ function hasScrollBar (el) {
+ return el.isConnected && flyout.offsetHeight < panel.scrollHeight;
+ }
+ const rosterview = document.querySelector('converse-roster');
+ const filter = rosterview.querySelector('.roster-filter');
+ const el = rosterview.querySelector('.roster-contacts');
+ await u.waitUntil(() => hasScrollBar(el) ? u.isVisible(filter) : !u.isVisible(filter), 900);
+ }));
+
+ it("can be used to filter the contacts shown",
+ mock.initConverse(
+ [], {'roster_groups': true},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+ const rosterview = document.querySelector('converse-roster');
+ let filter = rosterview.querySelector('.roster-filter');
+ const roster = rosterview.querySelector('.roster-contacts');
+
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
+ expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5);
+ filter.value = "juliet";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 1), 600);
+ // Only one roster contact is now visible
+ let visible_contacts = sizzle('li', roster).filter(u.isVisible);
+ expect(visible_contacts.length).toBe(1);
+ expect(visible_contacts.pop().textContent.trim()).toBe('Juliet Capulet');
+ // Only one foster group is still visible
+ expect(sizzle('.roster-group', roster).filter(u.isVisible).length).toBe(1);
+ 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.value = "j";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 2), 700);
+
+ visible_contacts = sizzle('li', roster).filter(u.isVisible);
+ expect(visible_contacts.length).toBe(2);
+
+ let visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle'));
+ expect(visible_groups.length).toBe(2);
+ expect(visible_groups[0].textContent.trim()).toBe('friends & acquaintences');
+ expect(visible_groups[1].textContent.trim()).toBe('Ungrouped');
+
+ filter = rosterview.querySelector('.roster-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.value = "";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+ await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600);
+ expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5);
+ }));
+
+ it("can be used to filter the groups shown", mock.initConverse([], {'roster_groups': true}, async function (_converse) {
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+ const rosterview = document.querySelector('converse-roster');
+ const roster = rosterview.querySelector('.roster-contacts');
+
+ const button = rosterview.querySelector('converse-icon[data-type="groups"]');
+ button.click();
+
+ 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');
+ filter.value = "colleagues";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+
+ await u.waitUntil(() => (sizzle('div.roster-group:not(.collapsed)', roster).length === 1), 600);
+ expect(sizzle('div.roster-group:not(.collapsed)', roster).pop().firstElementChild.textContent.trim()).toBe('Colleagues');
+ expect(sizzle('div.roster-group:not(.collapsed) li', roster).filter(u.isVisible).length).toBe(6);
+ // 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.value = "xxx";
+ u.triggerEvent(filter, "keydown", "KeyboardEvent");
+
+ await u.waitUntil(() => (roster.querySelectorAll('.roster-group').length === 0), 700);
+
+ filter = rosterview.querySelector('.roster-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);
+ expect(sizzle('div.roster-group', roster).length).toBe(0);
+ }));
+
+ it("has a button with which its contents can be cleared",
+ mock.initConverse([], {'roster_groups': true}, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+
+ const rosterview = document.querySelector('converse-roster');
+ const filter = rosterview.querySelector('.roster-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();
+
+ const isHidden = (el) => u.hasClass('hidden', el);
+ await u.waitUntil(() => !isHidden(rosterview.querySelector('.roster-filter-form .clear-input')), 900);
+ rosterview.querySelector('.clear-input').click();
+ await u.waitUntil(() => document.querySelector('.roster-filter').value == '');
+ }));
+
+ // Disabling for now, because since recently this test consistently
+ // fails on Travis and I couldn't get it to pass there.
+ xit("can be used to filter contacts by their chat state",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ mock.waitForRoster(_converse, 'all');
+ let jid = mock.cur_names[3].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'online');
+ jid = mock.cur_names[4].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'dnd');
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ const button = rosterview.querySelector('span[data-type="state"]');
+ button.click();
+ const roster = rosterview.querySelector('.roster-contacts');
+ await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).length === 15, 900);
+ const filter = rosterview.querySelector('.state-type');
+ expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5);
+ filter.value = "online";
+ u.triggerEvent(filter, 'change');
+
+ await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).length === 1, 900);
+ expect(sizzle('li', roster).filter(u.isVisible).pop().textContent.trim()).toBe('Lord Montague');
+ await u.waitUntil(() => sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length === 1, 900);
+ const ul = sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).pop();
+ expect(ul.parentElement.firstElementChild.textContent.trim()).toBe('friends & acquaintences');
+ filter.value = "dnd";
+ u.triggerEvent(filter, 'change');
+ await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).pop().textContent.trim() === 'Friar Laurence', 900);
+ expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(1);
+ }));
+ });
+
+ describe("A Roster Group", function () {
+
+ it("is created to show contacts with unread messages",
+ mock.initConverse(
+ [], {'roster_groups': true},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'all');
+ await mock.createContacts(_converse, 'requesting');
+
+ // Check that the groups appear alphabetically and that
+ // requesting and pending contacts are last.
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 6);
+ let group_titles = sizzle('.roster-group a.group-toggle', rosterview).map(o => o.textContent.trim());
+ expect(group_titles).toEqual([
+ "Contact requests",
+ "Colleagues",
+ "Family",
+ "friends & acquaintences",
+ "ænemies",
+ "Ungrouped",
+ ]);
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const contact = await _converse.api.contacts.get(contact_jid);
+ contact.save({'num_unread': 5});
+
+ await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 7);
+ group_titles = sizzle('.roster-group a.group-toggle', rosterview).map(o => o.textContent.trim());
+
+ expect(group_titles).toEqual([
+ "New messages",
+ "Contact requests",
+ "Colleagues",
+ "Family",
+ "friends & acquaintences",
+ "ænemies",
+ "Ungrouped"
+ ]);
+ const contacts = sizzle('.roster-group[data-group="New messages"] li', rosterview);
+ expect(contacts.length).toBe(1);
+ expect(contacts[0].querySelector('.contact-name').textContent).toBe("Mercutio");
+ expect(contacts[0].querySelector('.msgs-indicator').textContent).toBe("5");
+
+ contact.save({'num_unread': 0});
+ await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 6);
+ group_titles = sizzle('.roster-group a.group-toggle', rosterview).map(o => o.textContent.trim());
+ expect(group_titles).toEqual([
+ "Contact requests",
+ "Colleagues",
+ "Family",
+ "friends & acquaintences",
+ "ænemies",
+ "Ungrouped"
+ ]);
+ }));
+
+
+ it("can be used to organize existing contacts",
+ mock.initConverse(
+ [], {'roster_groups': true},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'all');
+ await mock.createContacts(_converse, 'requesting');
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 6);
+ const group_titles = sizzle('.roster-group a.group-toggle', rosterview).map(o => o.textContent.trim());
+ expect(group_titles).toEqual([
+ "Contact requests",
+ "Colleagues",
+ "Family",
+ "friends & acquaintences",
+ "ænemies",
+ "Ungrouped",
+ ]);
+ // Check that usernames appear alphabetically per group
+ Object.keys(mock.groups).forEach(name => {
+ const contacts = sizzle('.roster-group[data-group="'+name+'"] ul', rosterview);
+ const names = contacts.map(o => o.textContent.trim());
+ expect(names).toEqual(_.clone(names).sort());
+ });
+ }));
+
+ it("gets created when a contact's \"groups\" attribute changes",
+ mock.initConverse([], {'roster_groups': true}, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current', 0);
+
+ _converse.roster.create({
+ jid: 'groupchanger@montague.lit',
+ subscription: 'both',
+ ask: null,
+ groups: ['firstgroup'],
+ fullname: 'George Groupchanger'
+ });
+
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 1);
+ let group_titles = await u.waitUntil(() => {
+ const toggles = sizzle('.roster-group a.group-toggle', rosterview);
+ if (toggles.reduce((result, t) => result && u.isVisible(t), true)) {
+ return toggles.map(o => o.textContent.trim());
+ } else {
+ return false;
+ }
+ }, 1000);
+ expect(group_titles).toEqual(['firstgroup']);
+
+ const contact = _converse.roster.get('groupchanger@montague.lit');
+ contact.set({'groups': ['secondgroup']});
+ await u.waitUntil(() => sizzle('.roster-group[data-group="secondgroup"] a.group-toggle', rosterview).length);
+ group_titles = await u.waitUntil(() => {
+ const toggles = sizzle('.roster-group[data-group="secondgroup"] a.group-toggle', rosterview);
+ if (toggles.reduce((result, t) => result && u.isVisible(t), true)) {
+ return toggles.map(o => o.textContent.trim());
+ } else {
+ return false;
+ }
+ }, 1000);
+ expect(group_titles).toEqual(['secondgroup']);
+ }));
+
+ it("can share contacts with other roster groups",
+ mock.initConverse( [], {'roster_groups': true}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ const groups = ['Colleagues', 'friends'];
+ await mock.openControlBox(_converse);
+ for (let i=0; i<mock.cur_names.length; i++) {
+ _converse.roster.create({
+ jid: mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'both',
+ ask: null,
+ groups: groups,
+ fullname: mock.cur_names[i]
+ });
+ }
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => (sizzle('li', rosterview).filter(u.isVisible).length === 30));
+ // Check that usernames appear alphabetically per group
+ groups.forEach(name => {
+ const contacts = sizzle('.roster-group[data-group="'+name+'"] ul li', rosterview);
+ const names = contacts.map(o => o.textContent.trim());
+ expect(names).toEqual(_.clone(names).sort());
+ expect(names.length).toEqual(mock.cur_names.length);
+ });
+ }));
+
+ it("remembers whether it is closed or opened",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.openControlBox(_converse);
+
+ let i=0, j=0;
+ const groups = {
+ 'Colleagues': 3,
+ 'friends & acquaintences': 3,
+ 'Ungrouped': 2
+ };
+ Object.keys(groups).forEach(function (name) {
+ j = i;
+ for (i=j; i<j+groups[name]; i++) {
+ _converse.roster.create({
+ jid: mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'both',
+ ask: null,
+ groups: name === 'ungrouped'? [] : [name],
+ fullname: mock.cur_names[i]
+ });
+ }
+ });
+
+ const state = _converse.roster.state;
+ expect(state.get('collapsed_groups')).toEqual([]);
+ const rosterview = document.querySelector('converse-roster');
+ const toggle = await u.waitUntil(() => rosterview.querySelector('a.group-toggle'));
+ toggle.click();
+ await u.waitUntil(() => state.get('collapsed_groups').length);
+ expect(state.get('collapsed_groups')).toEqual(['Colleagues']);
+ toggle.click();
+ expect(state.get('collapsed_groups')).toEqual([]);
+ }));
+ });
+
+ describe("Pending Contacts", function () {
+
+ it("can be collapsed under their own header (if roster_groups is false)",
+ mock.initConverse([], {'roster_groups': false}, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'all');
+ await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group', rosterview).filter(u.isVisible).map(e => e.querySelector('li')).length, 1000);
+ await checkHeaderToggling.apply(_converse, [rosterview.querySelector('[data-group="Pending contacts"]')]);
+ }));
+
+ it("can be added to the roster",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'all', 0);
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ _converse.roster.create({
+ jid: mock.pend_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'none',
+ ask: 'subscribe',
+ fullname: mock.pend_names[0]
+ });
+ expect(u.isVisible(rosterview)).toBe(true);
+ await u.waitUntil(() => sizzle('li', rosterview).filter(u.isVisible).length === 1);
+ }));
+
+ it("are shown in the roster when hide_offline_users",
+ mock.initConverse(
+ [], {'hide_offline_users': true},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'pending');
+ await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('li', rosterview).filter(u.isVisible).length, 500)
+ expect(u.isVisible(rosterview)).toBe(true);
+ expect(sizzle('li', rosterview).filter(u.isVisible).length).toBe(3);
+ expect(sizzle('ul.roster-group-contacts', rosterview).filter(u.isVisible).length).toBe(1);
+ }));
+
+ it("can be removed by the user", mock.initConverse([], {'roster_groups': false}, async function (_converse) {
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'all');
+ await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+ const name = mock.pend_names[0];
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const contact = _converse.roster.get(jid);
+ spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+ spyOn(contact, 'unauthorize').and.callFake(function () { return contact; });
+ spyOn(contact, 'removeFromRoster').and.callThrough();
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle(`.pending-xmpp-contact .contact-name:contains("${name}")`, rosterview).length, 500);
+ let sent_IQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback) {
+ sent_IQ = iq;
+ callback();
+ });
+ sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop().click();
+ await u.waitUntil(() => !sizzle(`.pending-xmpp-contact .contact-name:contains("${name}")`, rosterview).length, 500);
+ expect(_converse.api.confirm).toHaveBeenCalled();
+ expect(contact.removeFromRoster).toHaveBeenCalled();
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster">`+
+ `<item jid="lord.capulet@montague.lit" subscription="remove"/>`+
+ `</query>`+
+ `</iq>`);
+ }));
+
+ it("do not have a header if there aren't any",
+ mock.initConverse(
+ ['VCardsInitialized'], {'roster_groups': false},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current', 0);
+ const name = mock.pend_names[0];
+ _converse.roster.create({
+ jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'none',
+ ask: 'subscribe',
+ fullname: name
+ });
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => {
+ const el = rosterview.querySelector(`ul[data-group="Pending contacts"]`);
+ return u.isVisible(el) && Array.from(el.querySelectorAll('li')).filter(li => u.isVisible(li)).length;
+ }, 700)
+
+ const remove_el = await u.waitUntil(() => sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop());
+ spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
+ remove_el.click();
+ expect(_converse.api.confirm).toHaveBeenCalled();
+
+ const iq_stanzas = _converse.connection.IQ_stanzas;
+ await u.waitUntil(() => Strophe.serialize(iq_stanzas.at(-1)) ===
+ `<iq id="${iq_stanzas.at(-1).getAttribute('id')}" type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster">`+
+ `<item jid="lord.capulet@montague.lit" subscription="remove"/>`+
+ `</query>`+
+ `</iq>`);
+
+ const iq = iq_stanzas.at(-1);
+ const stanza = u.toStanza(`<iq id="${iq.getAttribute('id')}" to="romeo@montague.lit/orchard" type="result"/>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Pending contacts"]`) === null);
+ }));
+
+ it("can be removed by the user",
+ mock.initConverse([], {'roster_groups': false}, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'all');
+ await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+ await u.waitUntil(() => _converse.roster.at(0).vcard.get('fullname'))
+ const rosterview = document.querySelector('converse-roster');
+ spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+ for (let i=0; i<mock.pend_names.length; i++) {
+ const name = mock.pend_names[i];
+ sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop().click();
+ }
+ await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Pending contacts"]`) === null);
+ }));
+
+ it("can be added to the roster and they will be sorted alphabetically",
+ mock.initConverse(
+ [], {'roster_groups': false},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current');
+ await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+ let i;
+ for (i=0; i<mock.pend_names.length; i++) {
+ _converse.roster.create({
+ jid: mock.pend_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'none',
+ ask: 'subscribe',
+ fullname: mock.pend_names[i]
+ });
+ }
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('li', rosterview.querySelector(`ul[data-group="Pending contacts"]`)).filter(u.isVisible).length);
+ // Check that they are sorted alphabetically
+ const el = await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Pending contacts"]`));
+ const spans = el.querySelectorAll('.pending-xmpp-contact span');
+
+ await u.waitUntil(
+ () => Array.from(spans).reduce((result, value) => result + value.textContent?.trim(), '') ===
+ mock.pend_names.slice(0,i+1).sort().join('')
+ );
+ expect(true).toBe(true);
+ }));
+ });
+
+ describe("Existing Contacts", function () {
+ async function _addContacts (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname'))));
+ }
+
+ it("can be collapsed under their own header",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('li', rosterview).filter(u.isVisible).length, 500);
+ await checkHeaderToggling.apply(_converse, [rosterview.querySelector('.roster-group')]);
+ }));
+
+ it("will be hidden when appearing under a collapsed group",
+ mock.initConverse(
+ [], {'roster_groups': false},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('li', rosterview).filter(u.isVisible).length, 500);
+ rosterview.querySelector('.group-toggle').click();
+ const name = "Romeo Montague";
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.create({
+ ask: null,
+ fullname: name,
+ jid: jid,
+ requesting: false,
+ subscription: 'both'
+ });
+ await u.waitUntil(() => u.hasClass('collapsed', rosterview.querySelector(`ul[data-group="My contacts"]`)) === true);
+ expect(true).toBe(true);
+ }));
+
+ it("will have their online statuses shown correctly",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.openControlBox(_converse);
+ const icon_el = document.querySelector('converse-roster-contact converse-icon');
+ expect(icon_el.getAttribute('color')).toBe('var(--subdued-color)');
+
+ let pres = $pres({from: 'mercutio@montague.lit/resource'});
+ _converse.connection._dataRecv(mock.createRequest(pres));
+ await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--chat-status-online)');
+
+ pres = $pres({from: 'mercutio@montague.lit/resource'}).c('show', 'away');
+ _converse.connection._dataRecv(mock.createRequest(pres));
+ await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--chat-status-away)');
+
+ pres = $pres({from: 'mercutio@montague.lit/resource'}).c('show', 'xa');
+ _converse.connection._dataRecv(mock.createRequest(pres));
+ await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--subdued-color)');
+
+ pres = $pres({from: 'mercutio@montague.lit/resource'}).c('show', 'dnd');
+ _converse.connection._dataRecv(mock.createRequest(pres));
+ await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--chat-status-busy)');
+
+ pres = $pres({from: 'mercutio@montague.lit/resource', type: 'unavailable'});
+ _converse.connection._dataRecv(mock.createRequest(pres));
+ await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--subdued-color)');
+ }));
+
+ it("can be added to the roster and they will be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await Promise.all(mock.cur_names.map(name => {
+ const contact = _converse.roster.create({
+ jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'both',
+ ask: null,
+ fullname: name
+ });
+ return u.waitUntil(() => contact.initialized);
+ }));
+ await u.waitUntil(() => sizzle('li', rosterview).length);
+ // Check that they are sorted alphabetically
+ const els = sizzle('.current-xmpp-contact.offline a.open-chat', rosterview)
+ const t = els.reduce((result, value) => (result + value.textContent.trim()), '');
+ expect(t).toEqual(mock.cur_names.slice(0,mock.cur_names.length).sort().join(''));
+ }));
+
+ it("can be removed by the user",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('li').length);
+ const name = mock.cur_names[0];
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const contact = _converse.roster.get(jid);
+ spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+ spyOn(contact, 'removeFromRoster').and.callThrough();
+
+ let sent_IQ;
+ spyOn(_converse.connection, 'sendIQ').and.callFake((iq, callback) => {
+ sent_IQ = iq;
+ callback();
+ });
+ sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop().click();
+ expect(_converse.api.confirm).toHaveBeenCalled();
+ await u.waitUntil(() => sent_IQ);
+
+ expect(Strophe.serialize(sent_IQ)).toBe(
+ `<iq type="set" xmlns="jabber:client">`+
+ `<query xmlns="jabber:iq:roster"><item jid="mercutio@montague.lit" subscription="remove"/></query>`+
+ `</iq>`);
+ expect(contact.removeFromRoster).toHaveBeenCalled();
+ await u.waitUntil(() => sizzle(".open-chat:contains('"+name+"')", rosterview).length === 0);
+ }));
+
+ it("do not have a header if there aren't any",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current', 0);
+ const name = mock.cur_names[0];
+ const contact = _converse.roster.create({
+ jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'both',
+ ask: null,
+ fullname: name
+ });
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group', rosterview).filter(u.isVisible).map(e => e.querySelector('li')).length, 1000);
+ spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+ spyOn(contact, 'removeFromRoster').and.callThrough();
+ spyOn(_converse.connection, 'sendIQ').and.callFake((iq, callback) => callback?.());
+ expect(u.isVisible(rosterview.querySelector('.roster-group'))).toBe(true);
+ sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop().click();
+ expect(_converse.api.confirm).toHaveBeenCalled();
+ await u.waitUntil(() => _converse.connection.sendIQ.calls.count());
+ expect(contact.removeFromRoster).toHaveBeenCalled();
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length === 0);
+ }));
+
+ it("can change their status to online and be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group li').length, 700);
+ const roster = rosterview;
+ const groups = roster.querySelectorAll('.roster-group');
+ const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+ expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+ for (let i=0; i<mock.cur_names.length; i++) {
+ const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'online');
+ // Check that they are sorted alphabetically
+ for (let j=0; j<groups.length; j++) {
+ const group = groups[j];
+ const groupname = groupnames[j];
+ const els = [...group.querySelectorAll('.current-xmpp-contact.online a.open-chat')];
+ const t = els.reduce((result, value) => result + value.textContent?.trim(), '');
+ expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
+ }
+ }
+ }));
+
+ it("can change their status to busy and be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700);
+ const roster = rosterview;
+ const groups = roster.querySelectorAll('.roster-group');
+ const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+ expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+ for (let i=0; i<mock.cur_names.length; i++) {
+ const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'dnd');
+ // Check that they are sorted alphabetically
+ for (let j=0; j<groups.length; j++) {
+ const group = groups[j];
+ const groupname = groupnames[j];
+ const els = [...group.querySelectorAll('.current-xmpp-contact.dnd a.open-chat')];
+ const t = els.reduce((result, value) => result + value.textContent.trim(), '');
+ expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
+ }
+ }
+ }));
+
+ it("can change their status to away and be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700);
+ const roster = rosterview;
+ const groups = roster.querySelectorAll('.roster-group');
+ const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+ expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+ for (let i=0; i<mock.cur_names.length; i++) {
+ const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'away');
+ // Check that they are sorted alphabetically
+ for (let j=0; j<groups.length; j++) {
+ const group = groups[j];
+ const groupname = groupnames[j];
+ const els = [...group.querySelectorAll('.current-xmpp-contact.away a.open-chat')];
+ const t = els.reduce((result, value) => result + value.textContent.trim(), '');
+ expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
+ }
+ }
+ }));
+
+ it("can change their status to xa and be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700);
+ const roster = rosterview;
+ const groups = roster.querySelectorAll('.roster-group');
+ const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+ expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+ for (let i=0; i<mock.cur_names.length; i++) {
+ const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'xa');
+ // Check that they are sorted alphabetically
+ for (let j=0; j<groups.length; j++) {
+ const group = groups[j];
+ const groupname = groupnames[j];
+ const els = [...group.querySelectorAll('.current-xmpp-contact.xa a.open-chat')];
+ const t = els.reduce((result, value) => result + value.textContenc?.trim(), '');
+ expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
+ }
+ }
+ }));
+
+ it("can change their status to unavailable and be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 500)
+ const roster = rosterview;
+ const groups = roster.querySelectorAll('.roster-group');
+ const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+ expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+ for (let i=0; i<mock.cur_names.length; i++) {
+ const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'unavailable');
+ // Check that they are sorted alphabetically
+ for (let j=0; j<groups.length; j++) {
+ const group = groups[j];
+ const groupname = groupnames[j];
+ const els = [...group.querySelectorAll('.current-xmpp-contact.unavailable a.open-chat')];
+ const t = els.reduce((result, value) => result + value.textContent.trim(), '');
+ expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join(''));
+ }
+ }
+ }));
+
+ it("are ordered according to status: online, busy, away, xa, unavailable, offline",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await _addContacts(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700);
+ let i, jid;
+ for (i=0; i<3; i++) {
+ jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'online');
+ }
+ for (i=3; i<6; i++) {
+ jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'dnd');
+ }
+ for (i=6; i<9; i++) {
+ jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'away');
+ }
+ for (i=9; i<12; i++) {
+ jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'xa');
+ }
+ for (i=12; i<15; i++) {
+ jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.roster.get(jid).presence.set('show', 'unavailable');
+ }
+
+ await u.waitUntil(() => u.isVisible(rosterview.querySelector('li:first-child')), 900);
+ const roster = rosterview;
+ const groups = roster.querySelectorAll('.roster-group');
+ const groupnames = Array.from(groups).map(g => g.getAttribute('data-group'));
+ expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped");
+
+ const group = groups[0];
+ const els = Array.from(group.querySelectorAll('.current-xmpp-contact'));
+ await u.waitUntil(() => els.map(e => e.getAttribute('data-status')).join(" ") === "online online away xa xa xa");
+
+ for (let j=0; j<groups.length; j++) {
+ const group = groups[j];
+ const groupname = groupnames[j];
+ const els = Array.from(group.querySelectorAll('.current-xmpp-contact'));
+ expect(els.length).toBe(mock.groups_map[groupname].length);
+
+ if (groupname === "Colleagues") {
+
+ const statuses = els.map(e => e.getAttribute('data-status'));
+ const subscription_classes = els.map(e => e.classList[4]);
+ const status_classes = els.map(e => e.classList[5]);
+ expect(statuses.join(" ")).toBe("online online away xa xa xa");
+ expect(status_classes.join(" ")).toBe("online online away xa xa xa");
+ expect(subscription_classes.join(" ")).toBe("both both both both both both");
+ } else if (groupname === "friends & acquaintences") {
+ const statuses = els.map(e => e.getAttribute('data-status'));
+ const subscription_classes = els.map(e => e.classList[4]);
+ const status_classes = els.map(e => e.classList[5]);
+ expect(statuses.join(" ")).toBe("online online dnd dnd away unavailable");
+ expect(status_classes.join(" ")).toBe("online online dnd dnd away unavailable");
+ expect(subscription_classes.join(" ")).toBe("both both both both both both");
+ } else if (groupname === "Family") {
+ const statuses = els.map(e => e.getAttribute('data-status'));
+ const subscription_classes = els.map(e => e.classList[4]);
+ const status_classes = els.map(e => e.classList[5]);
+ expect(statuses.join(" ")).toBe("online dnd");
+ expect(status_classes.join(" ")).toBe("online dnd");
+ expect(subscription_classes.join(" ")).toBe("both both");
+ } else if (groupname === "ænemies") {
+ const statuses = els.map(e => e.getAttribute('data-status'));
+ const subscription_classes = els.map(e => e.classList[4]);
+ const status_classes = els.map(e => e.classList[5]);
+ expect(statuses.join(" ")).toBe("away");
+ expect(status_classes.join(" ")).toBe("away");
+ expect(subscription_classes.join(" ")).toBe("both");
+ } else if (groupname === "Ungrouped") {
+ const statuses = els.map(e => e.getAttribute('data-status'));
+ const subscription_classes = els.map(e => e.classList[4]);
+ const status_classes = els.map(e => e.classList[5]);
+ expect(statuses.join(" ")).toBe("unavailable unavailable");
+ expect(status_classes.join(" ")).toBe("unavailable unavailable");
+ expect(subscription_classes.join(" ")).toBe("both both");
+ }
+ }
+ }));
+ });
+
+ describe("Requesting Contacts", function () {
+
+ it("can be added to the roster and they will be sorted alphabetically",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, "current", 0);
+ await mock.openControlBox(_converse);
+ let names = [];
+ const addName = function (item) {
+ if (!u.hasClass('request-actions', item)) {
+ names.push(item.textContent.replace(/^\s+|\s+$/g, ''));
+ }
+ };
+ const rosterview = document.querySelector('converse-roster');
+ await Promise.all(mock.req_names.map(name => {
+ const contact = _converse.roster.create({
+ jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ subscription: 'none',
+ ask: null,
+ requesting: true,
+ nickname: name
+ });
+ return u.waitUntil(() => contact.initialized);
+ }));
+ await u.waitUntil(() => rosterview.querySelectorAll(`ul[data-group="Contact requests"] li`).length, 700);
+ // Check that they are sorted alphabetically
+ const children = rosterview.querySelector(`ul[data-group="Contact requests"]`).querySelectorAll('.requesting-xmpp-contact span');
+ names = [];
+ Array.from(children).forEach(addName);
+ expect(names.join('')).toEqual(mock.req_names.slice(0,mock.req_names.length+1).sort().join(''));
+ }));
+
+ it("do not have a header if there aren't any", mock.initConverse([], {}, async function (_converse) {
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, "current", 0);
+ const name = mock.req_names[0];
+ spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+ _converse.roster.create({
+ 'jid': name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ 'subscription': 'none',
+ 'ask': null,
+ 'requesting': true,
+ 'nickname': name
+ });
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group', rosterview).filter(u.isVisible).length, 900);
+ expect(u.isVisible(rosterview.querySelector(`ul[data-group="Contact requests"]`))).toEqual(true);
+ expect(sizzle('.roster-group', rosterview).filter(u.isVisible).map(e => e.querySelector('li')).length).toBe(1);
+ sizzle('.roster-group', rosterview).filter(u.isVisible).map(e => e.querySelector('li .decline-xmpp-request'))[0].click();
+ expect(_converse.api.confirm).toHaveBeenCalled();
+ await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Contact requests"]`) === null);
+ }));
+
+ it("can be collapsed under their own header", mock.initConverse([], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current', 0);
+ mock.createContacts(_converse, 'requesting');
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group', rosterview).filter(u.isVisible).length, 700);
+ const el = await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Contact requests"]`));
+ await checkHeaderToggling.apply(_converse, [el.parentElement]);
+ }));
+
+ it("can have their requests accepted by the user",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.createContacts(_converse, 'requesting');
+ const name = mock.req_names.sort()[0];
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const contact = _converse.roster.get(jid);
+ spyOn(contact, 'authorize').and.callFake(() => contact);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group li').length)
+ // TODO: Testing can be more thorough here, the user is
+ // actually not accepted/authorized because of
+ // mock_connection.
+ spyOn(_converse.roster, 'sendContactAddIQ').and.callFake(() => Promise.resolve());
+ const req_contact = sizzle(`.req-contact-name:contains("${contact.getDisplayName()}")`, rosterview).pop();
+ req_contact.parentElement.parentElement.querySelector('.accept-xmpp-request').click();
+ expect(_converse.roster.sendContactAddIQ).toHaveBeenCalled();
+ await u.waitUntil(() => contact.authorize.calls.count());
+ expect(contact.authorize).toHaveBeenCalled();
+ }));
+
+ it("can have their requests denied by the user",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.createContacts(_converse, 'requesting');
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700);
+ const name = mock.req_names.sort()[1];
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const contact = _converse.roster.get(jid);
+ spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+ spyOn(contact, 'unauthorize').and.callFake(function () { return contact; });
+ const req_contact = await u.waitUntil(() => sizzle(".req-contact-name:contains('"+name+"')", rosterview).pop());
+ req_contact.parentElement.parentElement.querySelector('.decline-xmpp-request').click();
+ expect(_converse.api.confirm).toHaveBeenCalled();
+ await u.waitUntil(() => contact.unauthorize.calls.count());
+ // There should now be one less contact
+ expect(_converse.roster.length).toEqual(mock.req_names.length-1);
+ }));
+
+ it("are persisted even if other contacts' change their presence ", mock.initConverse(
+ [], {}, async function (_converse) {
+
+ const sent_IQs = _converse.connection.IQ_stanzas;
+ const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop());
+ // Taken from the spec
+ // https://xmpp.org/rfcs/rfc3921.html#rfc.section.7.3
+ const result = $iq({
+ to: _converse.connection.jid,
+ type: 'result',
+ id: stanza.getAttribute('id')
+ }).c('query', {
+ xmlns: 'jabber:iq:roster',
+ }).c('item', {
+ jid: 'juliet@example.net',
+ name: 'Juliet',
+ subscription:'both'
+ }).c('group').t('Friends').up().up()
+ .c('item', {
+ jid: 'mercutio@example.org',
+ name: 'Mercutio',
+ subscription:'from'
+ }).c('group').t('Friends').up().up()
+ _converse.connection._dataRecv(mock.createRequest(result));
+
+ const pres = $pres({from: 'data@enterprise/resource', type: 'subscribe'});
+ _converse.connection._dataRecv(mock.createRequest(pres));
+
+ expect(_converse.roster.pluck('jid').length).toBe(1);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('a:contains("Contact requests")', rosterview).length, 700);
+ expect(_converse.roster.pluck('jid').includes('data@enterprise')).toBeTruthy();
+
+ const roster_push = $iq({
+ 'to': _converse.connection.jid,
+ 'type': 'set',
+ }).c('query', {'xmlns': 'jabber:iq:roster', 'ver': 'ver34'})
+ .c('item', {
+ jid: 'benvolio@example.org',
+ name: 'Benvolio',
+ subscription:'both'
+ }).c('group').t('Friends');
+ _converse.connection._dataRecv(mock.createRequest(roster_push));
+ expect(_converse.roster.data.get('version')).toBe('ver34');
+ expect(_converse.roster.models.length).toBe(4);
+ expect(_converse.roster.pluck('jid').includes('data@enterprise')).toBeTruthy();
+ }));
+ });
+
+ describe("All Contacts", function () {
+
+ it("are saved to, and can be retrieved from browserStorage",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ await mock.createContacts(_converse, 'requesting');
+ await mock.openControlBox(_converse);
+ var new_attrs, old_attrs, attrs;
+ var num_contacts = _converse.roster.length;
+ var new_roster = new _converse.RosterContacts();
+ // Roster items are yet to be fetched from browserStorage
+ expect(new_roster.length).toEqual(0);
+ new_roster.browserStorage = _converse.roster.browserStorage;
+ await new Promise(success => new_roster.fetch({success}));
+ expect(new_roster.length).toEqual(num_contacts);
+ // Check that the roster items retrieved from browserStorage
+ // have the same attributes values as the original ones.
+ attrs = ['jid', 'fullname', 'subscription', 'ask'];
+ for (var i=0; i<attrs.length; i++) {
+ new_attrs = new_roster.models.map(m => m.attributes[attrs[i]]); // eslint-disable-line
+ old_attrs = _converse.roster.models.map(m => m.attributes[attrs[i]]); // eslint-disable-line
+ // Roster items in storage are not necessarily sorted,
+ // so we have to sort them here to do a proper
+ // comparison
+ expect(new_attrs.sort()).toEqual(old_attrs.sort());
+ }
+ }));
+
+ it("will show fullname and jid properties on tooltip",
+ mock.initConverse(
+ [], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 'all');
+ await mock.createContacts(_converse, 'requesting');
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700);
+ await Promise.all(mock.cur_names.map(async name => {
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const el = await u.waitUntil(() => sizzle("li:contains('"+name+"')", rosterview).pop());
+ const child = el.firstElementChild.firstElementChild;
+ expect(child.textContent.trim()).toBe(name);
+ expect(child.getAttribute('title')).toContain(name);
+ expect(child.getAttribute('title')).toContain(jid);
+ }));
+ await Promise.all(mock.req_names.map(async name => {
+ const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const el = await u.waitUntil(() => sizzle("li:contains('"+name+"')", rosterview).pop());
+ const child = el.firstElementChild.firstElementChild;
+ expect(child.textContent.trim()).toBe(name);
+ expect(child.firstElementChild.getAttribute('title')).toContain(jid);
+ }));
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/utils.js
new file mode 100644
index 0000000..8b5c694
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/utils.js
@@ -0,0 +1,114 @@
+import log from "@converse/headless/log";
+import { __ } from 'i18n';
+import { _converse, api } from "@converse/headless/core";
+
+export function removeContact (contact) {
+ contact.removeFromRoster(
+ () => contact.destroy(),
+ (e) => {
+ e && log.error(e);
+ api.alert('error', __('Error'), [
+ __('Sorry, there was an error while trying to remove %1$s as a contact.',
+ contact.getDisplayName())
+ ]);
+ }
+ );
+}
+
+export function highlightRosterItem (chatbox) {
+ _converse.roster?.get(chatbox.get('jid'))?.trigger('highlight');
+}
+
+export function toggleGroup (ev, name) {
+ ev?.preventDefault?.();
+ const collapsed = _converse.roster.state.get('collapsed_groups');
+ if (collapsed.includes(name)) {
+ _converse.roster.state.save('collapsed_groups', collapsed.filter(n => n !== name));
+ } else {
+ _converse.roster.state.save('collapsed_groups', [...collapsed, name]);
+ }
+}
+
+export function isContactFiltered (contact, groupname) {
+ const filter = _converse.roster_filter;
+ const type = filter.get('filter_type');
+ const q = (type === 'state') ?
+ filter.get('chat_state').toLowerCase() :
+ filter.get('filter_text').toLowerCase();
+
+ if (!q) return false;
+
+ if (type === 'state') {
+ const sticky_groups = [_converse.HEADER_REQUESTING_CONTACTS, _converse.HEADER_UNREAD];
+ if (sticky_groups.includes(groupname)) {
+ // When filtering by chat state, we still want to
+ // show sticky groups, even though they don't
+ // match the state in question.
+ return false;
+ } else if (q === 'unread_messages') {
+ return contact.get('num_unread') === 0;
+ } else if (q === 'online') {
+ return ["offline", "unavailable"].includes(contact.presence.get('show'));
+ } else {
+ return !contact.presence.get('show').includes(q);
+ }
+ } else if (type === 'contacts') {
+ return !contact.getFilterCriteria().includes(q);
+ }
+}
+
+export function shouldShowContact (contact, groupname) {
+ const chat_status = contact.presence.get('show');
+ if (api.settings.get('hide_offline_users') && chat_status === 'offline') {
+ // If pending or requesting, show
+ if ((contact.get('ask') === 'subscribe') ||
+ (contact.get('subscription') === 'from') ||
+ (contact.get('requesting') === true)) {
+ return !isContactFiltered(contact, groupname);
+ }
+ return false;
+ }
+ return !isContactFiltered(contact, groupname);
+}
+
+export function shouldShowGroup (group) {
+ const filter = _converse.roster_filter;
+ const type = filter.get('filter_type');
+ if (type === 'groups') {
+ const q = filter.get('filter_text')?.toLowerCase();
+ if (!q) {
+ return true;
+ }
+ if (!group.toLowerCase().includes(q)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+export function populateContactsMap (contacts_map, contact) {
+ if (contact.get('requesting')) {
+ const name = _converse.HEADER_REQUESTING_CONTACTS;
+ contacts_map[name] ? contacts_map[name].push(contact) : (contacts_map[name] = [contact]);
+ } else {
+ let contact_groups;
+ if (api.settings.get('roster_groups')) {
+ contact_groups = contact.get('groups');
+ contact_groups = (contact_groups.length === 0) ? [_converse.HEADER_UNGROUPED] : contact_groups;
+ } else {
+ if (contact.get('ask') === 'subscribe') {
+ contact_groups = [_converse.HEADER_PENDING_CONTACTS];
+ } else {
+ contact_groups = [_converse.HEADER_CURRENT_CONTACTS];
+ }
+ }
+ for (const name of contact_groups) {
+ contacts_map[name] ? contacts_map[name].push(contact) : (contacts_map[name] = [contact]);
+ }
+ }
+ if (contact.get('num_unread')) {
+ const name = _converse.HEADER_UNREAD;
+ contacts_map[name] ? contacts_map[name].push(contact) : (contacts_map[name] = [contact]);
+ }
+ return contacts_map;
+}