diff options
Diffstat (limited to 'roles/reverseproxy/files/conversejs/src/plugins/rosterview')
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; +} |