Browse Source

Add contact request buttons to user details modal

JC Brand 1 month ago
parent
commit
70c7b7ea12
34 changed files with 915 additions and 608 deletions
  1. 11 0
      fullscreen.html
  2. 1 1
      index.html
  3. 1 0
      karma.conf.js
  4. 0 1
      src/headless/plugins/roster/api.js
  5. 4 0
      src/headless/plugins/roster/contact.js
  6. 1 0
      src/headless/types/plugins/roster/contact.d.ts
  7. 13 5
      src/plugins/modal/toast.js
  8. 1 0
      src/plugins/modal/toasts.js
  9. 1 0
      src/plugins/modal/types.ts
  10. 9 43
      src/plugins/rosterview/contactview.js
  11. 21 15
      src/plugins/rosterview/modals/accept-contact-request.js
  12. 36 30
      src/plugins/rosterview/modals/add-contact.js
  13. 23 11
      src/plugins/rosterview/modals/templates/accept-contact-request.js
  14. 57 42
      src/plugins/rosterview/modals/templates/add-contact.js
  15. 1 1
      src/plugins/rosterview/templates/requesting_contact.js
  16. 3 5
      src/plugins/rosterview/templates/unsaved_contact.js
  17. 1 1
      src/plugins/rosterview/tests/protocol.js
  18. 276 0
      src/plugins/rosterview/tests/requesting_contacts.js
  19. 1 224
      src/plugins/rosterview/tests/roster.js
  20. 39 5
      src/plugins/rosterview/utils.js
  21. 150 98
      src/shared/modals/templates/user-details.js
  22. 163 105
      src/shared/modals/tests/user-details-modal.js
  23. 41 3
      src/shared/modals/user-details.js
  24. 11 9
      src/shared/styles/buttons.scss
  25. 0 1
      src/shared/styles/themes/cyberpunk.scss
  26. 20 0
      src/shared/tests/mock.js
  27. 5 1
      src/types/plugins/modal/toast.d.ts
  28. 1 0
      src/types/plugins/modal/types.d.ts
  29. 1 1
      src/types/plugins/rosterview/contactview.d.ts
  30. 1 1
      src/types/plugins/rosterview/modals/accept-contact-request.d.ts
  31. 4 3
      src/types/plugins/rosterview/modals/add-contact.d.ts
  32. 4 0
      src/types/plugins/rosterview/utils.d.ts
  33. 1 1
      src/types/shared/modals/templates/user-details.d.ts
  34. 13 1
      src/types/shared/modals/user-details.d.ts

+ 11 - 0
fullscreen.html

@@ -17,6 +17,17 @@
     <script src="https://cdn.conversejs.org/3rdparty/libsignal-protocol.min.js"></script>
     <script src="/dist/converse.min.js"></script>
 </head>
+<script type="application/ld+json">
+{
+  "@context": "https://schema.org",
+  "@type": "SoftwareApplication",
+  "name": "Converse",
+  "description": "Open source XMPP chat client for the web",
+  "url": "https://conversejs.org",
+  "applicationCategory": "CommunicationApplication",
+  "operatingSystem": "Web Browser",
+}
+</script>
 <body class="converse-fullscreen">
 <noscript>You need to enable JavaScript to run the Converse.js chat app.</noscript>
 <div id="conversejs-bg"></div>

+ 1 - 1
index.html

@@ -18,7 +18,7 @@
 {
   "@context": "https://schema.org",
   "@type": "SoftwareApplication",
-  "name": "Converse.js",
+  "name": "Converse",
   "description": "Open source XMPP chat client for the web",
   "url": "https://conversejs.org",
   "applicationCategory": "CommunicationApplication",

+ 1 - 0
karma.conf.js

@@ -149,6 +149,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/rosterview/tests/presence.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/protocol.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/roster.js", type: 'module' },
+      { pattern: "src/plugins/rosterview/tests/requesting_contacts.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/unsaved-contacts.js", type: 'module' },
       { pattern: "src/utils/tests/url.js", type: 'module' },
 

+ 0 - 1
src/headless/plugins/roster/api.js

@@ -83,7 +83,6 @@ export default {
         async add(attributes, persist = true, subscribe = true, message = '') {
             if (!isValidJID(attributes?.jid)) throw new Error('api.contacts.add: Valid JID required');
 
-            await api.waitUntil('rosterContactsFetched');
             const { roster } = _converse.state;
             return roster.addContact(attributes, persist, subscribe, message);
         },

+ 4 - 0
src/headless/plugins/roster/contact.js

@@ -203,6 +203,10 @@ class RosterContact extends ModelWithVCard(ColorAwareModel(Model)) {
         </iq>`;
         return await api.sendIQ(iq);
     }
+
+    isUnsaved () {
+        return this.get('subscription') === undefined;
+    }
 }
 
 export default RosterContact;

+ 1 - 0
src/headless/types/plugins/roster/contact.d.ts

@@ -208,6 +208,7 @@ declare class RosterContact extends RosterContact_base {
      * @returns {Promise}
      */
     sendRosterRemoveStanza(): Promise<any>;
+    isUnsaved(): boolean;
 }
 import { Model } from '@converse/skeletor';
 //# sourceMappingURL=contact.d.ts.map

+ 13 - 5
src/plugins/modal/toast.js

@@ -7,6 +7,7 @@ import './styles/toast.scss';
 export default class Toast extends CustomElement {
     static get properties() {
         return {
+            type: { type: String },
             name: { type: String },
             title: { type: String },
             body: { type: String },
@@ -18,17 +19,24 @@ export default class Toast extends CustomElement {
         this.name = '';
         this.body = '';
         this.header = '';
+        this.type = 'info';
     }
 
-    initialize() {
-        super.initialize();
-        setTimeout(() => this.hide(), 5000);
+    /**
+     * @param {import('lit').PropertyValues} changed
+     */
+    updated(changed) {
+        if (changed.get('type') !== 'danger') {
+            this.timeoutId = setTimeout(() => this.hide(), 5000);
+        } else {
+            clearTimeout(this.timeoutId);
+        }
     }
 
     render() {
-        return html`<div class="toast show" role="alert" aria-live="assertive" aria-atomic="true">
+        return html`<div class="toast show text-bg-${this.type}" role="alert" aria-live="assertive" aria-atomic="true">
             ${this.title
-                ? html` <div class="toast-header">
+                ? html`<div class="toast-header">
                       <img src="/logo/conversejs-filled.svg" class="rounded me-2" alt="${__('Converse logo')}" />
                       <strong class="me-auto">${this.title ?? ''}</strong>
                       <button

+ 1 - 0
src/plugins/modal/toasts.js

@@ -18,6 +18,7 @@ export class ToastsContainer extends CustomElement {
                     name="${toast.name}"
                     title="${toast.title ?? ''}"
                     body="${toast.body ?? ''}"
+                    type="${toast.type ?? ''}"
                 ></converse-toast>`
         )}`;
     }

+ 1 - 0
src/plugins/modal/types.ts

@@ -13,4 +13,5 @@ export type ToastProperties = {
     title?: string;
     body?: string;
     name: string;
+    type: 'info'|'danger';
 }

+ 9 - 43
src/plugins/rosterview/contactview.js

@@ -1,13 +1,11 @@
 import { Model } from '@converse/skeletor';
-import { _converse, converse, api } from '@converse/headless';
+import { _converse, api } from '@converse/headless';
 import { ObservableElement } from 'shared/components/observable.js';
 import tplRequestingContact from './templates/requesting_contact.js';
 import tplRosterItem from './templates/roster_item.js';
 import tplUnsavedContact from './templates/unsaved_contact.js';
 import { __ } from 'i18n';
-import { blockContact, removeContact } from './utils.js';
-
-const { Strophe } = converse.env;
+import { blockContact, declineContactRequest, removeContact } from './utils.js';
 
 export default class RosterContactView extends ObservableElement {
     /**
@@ -17,7 +15,7 @@ export default class RosterContactView extends ObservableElement {
     constructor() {
         super();
         this.model = null;
-        this.observable = /** @type {ObservableProperty} */ ("once");
+        this.observable = /** @type {ObservableProperty} */ ('once');
     }
 
     static get properties() {
@@ -59,7 +57,10 @@ export default class RosterContactView extends ObservableElement {
      */
     addContact(ev) {
         ev?.preventDefault?.();
-        api.modal.show('converse-add-contact-modal', { model: new Model() }, ev);
+        api.modal.show('converse-add-contact-modal', {
+            contact: this.model,
+            model: new Model()
+        }, ev);
     }
 
     /**
@@ -96,11 +97,7 @@ export default class RosterContactView extends ObservableElement {
      */
     async acceptRequest(ev) {
         ev?.preventDefault?.();
-        api.modal.show(
-            'converse-accept-contact-request-modal',
-            { model: new Model(), contact: this.model },
-            ev
-        );
+        api.modal.show('converse-accept-contact-request-modal', { model: new Model(), contact: this.model }, ev);
     }
 
     /**
@@ -108,38 +105,7 @@ export default class RosterContactView extends ObservableElement {
      */
     async declineRequest(ev) {
         ev?.preventDefault?.();
-        const domain = _converse.session.get('domain');
-        const blocking_supported = await api.disco.supports(Strophe.NS.BLOCKING, domain);
-
-        const result = await api.confirm(
-            __('Remove and decline contact request'),
-            [__('Are you sure you want to decline the contact request from %1$s?', this.model.getDisplayName())],
-            blocking_supported
-                ? [
-                      {
-                          label: __('Also block this user from sending you further messages'),
-                          name: 'block',
-                          type: 'checkbox',
-                      },
-                  ]
-                : []
-        );
-
-        if (result) {
-            const chat = await api.chats.get(this.model.get('jid'));
-            chat?.close();
-            this.model.unauthorize();
-
-            if (blocking_supported && Array.isArray(result)) {
-                const should_block = result.find((i) => i.name === 'block')?.value === 'on';
-                if (should_block) {
-                    api.blocklist.add(this.model.get('jid'));
-                }
-            }
-
-            this.model.destroy();
-        }
-        return this;
+        declineContactRequest(this.model);
     }
 }
 

+ 21 - 15
src/plugins/rosterview/modals/accept-contact-request.js

@@ -1,8 +1,8 @@
-import { _converse, api, log } from "@converse/headless";
-import "shared/autocomplete/index.js";
-import BaseModal from "plugins/modal/modal.js";
-import tplAcceptContactRequest from "./templates/accept-contact-request.js";
-import { __ } from "i18n";
+import { _converse, api, log } from '@converse/headless';
+import 'shared/autocomplete/index.js';
+import BaseModal from 'plugins/modal/modal.js';
+import tplAcceptContactRequest from './templates/accept-contact-request.js';
+import { __ } from 'i18n';
 
 export default class AcceptContactRequest extends BaseModal {
     /**
@@ -15,11 +15,11 @@ export default class AcceptContactRequest extends BaseModal {
 
     initialize() {
         super.initialize();
-        this.listenTo(this.model, "change", () => this.requestUpdate());
-        this.listenTo(this.contact, "change", () => this.requestUpdate());
+        this.listenTo(this.model, 'change', () => this.requestUpdate());
+        this.listenTo(this.contact, 'change', () => this.requestUpdate());
         this.requestUpdate();
         this.addEventListener(
-            "shown.bs.modal",
+            'shown.bs.modal',
             () => /** @type {HTMLInputElement} */ (this.querySelector('input[name="name"]'))?.focus(),
             false
         );
@@ -30,7 +30,7 @@ export default class AcceptContactRequest extends BaseModal {
     }
 
     getModalTitle() {
-        return __("Contact Request");
+        return __('Contact Request');
     }
 
     /**
@@ -40,30 +40,36 @@ export default class AcceptContactRequest extends BaseModal {
         ev.preventDefault();
         const form = /** @type {HTMLFormElement} */ (ev.target);
         const data = new FormData(form);
-        const name = /** @type {string} */ (data.get("name") || "").trim();
+        const name = /** @type {string} */ (data.get('name') || '').trim();
         const groups =
-            /** @type {string} */ (data.get("groups"))
-                ?.split(",")
+            /** @type {string} */ (data.get('groups'))
+                ?.split(',')
                 .map((g) => g.trim())
                 .filter((g) => g) || [];
         try {
             await _converse.state.roster.sendContactAddIQ({
-                jid: this.contact.get("jid"),
+                jid: this.contact.get('jid'),
                 name,
                 groups,
             });
             this.contact.authorize().subscribe();
         } catch (e) {
             log.error(e);
-            this.model.set("error", __("Sorry, something went wrong"));
+            api.toast.show('accept-request-error', {
+                type: 'danger',
+                body: __('Sorry, something went wrong while accepting the contact request'),
+            });
             return;
         }
         this.contact.save({
             nickname: name,
             groups,
         });
+        api.toast.show('accept-request-success', {
+            body: __('Succesfully accepted the contact request'),
+        });
         this.modal.hide();
     }
 }
 
-api.elements.define("converse-accept-contact-request-modal", AcceptContactRequest);
+api.elements.define('converse-accept-contact-request-modal', AcceptContactRequest);

+ 36 - 30
src/plugins/rosterview/modals/add-contact.js

@@ -1,18 +1,24 @@
-import { Strophe } from "strophe.js";
-import { _converse, api, log } from "@converse/headless";
-import BaseModal from "plugins/modal/modal.js";
-import tplAddContactModal from "./templates/add-contact.js";
-import { __ } from "i18n";
+import { Strophe } from 'strophe.js';
+import { _converse, api, log } from '@converse/headless';
+import BaseModal from 'plugins/modal/modal.js';
+import tplAddContactModal from './templates/add-contact.js';
+import { __ } from 'i18n';
 
 import './styles/add-contact.scss';
 
 export default class AddContactModal extends BaseModal {
+    constructor() {
+        super();
+        this.contact = null;
+    }
+
     initialize() {
         super.initialize();
-        this.listenTo(this.model, "change", () => this.requestUpdate());
+        this.listenTo(this.contact, 'change', () => this.requestUpdate());
+        this.listenTo(this.model, 'change', () => this.requestUpdate());
         this.requestUpdate();
         this.addEventListener(
-            "shown.bs.modal",
+            'shown.bs.modal',
             () => /** @type {HTMLInputElement} */ (this.querySelector('input[name="jid"]'))?.focus(),
             false
         );
@@ -23,42 +29,41 @@ export default class AddContactModal extends BaseModal {
     }
 
     getModalTitle() {
-        return __("Add a Contact");
+        return __('Add a Contact');
     }
 
     /**
      * @param {string} jid
      */
     validateSubmission(jid) {
-        if (!jid || jid.split("@").filter((s) => !!s).length < 2) {
-            this.model.set("error", __("Please enter a valid XMPP address"));
+        if (!jid || jid.split('@').filter((s) => !!s).length < 2) {
+            this.model.set('error', __('Please enter a valid XMPP address'));
             return false;
-        } else if (_converse.state.roster.get(Strophe.getBareJidFromJid(jid))) {
-            this.model.set("error", __("This contact has already been added"));
+        } else if (!this.contact && _converse.state.roster.get(Strophe.getBareJidFromJid(jid))) {
+            this.model.set('error', __('This contact has already been added'));
             return false;
         }
-        this.model.set("error", null);
+        this.model.set('error', null);
         return true;
     }
 
     /**
-     * @param {HTMLFormElement} form
      * @param {string} jid
      * @param {string} name
      * @param {string[]} groups
      */
-    async afterSubmission(form, jid, name, groups) {
-        try {
-            await api.contacts.add({ jid, name, groups });
-        } catch (e) {
+    async afterSubmission(jid, name, groups) {
+        api.contacts.add({ jid, name, groups }).catch((e) => {
             log.error(e);
-            this.model.set("error", __("Sorry, something went wrong"));
+            api.toast.show('contact-add-error', {
+                type: 'danger',
+                body: __('Sorry, something went wrong while adding the contact'),
+            });
             return;
-        }
+        });
         api.chats.open(jid, {}, true);
-        form.reset();
         this.model.clear();
-        api.toast.show('contact-added', { body: __("Contact added successfully") });
+        api.toast.show('contact-added', { body: __('Contact added successfully') });
         this.modal.hide();
     }
 
@@ -69,10 +74,10 @@ export default class AddContactModal extends BaseModal {
         ev.preventDefault();
         const form = /** @type {HTMLFormElement} */ (ev.target);
         const data = new FormData(form);
-        let jid = /** @type {string} */ (data.get("jid") || "").trim();
+        let jid = /** @type {string} */ (data.get('jid') || '').trim();
 
         let name;
-        if (api.settings.get("xhr_user_search_url")) {
+        if (api.settings.get('xhr_user_search_url')) {
             // In this case, the value of `jid` is something like `John Doe <john@chat.com>`
             // So we want to get `name` which is `John Doe` and reset `jid` to
             // what's inside the arrow brackets, so in this case
@@ -83,7 +88,7 @@ export default class AddContactModal extends BaseModal {
                 jid = match[2].trim();
             } else {
                 this.model.set(
-                    "error",
+                    'error',
                     __(
                         'Invalid value for the name and XMPP address. Please use the format "Name <username@example.org>".'
                     )
@@ -91,18 +96,19 @@ export default class AddContactModal extends BaseModal {
                 return;
             }
         } else {
-            name = /** @type {string} */ (data.get("name") || "").trim();
+            name = /** @type {string} */ (data.get('name') || '').trim();
         }
 
         if (this.validateSubmission(jid)) {
             const groups =
-                /** @type {string} */ (data.get("groups"))
-                    ?.split(",")
+                /** @type {string} */ (data.get('groups'))
+                    ?.split(',')
                     .map((g) => g.trim())
                     .filter((g) => g) || [];
-            this.afterSubmission(form, jid, name, groups);
+            this.afterSubmission(jid, name, groups);
+            form.reset();
         }
     }
 }
 
-api.elements.define("converse-add-contact-modal", AddContactModal);
+api.elements.define('converse-add-contact-modal', AddContactModal);

+ 23 - 11
src/plugins/rosterview/modals/templates/accept-contact-request.js

@@ -1,23 +1,29 @@
-import { __ } from "i18n";
-import { getGroupsAutoCompleteList } from "../../utils.js";
-import { html } from "lit";
+import { __ } from 'i18n';
+import { getGroupsAutoCompleteList } from '../../utils.js';
+import { html } from 'lit';
 
 /**
  * @param {import('../accept-contact-request.js').default} el
  */
 export default (el) => {
-    const i18n_add = __("Add");
-    const i18n_groups = __("Groups");
-    const i18n_groups_help = __("Use commas to separate multiple values");
-    const i18n_nickname = __("Name");
-    const error = el.model.get("error");
+    const i18n_accept = __('Accept');
+    const i18n_groups = __('Groups');
+    const i18n_groups_help = __('Use commas to separate multiple values');
+    const i18n_nickname = __('Name');
+    const i18n_xmpp_address = __('XMPP Address');
+    const error = el.model.get('error');
 
     return html` <div class="modal-body">
-        ${error ? html`<div class="alert alert-danger" role="alert">${error}</div>` : ""}
+        ${error ? html`<div class="alert alert-danger" role="alert">${error}</div>` : ''}
         <form class="converse-form" @submit=${(ev) => el.acceptContactRequest(ev)}>
             <div class="mb-3">
                 <label class="form-label clearfix" for="name">${i18n_nickname}:</label>
-                <input type="text" name="name" value="${el.contact.vcard?.get('fullname') || ''}" class="form-control" />
+                <input
+                    type="text"
+                    name="name"
+                    value="${el.contact.vcard?.get('fullname') || ''}"
+                    class="form-control"
+                />
             </div>
             <div class="mb-3">
                 <label class="form-label clearfix" for="name">${i18n_groups}:</label>
@@ -26,7 +32,13 @@ export default (el) => {
                 </div>
                 <converse-autocomplete .list=${getGroupsAutoCompleteList()} name="groups"></converse-autocomplete>
             </div>
-            <button type="submit" class="btn btn-primary">${i18n_add}</button>
+
+            <div class="mb-3 d-flex justify-content-between">
+                <label class="form-label clearfix w-100" for="jid-display">${i18n_xmpp_address}:</label>
+                <p class="form-control-plaintext text-end" id="jid-display">${el.contact.get('jid')}</p>
+            </div>
+
+            <button type="submit" class="btn btn-primary">${i18n_accept}</button>
         </form>
     </div>`;
 };

+ 57 - 42
src/plugins/rosterview/modals/templates/add-contact.js

@@ -1,50 +1,53 @@
-import { html } from "lit";
-import { api } from "@converse/headless";
-import { __ } from "i18n";
-import { getGroupsAutoCompleteList, getJIDsAutoCompleteList, getNamesAutoCompleteList } from "../../utils.js";
-import "shared/autocomplete/index.js";
+import { html } from 'lit';
+import { api } from '@converse/headless';
+import { __ } from 'i18n';
+import { getGroupsAutoCompleteList, getJIDsAutoCompleteList, getNamesAutoCompleteList } from '../../utils.js';
+import 'shared/autocomplete/index.js';
 
 /**
  * @param {import('../add-contact.js').default} el
  */
 export default (el) => {
-    const i18n_add = __("Add");
-    const i18n_contact_placeholder = __("name@example.org");
-    const i18n_groups = __("Groups");
-    const i18n_groups_help = __("Use commas to separate multiple values");
-    const i18n_nickname = __("Name");
-    const using_xhr = api.settings.get("xhr_user_search_url");
-    const i18n_xmpp_address = using_xhr ? __("Search name or XMPP address") : __("XMPP Address");
-    const error = el.model.get("error");
+    const i18n_add = __('Add');
+    const i18n_contact_placeholder = __('name@example.org');
+    const i18n_groups = __('Groups');
+    const i18n_groups_help = __('Use commas to separate multiple values');
+    const i18n_nickname = __('Name');
+    const using_xhr = api.settings.get('xhr_user_search_url');
+    const i18n_xmpp_address = __('XMPP Address');
+    const i18n_search_or_address = using_xhr ? __('Search name or XMPP address') : i18n_xmpp_address;
+    const error = el.model.get('error');
 
-    return html` <div class="modal-body">
-        ${error ? html`<div class="alert alert-danger" role="alert">${error}</div>` : ""}
+    return html`<div class="modal-body">
+        ${error ? html`<div class="alert alert-danger" role="alert">${error}</div>` : ''}
         <form class="converse-form add-xmpp-contact" @submit=${(ev) => el.addContactFromForm(ev)}>
-            <div class="mb-3">
-                <label class="form-label clearfix" for="jid">${i18n_xmpp_address}:</label>
-                ${using_xhr
-                    ? html`<converse-autocomplete
-                          .getAutoCompleteList=${getNamesAutoCompleteList}
-                          position="below"
-                          min_chars="2"
-                          filter="contains"
-                          ?required="${true}"
-                          value="${el.model.get("jid") || ""}"
-                          placeholder="${i18n_contact_placeholder}"
-                          name="jid"
-                      ></converse-autocomplete>`
-                    : html`<converse-autocomplete
-                          .list="${getJIDsAutoCompleteList()}"
-                          .data="${(text, input) => `${input.slice(0, input.indexOf("@"))}@${text}`}"
-                          position="below"
-                          min_chars="2"
-                          filter="startswith"
-                          ?required="${!api.settings.get("xhr_user_search_url")}"
-                          value="${el.model.get("jid") || ""}"
-                          placeholder="${i18n_contact_placeholder}"
-                          name="jid"
-                      ></converse-autocomplete>`}
-            </div>
+            ${el.contact
+                ? html`<input type="hidden" name="jid" value="${el.contact.get('jid')}" />`
+                : html`<div class="mb-3">
+                      <label class="form-label clearfix" for="jid">${i18n_search_or_address}:</label>
+                      ${using_xhr
+                          ? html`<converse-autocomplete
+                                .getAutoCompleteList=${getNamesAutoCompleteList}
+                                position="below"
+                                min_chars="2"
+                                filter="contains"
+                                ?required="${true}"
+                                value="${el.model.get('jid') || ''}"
+                                placeholder="${i18n_contact_placeholder}"
+                                name="jid"
+                            ></converse-autocomplete>`
+                          : html`<converse-autocomplete
+                                .list="${getJIDsAutoCompleteList()}"
+                                .data="${(text, input) => `${input.slice(0, input.indexOf('@'))}@${text}`}"
+                                position="below"
+                                min_chars="2"
+                                filter="startswith"
+                                ?required="${!api.settings.get('xhr_user_search_url')}"
+                                value="${el.model.get('jid') || ''}"
+                                placeholder="${i18n_contact_placeholder}"
+                                name="jid"
+                            ></converse-autocomplete>`}
+                  </div>`}
 
             ${!using_xhr
                 ? html`
@@ -53,12 +56,12 @@ export default (el) => {
                           <input
                               type="text"
                               name="name"
-                              value="${el.model.get("nickname") || ""}"
+                              value="${el.model.get('nickname') || ''}"
                               class="form-control"
                           />
                       </div>
                   `
-                : ""}
+                : ''}
 
             <div class="mb-3">
                 <label class="form-label clearfix" for="name">${i18n_groups}:</label>
@@ -67,6 +70,18 @@ export default (el) => {
                 </div>
                 <converse-autocomplete .list=${getGroupsAutoCompleteList()} name="groups"></converse-autocomplete>
             </div>
+
+            ${el.contact
+                ? html`<div class="mb-3 d-flex justify-content-between">
+                      <label class="form-label clearfix w-100" for="jid-display"
+                          >${i18n_xmpp_address}:</label
+                      >
+                      <p class="form-control-plaintext text-end" id="jid-display">
+                          ${el.contact.get('jid')}
+                      </p>
+                  </div>`
+                : ''}
+
             <button type="submit" class="btn btn-primary">${i18n_add}</button>
         </form>
     </div>`;

+ 1 - 1
src/plugins/rosterview/templates/requesting_contact.js

@@ -1,7 +1,7 @@
 import { html } from 'lit';
 import { __ } from 'i18n';
 import { getUnreadMsgsDisplay } from 'shared/chat/utils.js';
-import {tplDetailsButton } from './roster_item';
+import {tplDetailsButton } from './roster_item.js';
 
 /**
  * @param {import('../contactview').default} el

+ 3 - 5
src/plugins/rosterview/templates/unsaved_contact.js

@@ -14,7 +14,7 @@ function tplAddContactButton(el) {
     return html`<a
         class="dropdown-item add-contact"
         role="button"
-        @click="${el.addContact}"
+        @click="${(ev) => el.addContact(ev)}"
         title="${i18n_add_contact}"
         data-toggle="modal"
     >
@@ -31,13 +31,11 @@ export default (el) => {
     const num_unread = getUnreadMsgsDisplay(el.model);
     const display_name = el.model.getDisplayName();
     const jid = el.model.get('jid');
-
     const i18n_chat = __('Click to chat with %1$s (XMPP address: %2$s)', display_name, jid);
-
     const btns = [
-       ...(api.settings.get('allow_contact_removal') ? [tplRemoveButton(el)] : []),
        tplDetailsButton(el),
-       tplAddContactButton(el)
+       tplAddContactButton(el),
+       ...(api.settings.get('allow_contact_removal') ? [tplRemoveButton(el)] : []),
     ];
 
     return html`

+ 1 - 1
src/plugins/rosterview/tests/protocol.js

@@ -460,7 +460,7 @@ describe("Presence subscriptions", function () {
 
             const header = sizzle('a:contains("Contact requests")', rosterview).pop();
             expect(u.isVisible(header)).toBe(true);
-            const contacts = header.nextElementSibling.querySelectorAll('li');
+            const contacts = header.nextElementSibling.querySelectorAll('.open-chat');
             expect(contacts.length).toBe(1);
         }));
     });

+ 276 - 0
src/plugins/rosterview/tests/requesting_contacts.js

@@ -0,0 +1,276 @@
+const { sizzle, u } = converse.env;
+
+describe('Requesting Contacts', function () {
+    beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+
+    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.querySelectorAll(
+                `ul[data-group="Contact requests"] .requesting-xmpp-contact .contact-name`
+            );
+            names = [];
+            Array.from(children).forEach(addName);
+            expect(names.join('')).toEqual(
+                mock.req_names
+                    .slice(0, mock.req_names.length + 1)
+                    .sort()
+                    .join('')
+            );
+        })
+    );
+
+    it(
+        'can have their requests accepted via a dropdown in the roster',
+        mock.initConverse([], { lazy_load_vcards: false }, async function (_converse) {
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.createContacts(_converse, 'requesting', 1);
+            const roster_el = document.querySelector('converse-roster');
+            const accept_btn = roster_el.querySelector('.dropdown-item.accept-xmpp-request');
+            accept_btn.click();
+            await u.waitUntil(() => document.querySelector('converse-accept-contact-request-modal'));
+        })
+    );
+
+    it(
+        'can have their requests declined via a dropdown in the roster',
+        mock.initConverse([], {}, async function (_converse) {
+            await mock.waitUntilDiscoConfirmed(
+                _converse,
+                _converse.domain,
+                [{ 'category': 'server', 'type': 'IM' }],
+                ['urn:xmpp:blocking']
+            );
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.openControlBox(_converse);
+            await mock.createContacts(_converse, 'requesting', 1);
+            const name = mock.req_names.sort()[0];
+            const jid = name.replace(/ /g, '.').toLowerCase() + '@montague.lit';
+            const { roster } = _converse;
+            const contact = roster.get(jid);
+            spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+            spyOn(contact, 'unauthorize').and.callFake(function () {
+                return contact;
+            });
+            const roster_el = document.querySelector('converse-roster');
+            const decline_btn = roster_el.querySelector('.dropdown-item.decline-xmpp-request');
+            decline_btn.click();
+            await u.waitUntil(() => _converse.api.confirm.calls.count);
+            await u.waitUntil(() => contact.unauthorize.calls.count());
+        })
+    );
+
+    it(
+        "do not have a header if there aren't any",
+        mock.initConverse([], { show_self_in_roster: false }, async function (_converse) {
+            await mock.openControlBox(_converse);
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.waitUntilDiscoConfirmed(
+                _converse,
+                _converse.domain,
+                [{ 'category': 'server', 'type': 'IM' }],
+                ['urn:xmpp:blocking']
+            );
+
+            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();
+
+            await u.waitUntil(() => _converse.api.confirm.calls.count);
+            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 mock.checkHeaderToggling.apply(_converse, [el.parentElement]);
+        })
+    );
+
+    it(
+        'can have their requests accepted by the user',
+        mock.initConverse([], { lazy_load_vcards: false }, 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 { api, roster } = _converse;
+            const contact = roster.get(jid);
+            spyOn(contact, 'authorize').and.callThrough();
+            const rosterview = document.querySelector('converse-roster');
+            await u.waitUntil(() => rosterview.querySelectorAll('.roster-group li').length);
+
+            const req_contact = sizzle(`.contact-name:contains("${contact.getDisplayName()}")`, rosterview).pop();
+            req_contact.parentElement.parentElement.querySelector('.accept-xmpp-request').click();
+
+            const modal = _converse.api.modal.get('converse-accept-contact-request-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000);
+
+            expect(modal.querySelector('input[name="name"]')?.value).toBe('Escalus, prince of Verona');
+            const groups_input = modal.querySelector('input[name="groups"]');
+            groups_input.value = 'Princes, Veronese';
+
+            const sent_stanzas = _converse.api.connection.get().sent_stanzas;
+            while (sent_stanzas.length) sent_stanzas.pop();
+
+            modal.querySelector('button[type="submit"]').click();
+
+            let stanza = await u.waitUntil(() => sent_stanzas.filter((s) => s.matches('iq[type="set"]')).pop());
+            expect(stanza).toEqualStanza(
+                stx`<iq type="set" xmlns="jabber:client" id="${stanza.getAttribute('id')}">
+                        <query xmlns="jabber:iq:roster">
+                            <item jid="${contact.get('jid')}" name="Escalus, prince of Verona">
+                                <group>Princes</group>
+                                <group>Veronese</group>
+                            </item>
+                        </query>
+                    </iq>`
+            );
+
+            const result = stx`
+                <iq to="${api.connection.get().jid}" type="result" id="${stanza.getAttribute('id')}" xmlns="jabber:client"/>`;
+            api.connection.get()._dataRecv(mock.createRequest(result));
+
+            stanza = await u.waitUntil(() =>
+                sent_stanzas.filter((s) => s.matches('presence[type="subscribed"]')).pop()
+            );
+            expect(stanza).toEqualStanza(
+                stx`<presence to="${contact.get('jid')}" type="subscribed" xmlns="jabber:client"/>`
+            );
+
+            await u.waitUntil(() => contact.authorize.calls.count());
+            expect(contact.authorize).toHaveBeenCalled();
+            expect(contact.get('groups')).toEqual(['Princes', 'Veronese']);
+        })
+    );
+
+    it(
+        'can have their requests denied by the user',
+        mock.initConverse([], {}, async function (_converse) {
+            await mock.waitUntilDiscoConfirmed(
+                _converse,
+                _converse.domain,
+                [{ 'category': 'server', 'type': 'IM' }],
+                ['urn:xmpp:blocking']
+            );
+            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(".contact-name:contains('" + name + "')", rosterview).pop()
+            );
+            req_contact.parentElement.parentElement.querySelector('.decline-xmpp-request').click();
+            await u.waitUntil(() => _converse.api.confirm.calls.count);
+            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) {
+            await mock.openControlBox(_converse);
+
+            const { IQ_stanzas } = _converse.api.connection.get();
+            const stanza = await u.waitUntil(() =>
+                IQ_stanzas.filter((iq) => sizzle('iq query[xmlns="jabber:iq:roster"]', iq).length).pop()
+            );
+
+            // Taken from the spec
+            // https://xmpp.org/rfcs/rfc3921.html#rfc.section.7.3
+            const result = stx`
+                <iq to="${_converse.api.connection.get().jid}" type="result" id="${stanza.getAttribute('id')}" xmlns="jabber:client">
+                    <query xmlns="jabber:iq:roster">
+                        <item jid="juliet@example.net" name="Juliet" subscription="both">
+                            <group>Friends</group>
+                        </item>
+                        <item jid="mercutio@example.org" name="Mercutio" subscription="from">
+                            <group>Friends</group>
+                        </item>
+                    </query>
+                </iq>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(result));
+
+            const pres = $pres({ from: 'data@enterprise/resource', type: 'subscribe' });
+            _converse.api.connection.get()._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 = stx`
+                <iq type="set" to="${_converse.api.connection.get().jid}" xmlns="jabber:client">
+                    <query xmlns="jabber:iq:roster" ver="ver34">
+                        <item jid="benvolio@example.org" name="Benvolio" subscription="both">
+                            <group>Friends</group>
+                        </item>
+                    </query>
+                </iq>`;
+            _converse.api.connection.get()._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();
+        })
+    );
+});

+ 1 - 224
src/plugins/rosterview/tests/roster.js

@@ -4,25 +4,6 @@ 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 .open-chat').length ===
-        Array.from(group.querySelectorAll('li .open-chat')).filter(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 () {
 
@@ -823,7 +804,7 @@ describe("The Contacts Roster", function () {
             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')]);
+            await mock.checkHeaderToggling.apply(_converse, [rosterview.querySelector('.roster-group')]);
         }));
 
         it("will be hidden when appearing under a collapsed group",
@@ -1185,210 +1166,6 @@ describe("The Contacts Roster", function () {
         }));
     });
 
-    fdescribe("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.querySelectorAll(`ul[data-group="Contact requests"] .requesting-xmpp-contact .contact-name`);
-            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([], { show_self_in_roster: false }, async function (_converse) {
-            await mock.openControlBox(_converse);
-            await mock.waitForRoster(_converse, "current", 0);
-            await mock.waitUntilDiscoConfirmed(
-                _converse,
-                _converse.domain,
-                [{ 'category': 'server', 'type': 'IM' }],
-                ['urn:xmpp:blocking']
-            );
-
-            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();
-
-            await u.waitUntil(() => _converse.api.confirm.calls.count);
-            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(
-                [], { lazy_load_vcards: false },
-                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 { api, roster } = _converse;
-            const contact = roster.get(jid);
-            spyOn(contact, 'authorize').and.callThrough();
-            const rosterview = document.querySelector('converse-roster');
-            await u.waitUntil(() => rosterview.querySelectorAll('.roster-group li').length)
-
-            const req_contact = sizzle(`.contact-name:contains("${contact.getDisplayName()}")`, rosterview).pop();
-            req_contact.parentElement.parentElement.querySelector('.accept-xmpp-request').click();
-
-            const modal = _converse.api.modal.get('converse-accept-contact-request-modal');
-            await u.waitUntil(() => u.isVisible(modal), 1000);
-
-            expect(modal.querySelector('input[name="name"]')?.value).toBe('Escalus, prince of Verona');
-            const groups_input = modal.querySelector('input[name="groups"]');
-            groups_input.value = 'Princes, Veronese';
-
-            const sent_stanzas = _converse.api.connection.get().sent_stanzas;
-            while (sent_stanzas.length) sent_stanzas.pop();
-
-            modal.querySelector('button[type="submit"]').click();
-
-            let stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.matches('iq[type="set"]')).pop());
-            expect(stanza).toEqualStanza(
-                stx`<iq type="set" xmlns="jabber:client" id="${stanza.getAttribute('id')}">
-                        <query xmlns="jabber:iq:roster">
-                            <item jid="${contact.get('jid')}" name="Escalus, prince of Verona">
-                                <group>Princes</group>
-                                <group>Veronese</group>
-                            </item>
-                        </query>
-                    </iq>`);
-
-            const result = stx`
-                <iq to="${api.connection.get().jid}" type="result" id="${stanza.getAttribute('id')}" xmlns="jabber:client"/>`;
-            api.connection.get()._dataRecv(mock.createRequest(result));
-
-            stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.matches('presence[type="subscribed"]')).pop());
-            expect(stanza).toEqualStanza(
-                stx`<presence to="${contact.get('jid')}" type="subscribed" xmlns="jabber:client"/>`);
-
-            await u.waitUntil(() => contact.authorize.calls.count());
-            expect(contact.authorize).toHaveBeenCalled();
-            expect(contact.get('groups')).toEqual(['Princes', 'Veronese']);
-        }));
-
-        it("can have their requests denied by the user",
-            mock.initConverse(
-                [], {},
-                async function (_converse) {
-
-            await mock.waitUntilDiscoConfirmed(
-                _converse,
-                _converse.domain,
-                [{ 'category': 'server', 'type': 'IM' }],
-                ['urn:xmpp:blocking']
-            );
-            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(".contact-name:contains('"+name+"')", rosterview).pop());
-            req_contact.parentElement.parentElement.querySelector('.decline-xmpp-request').click();
-            await u.waitUntil(() => _converse.api.confirm.calls.count);
-            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) {
-
-            await mock.openControlBox(_converse);
-
-            const { IQ_stanzas } = _converse.api.connection.get();
-            const stanza = await u.waitUntil(
-                () => IQ_stanzas.filter(iq => sizzle('iq query[xmlns="jabber:iq:roster"]', iq).length).pop());
-
-            // Taken from the spec
-            // https://xmpp.org/rfcs/rfc3921.html#rfc.section.7.3
-            const result = stx`
-                <iq to="${_converse.api.connection.get().jid}" type="result" id="${stanza.getAttribute('id')}" xmlns="jabber:client">
-                    <query xmlns="jabber:iq:roster">
-                        <item jid="juliet@example.net" name="Juliet" subscription="both">
-                            <group>Friends</group>
-                        </item>
-                        <item jid="mercutio@example.org" name="Mercutio" subscription="from">
-                            <group>Friends</group>
-                        </item>
-                    </query>
-                </iq>`;
-            _converse.api.connection.get()._dataRecv(mock.createRequest(result));
-
-            const pres = $pres({from: 'data@enterprise/resource', type: 'subscribe'});
-            _converse.api.connection.get()._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 = stx`
-                <iq type="set" to="${_converse.api.connection.get().jid}" xmlns="jabber:client">
-                    <query xmlns="jabber:iq:roster" ver="ver34">
-                        <item jid="benvolio@example.org" name="Benvolio" subscription="both">
-                            <group>Friends</group>
-                        </item>
-                    </query>
-                </iq>`;
-            _converse.api.connection.get()._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 () {
 

+ 39 - 5
src/plugins/rosterview/utils.js

@@ -33,6 +33,43 @@ export async function removeContact(contact, unauthorize = false) {
     return true;
 }
 
+/**
+ * @param {RosterContact} contact
+ */
+export async function declineContactRequest(contact) {
+    const domain = _converse.session.get('domain');
+    const blocking_supported = await api.disco.supports(Strophe.NS.BLOCKING, domain);
+
+    const result = await api.confirm(
+        __('Remove and decline contact request'),
+        [__('Are you sure you want to decline the contact request from %1$s?', contact.getDisplayName())],
+        blocking_supported
+            ? [
+                  {
+                      label: __('Also block this user from sending you further messages'),
+                      name: 'block',
+                      type: 'checkbox',
+                  },
+              ]
+            : []
+    );
+
+    if (result) {
+        const chat = await api.chats.get(contact.get('jid'));
+        chat?.close();
+        contact.unauthorize();
+
+        if (blocking_supported && Array.isArray(result) && result.find((i) => i.name === 'block')?.value === 'on') {
+            api.blocklist.add(contact.get('jid'));
+            api.toast.show('', { body: __('Contact request declined and user blocked') });
+        } else {
+            api.toast.show('', { body: __('Contact request declined') });
+        }
+        contact.destroy();
+    }
+    return this;
+}
+
 /**
  * @param {RosterContact} contact
  * @returns {Promise<boolean>}
@@ -47,10 +84,7 @@ export async function blockContact(contact) {
     (await api.chats.get(contact.get('jid')))?.close();
 
     try {
-        await Promise.all([
-            api.blocklist.add(contact.get('jid')),
-            contact.remove(true)
-        ]);
+        await Promise.all([api.blocklist.add(contact.get('jid')), contact.remove(true)]);
     } catch (e) {
         log.error(e);
         api.alert('error', __('Error'), [
@@ -327,6 +361,6 @@ export async function getNamesAutoCompleteList(query) {
     }
     return json.map((i) => ({
         label: `${i.fullname} <${i.jid}>`,
-        value: `${i.fullname} <${i.jid}>`
+        value: `${i.fullname} <${i.jid}>`,
     }));
 }

+ 150 - 98
src/shared/modals/templates/user-details.js

@@ -1,8 +1,8 @@
-import { html } from "lit";
-import { until } from "lit/directives/until.js";
-import { api, converse, _converse } from "@converse/headless";
-import { getGroupsAutoCompleteList } from "plugins/rosterview/utils.js";
-import { __ } from "i18n";
+import { html } from 'lit';
+import { until } from 'lit/directives/until.js';
+import { api, converse, _converse } from '@converse/headless';
+import { getGroupsAutoCompleteList } from 'plugins/rosterview/utils.js';
+import { __ } from 'i18n';
 
 const { Strophe } = converse.env;
 
@@ -10,7 +10,7 @@ const { Strophe } = converse.env;
  * @param {import('../user-details').default} el
  */
 function tplUnblockButton(el) {
-    const i18n_block = __("Remove from blocklist");
+    const i18n_block = __('Remove from blocklist');
     return html`
         <button type="button" @click="${(ev) => el.unblockContact(ev)}" class="btn btn-danger">
             <converse-icon class="fas fa-times" color="var(--background-color)" size="1em"></converse-icon
@@ -23,7 +23,7 @@ function tplUnblockButton(el) {
  * @param {import('../user-details').default} el
  */
 function tplBlockButton(el) {
-    const i18n_block = __("Add to blocklist");
+    const i18n_block = __('Add to blocklist');
     return html`
         <button type="button" @click="${(ev) => el.blockContact(ev)}" class="btn btn-danger">
             <converse-icon class="fas fa-times" color="var(--background-color)" size="1em"></converse-icon
@@ -32,11 +32,24 @@ function tplBlockButton(el) {
     `;
 }
 
+/**
+ * @param {import('../user-details').default} el
+ */
+function tplAddButton(el) {
+    const i18n_add_contact = __('Add as contact');
+    return html`
+        <button type="button" @click="${(ev) => el.addContact(ev)}" class="btn btn-success add-contact">
+            <converse-icon class="fas fa-user-plus" color="var(--background-color)" size="1em"></converse-icon
+            >&nbsp;${i18n_add_contact}
+        </button>
+    `;
+}
+
 /**
  * @param {import('../user-details').default} el
  */
 function tplRemoveButton(el) {
-    const i18n_remove_contact = __("Remove as contact");
+    const i18n_remove_contact = __('Remove as contact');
     return html`
         <button type="button" @click="${(ev) => el.removeContact(ev)}" class="btn btn-danger remove-contact">
             <converse-icon class="fas fa-trash-alt" color="var(--background-color)" size="1em"></converse-icon
@@ -45,6 +58,32 @@ function tplRemoveButton(el) {
     `;
 }
 
+/**
+ * @param {import('../user-details').default} el
+ */
+function tplAcceptButton(el) {
+    const i18n_accept = __('Accept');
+    return html`
+        <button type="button" @click="${(ev) => el.acceptContactRequest(ev)}" class="btn btn-success accept-contact-request">
+            <converse-icon class="fas fa-user-plus" color="var(--background-color)" size="1em"></converse-icon
+            >&nbsp;${i18n_accept}
+        </button>
+    `;
+}
+
+/**
+ * @param {import('../user-details').default} el
+ */
+function tplDeclineButton(el) {
+    const i18n_decline = __('Decline');
+    return html`
+        <button type="button" @click="${(ev) => el.declineContactRequest(ev)}" class="btn btn-danger decline-contact-request">
+            <converse-icon class="fas fa-user-plus" color="var(--background-color)" size="1em"></converse-icon
+            >&nbsp;${i18n_decline}
+        </button>
+    `;
+}
+
 /**
  * @param {import('../user-details').default} el
  */
@@ -53,16 +92,17 @@ export function tplUserDetailsModal(el) {
     const vcard_json = vcard ? vcard.toJSON() : {};
     const o = { ...el.model.toJSON(), ...vcard_json };
 
-    const is_roster_contact = el.getContact() !== undefined;
-    const allow_contact_removal = api.settings.get("allow_contact_removal");
+    const contact = el.getContact();
+    const is_roster_contact = contact && !contact.isUnsaved();
+    const allow_contact_removal = api.settings.get('allow_contact_removal');
 
-    const domain = _converse.session.get("domain");
+    const domain = _converse.session.get('domain');
     const blocking_supported = api.disco.supports(Strophe.NS.BLOCKING, domain).then(
         /** @param {boolean} supported */
         async (supported) => {
             const blocklist = await api.blocklist.get();
             if (supported) {
-                if (blocklist.get(el.model.get("jid"))) {
+                if (blocklist.get(el.model.get('jid'))) {
                     tplUnblockButton(el);
                 } else {
                     tplBlockButton(el);
@@ -71,22 +111,22 @@ export function tplUserDetailsModal(el) {
         }
     );
 
-    const i18n_address = __("XMPP Address");
-    const i18n_email = __("Email");
-    const i18n_full_name = __("Full Name");
-    const i18n_nickname = __("Nickname");
-    const i18n_role = __("Role");
-    const i18n_url = __("URL");
-    const i18n_groups = __("Groups");
-    const i18n_groups_help = __("Use commas to separate multiple values");
-    const i18n_omemo = __("OMEMO");
-    const i18n_profile = __("Profile");
-    const ii18n_edit = __("Edit");
+    const i18n_address = __('XMPP Address');
+    const i18n_email = __('Email');
+    const i18n_full_name = __('Full Name');
+    const i18n_nickname = __('Nickname');
+    const i18n_role = __('Role');
+    const i18n_url = __('URL');
+    const i18n_groups = __('Groups');
+    const i18n_groups_help = __('Use commas to separate multiple values');
+    const i18n_omemo = __('OMEMO');
+    const i18n_profile = __('Profile');
+    const ii18n_edit = __('Edit');
 
     const navigation_tabs = [
         html`<li role="presentation" class="nav-item">
             <a
-                class="nav-link ${el.tab === "profile" ? "active" : ""}"
+                class="nav-link ${el.tab === 'profile' ? 'active' : ''}"
                 id="profile-tab"
                 href="#profile-tabpanel"
                 aria-controls="profile-tabpanel"
@@ -99,27 +139,29 @@ export function tplUserDetailsModal(el) {
         </li>`,
     ];
 
-    navigation_tabs.push(
-        html`<li role="presentation" class="nav-item">
-            <a
-                class="nav-link ${el.tab === "edit" ? "active" : ""}"
-                id="edit-tab"
-                href="#edit-tabpanel"
-                aria-controls="edit-tabpanel"
-                role="tab"
-                @click="${(ev) => el.switchTab(ev)}"
-                data-name="edit"
-                data-toggle="tab"
-                >${ii18n_edit}</a
-            >
-        </li>`
-    );
+    if (is_roster_contact) {
+        navigation_tabs.push(
+            html`<li role="presentation" class="nav-item">
+                <a
+                    class="nav-link ${el.tab === 'edit' ? 'active' : ''}"
+                    id="edit-tab"
+                    href="#edit-tabpanel"
+                    aria-controls="edit-tabpanel"
+                    role="tab"
+                    @click="${(ev) => el.switchTab(ev)}"
+                    data-name="edit"
+                    data-toggle="tab"
+                    >${ii18n_edit}</a
+                >
+            </li>`
+        );
+    }
 
-    if (_converse.pluggable.plugins["converse-omemo"]?.enabled(_converse)) {
+    if (_converse.pluggable.plugins['converse-omemo']?.enabled(_converse)) {
         navigation_tabs.push(
             html`<li role="presentation" class="nav-item">
                 <a
-                    class="nav-link ${el.tab === "omemo" ? "active" : ""}"
+                    class="nav-link ${el.tab === 'omemo' ? 'active' : ''}"
                     id="omemo-tab"
                     href="#omemo-tabpanel"
                     aria-controls="omemo-tabpanel"
@@ -133,11 +175,8 @@ export function tplUserDetailsModal(el) {
         );
     }
 
-    const contact = el.getContact();
-    if (!contact) return ''; // Happens during tests
-
-    const name = contact.get("nickname") || contact.vcard?.get('fullname');
-    const groups = contact.get("groups");
+    const name = contact?.get('nickname') || contact.vcard?.get('fullname');
+    const groups = contact?.get('groups');
 
     return html`
         <ul class="nav nav-pills justify-content-center">
@@ -145,7 +184,7 @@ export function tplUserDetailsModal(el) {
         </ul>
         <div class="tab-content">
             <div
-                class="tab-pane ${el.tab === "profile" ? "active" : ""}"
+                class="tab-pane ${el.tab === 'profile' ? 'active' : ''}"
                 id="profile-tabpanel"
                 role="tabpanel"
                 aria-labelledby="profile-tab"
@@ -154,7 +193,9 @@ export function tplUserDetailsModal(el) {
                     <converse-avatar
                         .model="${el.model}"
                         name="${el.model.getDisplayName()}"
-                        height="140" width="140" ></converse-avatar>
+                        height="140"
+                        width="140"
+                    ></converse-avatar>
                 </div>
                 ${o.fullname
                     ? html`
@@ -163,7 +204,7 @@ export function tplUserDetailsModal(el) {
                               <div class="col-sm-8">${o.fullname}</div>
                           </div>
                       `
-                    : ""}
+                    : ''}
                 <div class="row mb-2">
                     <div class="col-sm-4"><label>${i18n_address}:</label></div>
                     <div class="col-sm-8"><a href="xmpp:${o.jid}">${o.jid}</a></div>
@@ -175,7 +216,7 @@ export function tplUserDetailsModal(el) {
                               <div class="col-sm-8">${o.nickname}</div>
                           </div>
                       `
-                    : ""}
+                    : ''}
                 ${o.url
                     ? html`
                           <div class="row mb-2">
@@ -185,7 +226,7 @@ export function tplUserDetailsModal(el) {
                               </div>
                           </div>
                       `
-                    : ""}
+                    : ''}
                 ${o.email
                     ? html`
                           <div class="row mb-2">
@@ -193,7 +234,7 @@ export function tplUserDetailsModal(el) {
                               <div class="col-sm-8"><a href="mailto:${o.email}">${o.email}</a></div>
                           </div>
                       `
-                    : ""}
+                    : ''}
                 ${o.role
                     ? html`
                           <div class="row mb-2">
@@ -201,7 +242,7 @@ export function tplUserDetailsModal(el) {
                               <div class="col-sm-8">${o.role}</div>
                           </div>
                       `
-                    : ""}
+                    : ''}
                 ${groups.length
                     ? html`
                           <div class="row mb-2">
@@ -214,61 +255,72 @@ export function tplUserDetailsModal(el) {
                               </div>
                           </div>
                       `
-                    : ""}
-            </div>
+                    : ''}
 
-            <div
-                class="tab-pane ${el.tab === "edit" ? "active" : ""}"
-                id="edit-tabpanel"
-                role="tabpanel"
-                aria-labelledby="edit-tab"
-            >
-                ${el.tab === "edit"
-                    ? html`<form class="converse-form" @submit=${(ev) => el.updateContact(ev)}>
-                              <div class="mb-3">
-                                  <label class="form-label clearfix" for="name">${__("Name")}:</label>
-                                  <input
-                                      type="text"
-                                      name="name"
-                                      value="${name}"
-                                      class="form-control"
-                                  />
-                              </div>
-                              <div class="mb-3">
-                                  <label class="form-label clearfix" for="name">${i18n_groups}:</label>
-                                  <div class="mb-1">
-                                      <small class="form-text text-muted">${i18n_groups_help}</small>
-                                  </div>
-                                  <converse-autocomplete
-                                      .list=${getGroupsAutoCompleteList()}
-                                      name="groups"
-                                      value="${groups}"
-                                  ></converse-autocomplete>
-                              </div>
-                              <button type="submit" class="btn btn-primary">${__("Update")}</button>
-                          </form>
-                          <hr />
-
-                          ${allow_contact_removal && is_roster_contact ? tplRemoveButton(el) : ""}
-                          ${until(
-                              blocking_supported.then(() => tplBlockButton(el)),
-                              ""
-                          )}`
-                    : ""}
+                <hr />
+                ${contact.get('requesting')
+                    ? html`<div class="row mb-2">
+                              <div class="col-sm-4"><label>${__('Contact Request')}:</label></div>
+                              <div class="col-sm-8">${tplAcceptButton(el)} ${tplDeclineButton(el)}</div>
+                          </div>`
+                    : ''}
+                ${!is_roster_contact ? tplAddButton(el) : ''}
+                ${!contact
+                    ? until(
+                          blocking_supported.then(() => tplBlockButton(el)),
+                          ''
+                      )
+                    : ''}
             </div>
 
-            ${_converse.pluggable.plugins["converse-omemo"]?.enabled(_converse)
+            ${is_roster_contact
                 ? html` <div
-                      class="tab-pane ${el.tab === "omemo" ? "active" : ""}"
+                      class="tab-pane ${el.tab === 'edit' ? 'active' : ''}"
+                      id="edit-tabpanel"
+                      role="tabpanel"
+                      aria-labelledby="edit-tab"
+                  >
+                      ${el.tab === 'edit'
+                          ? html`<form class="converse-form" @submit=${(ev) => el.updateContact(ev)}>
+                                    <div class="mb-3">
+                                        <label class="form-label clearfix" for="name">${__('Name')}:</label>
+                                        <input type="text" name="name" value="${name}" class="form-control" />
+                                    </div>
+                                    <div class="mb-3">
+                                        <label class="form-label clearfix" for="name">${i18n_groups}:</label>
+                                        <div class="mb-1">
+                                            <small class="form-text text-muted">${i18n_groups_help}</small>
+                                        </div>
+                                        <converse-autocomplete
+                                            .list=${getGroupsAutoCompleteList()}
+                                            name="groups"
+                                            value="${groups}"
+                                        ></converse-autocomplete>
+                                    </div>
+                                    <button type="submit" class="btn btn-primary">${__('Update')}</button>
+                                </form>
+                                <hr />
+
+                                ${allow_contact_removal && is_roster_contact ? tplRemoveButton(el) : ''}
+                                ${until(
+                                    blocking_supported.then(() => tplBlockButton(el)),
+                                    ''
+                                )}`
+                          : ''}
+                  </div>`
+                : ''}
+            ${_converse.pluggable.plugins['converse-omemo']?.enabled(_converse)
+                ? html`<div
+                      class="tab-pane ${el.tab === 'omemo' ? 'active' : ''}"
                       id="omemo-tabpanel"
                       role="tabpanel"
                       aria-labelledby="omemo-tab"
                   >
-                      ${el.tab === "omemo"
+                      ${el.tab === 'omemo'
                           ? html`<converse-omemo-fingerprints jid=${o.jid}></converse-omemo-fingerprints>`
-                          : ""}
+                          : ''}
                   </div>`
-                : ""}
+                : ''}
         </div>
     `;
 }

+ 163 - 105
src/shared/modals/tests/user-details-modal.js

@@ -1,43 +1,43 @@
 /*global mock, converse */
 const { sizzle, u } = converse.env;
 
-describe("The User Details Modal", function () {
+describe('The User Details Modal', function () {
     beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
 
-    it("can be used to set a contact's name and groups",
-            mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+    it(
+        "can be used to set a contact's name and groups",
+        mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+            const { api } = _converse;
+            await mock.waitForRoster(_converse, 'current', 1);
+            api.trigger('rosterContactsFetched');
 
-        const { api } = _converse;
-        await mock.waitForRoster(_converse, 'current', 1);
-        api.trigger('rosterContactsFetched');
+            const contact_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, contact_jid);
+            await u.waitUntil(() => _converse.chatboxes.length > 1);
 
-        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-        await mock.openChatBoxFor(_converse, contact_jid);
-        await u.waitUntil(() => _converse.chatboxes.length > 1);
+            const view = _converse.chatboxviews.get(contact_jid);
+            let show_modal_button = view.querySelector('.show-msg-author-modal');
+            show_modal_button.click();
+            let modal = api.modal.get('converse-user-details-modal');
+            await u.waitUntil(() => u.isVisible(modal));
+            modal.querySelector('#edit-tab').click();
 
-        const view = _converse.chatboxviews.get(contact_jid);
-        let show_modal_button = view.querySelector('.show-msg-author-modal');
-        show_modal_button.click();
-        let modal = api.modal.get('converse-user-details-modal');
-        await u.waitUntil(() => u.isVisible(modal));
-        modal.querySelector('#edit-tab').click();
+            const name_input = await u.waitUntil(() => modal.querySelector('input[name="name"]'));
+            expect(name_input.value).toBe('Mercutio');
 
-        const name_input = await u.waitUntil(() => modal.querySelector('input[name="name"]'));
-        expect(name_input.value).toBe('Mercutio');
+            const groups_input = modal.querySelector('input[name="groups"]');
+            expect(groups_input.value).toBe('Colleagues,friends & acquaintences');
 
-        const groups_input = modal.querySelector('input[name="groups"]');
-        expect(groups_input.value).toBe('Colleagues,friends & acquaintences');
+            const sent_stanzas = _converse.api.connection.get().sent_stanzas;
+            while (sent_stanzas.length) sent_stanzas.pop();
 
-        const sent_stanzas = _converse.api.connection.get().sent_stanzas;
-        while (sent_stanzas.length) sent_stanzas.pop();
+            name_input.value = 'New Name';
+            groups_input.value = 'Other';
+            modal.querySelector('button[type="submit"]').click();
+            await u.waitUntil(() => modal.getAttribute('aria-hidden'));
 
-        name_input.value = 'New Name';
-        groups_input.value = 'Other';
-        modal.querySelector('button[type="submit"]').click();
-        await u.waitUntil(() => modal.getAttribute('aria-hidden'));
-
-        const sent_IQ = await u.waitUntil(() => sent_stanzas.pop());
-        expect(sent_IQ).toEqualStanza(stx`
+            const sent_IQ = await u.waitUntil(() => sent_stanzas.pop());
+            expect(sent_IQ).toEqualStanza(stx`
             <iq xmlns="jabber:client"
                     type="set"
                     id="${sent_IQ.getAttribute('id')}">
@@ -45,81 +45,139 @@ describe("The User Details Modal", function () {
                     <item jid="mercutio@montague.lit" name="New Name"><group>Other</group></item>
                 </query>
             </iq>`);
-    }));
-
-    it("can be used to remove a contact",
-            mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
-
-        const { api } = _converse;
-        await mock.waitForRoster(_converse, 'current', 1);
-        api.trigger('rosterContactsFetched');
-
-        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-        await mock.openChatBoxFor(_converse, contact_jid);
-        await u.waitUntil(() => _converse.chatboxes.length > 1);
-
-        const view = _converse.chatboxviews.get(contact_jid);
-        let show_modal_button = view.querySelector('.show-msg-author-modal');
-        show_modal_button.click();
-        let modal = api.modal.get('converse-user-details-modal');
-        await u.waitUntil(() => u.isVisible(modal));
-        modal.querySelector('#edit-tab').click();
-        spyOn(view.model.contact, 'sendRosterRemoveStanza').and.callFake(callback => callback());
-        let remove_contact_button = await u.waitUntil(() => modal.querySelector('button.remove-contact'));
-        remove_contact_button.click();
-
-        modal = await u.waitUntil(() => document.querySelector('converse-confirm-modal'));
-        modal.querySelector('.btn-primary').click();
-        await u.waitUntil(() => modal.getAttribute('aria-hidden'));
-
-        show_modal_button = view.querySelector('.show-msg-author-modal');
-        show_modal_button.click();
-        remove_contact_button = modal.querySelector('button.remove-contact');
-        expect(remove_contact_button === null).toBeTruthy();
-    }));
-
-    it("shows an alert when an error happened while removing the contact",
-            mock.initConverse([], {}, async function (_converse) {
-
-        await mock.waitForRoster(_converse, 'current', 1);
-        _converse.api.trigger('rosterContactsFetched');
-
-        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-        await mock.openChatBoxFor(_converse, contact_jid)
-        const view = _converse.chatboxviews.get(contact_jid);
-        let show_modal_button = view.querySelector('.show-msg-author-modal');
-        show_modal_button.click();
-        let modal = _converse.api.modal.get('converse-user-details-modal');
-        await u.waitUntil(() => u.isVisible(modal), 2000);
-        modal.querySelector('#edit-tab').click();
-        spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
-
-        spyOn(view.model.contact, 'sendRosterRemoveStanza').and.callFake(() => {
-            throw new Error('foo')
-        });
-        let remove_contact_button = await u.waitUntil(() => modal.querySelector('button.remove-contact'));
-        expect(u.isVisible(remove_contact_button)).toBeTruthy();
-
-        remove_contact_button.click();
-        await u.waitUntil(() => !u.isVisible(modal))
-        await u.waitUntil(() => u.isVisible(document.querySelector('.alert-danger')), 2000);
-
-        const header = document.querySelector('.alert-danger .modal-title');
-        expect(header.textContent).toBe("Error");
-        expect(u.ancestor(header, '.modal-content').querySelector('.modal-body p').textContent.trim())
-            .toBe("Sorry, an error occurred while trying to remove Mercutio as a contact");
-        document.querySelector('.alert-danger .btn[aria-label="Close"]').click();
-
-        show_modal_button = view.querySelector('.show-msg-author-modal');
-        show_modal_button.click();
-        modal = _converse.api.modal.get('converse-user-details-modal');
-        await u.waitUntil(() => u.isVisible(modal), 2000)
-
-        show_modal_button = view.querySelector('.show-msg-author-modal');
-        show_modal_button.click();
-        await u.waitUntil(() => u.isVisible(modal), 2000)
-
-        remove_contact_button = modal.querySelector('button.remove-contact');
-        expect(u.isVisible(remove_contact_button)).toBeTruthy();
-    }));
+        })
+    );
+
+    it(
+        'can be used to remove a contact',
+        mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+            const { api } = _converse;
+            await mock.waitForRoster(_converse, 'current', 1);
+            api.trigger('rosterContactsFetched');
+
+            const contact_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, contact_jid);
+            await u.waitUntil(() => _converse.chatboxes.length > 1);
+
+            const view = _converse.chatboxviews.get(contact_jid);
+            let show_modal_button = view.querySelector('.show-msg-author-modal');
+            show_modal_button.click();
+            let modal = api.modal.get('converse-user-details-modal');
+            await u.waitUntil(() => u.isVisible(modal));
+            modal.querySelector('#edit-tab').click();
+            spyOn(view.model.contact, 'sendRosterRemoveStanza').and.callFake((callback) => callback());
+            let remove_contact_button = await u.waitUntil(() => modal.querySelector('button.remove-contact'));
+            remove_contact_button.click();
+
+            modal = await u.waitUntil(() => document.querySelector('converse-confirm-modal'));
+            modal.querySelector('.btn-primary').click();
+            await u.waitUntil(() => modal.getAttribute('aria-hidden'));
+
+            show_modal_button = view.querySelector('.show-msg-author-modal');
+            show_modal_button.click();
+            remove_contact_button = modal.querySelector('button.remove-contact');
+            expect(remove_contact_button === null).toBeTruthy();
+        })
+    );
+
+    it(
+        'shows an alert when an error happened while removing the contact',
+        mock.initConverse([], {}, async function (_converse) {
+            await mock.waitForRoster(_converse, 'current', 1);
+            _converse.api.trigger('rosterContactsFetched');
+
+            const contact_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, contact_jid);
+            const view = _converse.chatboxviews.get(contact_jid);
+            let show_modal_button = view.querySelector('.show-msg-author-modal');
+            show_modal_button.click();
+            let modal = _converse.api.modal.get('converse-user-details-modal');
+            await u.waitUntil(() => u.isVisible(modal), 2000);
+            modal.querySelector('#edit-tab').click();
+            spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+
+            spyOn(view.model.contact, 'sendRosterRemoveStanza').and.callFake(() => {
+                throw new Error('foo');
+            });
+            let remove_contact_button = await u.waitUntil(() => modal.querySelector('button.remove-contact'));
+            expect(u.isVisible(remove_contact_button)).toBeTruthy();
+
+            remove_contact_button.click();
+            await u.waitUntil(() => !u.isVisible(modal));
+            await u.waitUntil(() => u.isVisible(document.querySelector('.alert-danger')), 2000);
+
+            const header = document.querySelector('.alert-danger .modal-title');
+            expect(header.textContent).toBe('Error');
+            expect(u.ancestor(header, '.modal-content').querySelector('.modal-body p').textContent.trim()).toBe(
+                'Sorry, an error occurred while trying to remove Mercutio as a contact'
+            );
+            document.querySelector('.alert-danger .btn[aria-label="Close"]').click();
+
+            show_modal_button = view.querySelector('.show-msg-author-modal');
+            show_modal_button.click();
+            modal = _converse.api.modal.get('converse-user-details-modal');
+            await u.waitUntil(() => u.isVisible(modal), 2000);
+
+            show_modal_button = view.querySelector('.show-msg-author-modal');
+            show_modal_button.click();
+            await u.waitUntil(() => u.isVisible(modal), 2000);
+
+            remove_contact_button = modal.querySelector('button.remove-contact');
+            expect(u.isVisible(remove_contact_button)).toBeTruthy();
+        })
+    );
+
+    it(
+        'can be used to accept a contact request',
+        mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.openControlBox(_converse);
+            await mock.createContacts(_converse, 'requesting', 1);
+            const name = mock.req_names.sort()[0];
+            const contact_jid = name.replace(/ /g, '.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, contact_jid);
+            const view = _converse.chatboxviews.get(contact_jid);
+
+            const show_modal_button = view.querySelector('.show-msg-author-modal');
+            show_modal_button.click();
+            const modal = _converse.api.modal.get('converse-user-details-modal');
+            expect(modal).toBeDefined();
+            await u.waitUntil(() => u.isVisible(modal));
+            modal.querySelector('.accept-contact-request').click();
+            await u.waitUntil(() => document.querySelector('converse-accept-contact-request-modal'));
+        })
+    );
+
+    it(
+        'can be used to decline a contact request',
+        mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+            await mock.waitUntilDiscoConfirmed(
+                _converse,
+                _converse.domain,
+                [{ 'category': 'server', 'type': 'IM' }],
+                ['urn:xmpp:blocking']
+            );
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.openControlBox(_converse);
+            await mock.createContacts(_converse, 'requesting', 1);
+            const name = mock.req_names.sort()[0];
+            const contact_jid = name.replace(/ /g, '.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, contact_jid);
+            const view = _converse.chatboxviews.get(contact_jid);
+            const { roster } = _converse.state;
+            const contact = roster.get(contact_jid);
+
+            const show_modal_button = view.querySelector('.show-msg-author-modal');
+            show_modal_button.click();
+            const modal = _converse.api.modal.get('converse-user-details-modal');
+            expect(modal).toBeDefined();
+
+            spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
+            spyOn(contact, 'unauthorize').and.callFake(function () { return contact; });
+            await u.waitUntil(() => u.isVisible(modal));
+            modal.querySelector('.decline-contact-request').click();
+            await u.waitUntil(() => _converse.api.confirm.calls.count);
+            await u.waitUntil(() => contact.unauthorize.calls.count());
+        })
+    );
 });

+ 41 - 3
src/shared/modals/user-details.js

@@ -1,5 +1,6 @@
-import { api, _converse } from '@converse/headless';
-import { blockContact, removeContact, unblockContact } from 'plugins/rosterview/utils.js';
+import { Model } from '@converse/skeletor';
+import { api, _converse, log } from '@converse/headless';
+import { blockContact, declineContactRequest, removeContact, unblockContact } from 'plugins/rosterview/utils.js';
 import BaseModal from 'plugins/modal/modal.js';
 import { __ } from 'i18n';
 import { tplUserDetailsModal } from './templates/user-details.js';
@@ -73,11 +74,24 @@ export default class UserDetailsModal extends BaseModal {
         this.listenTo(contact, 'change', () => this.requestUpdate());
         this.listenTo(contact, 'destroy', () => this.close());
         this.listenTo(contact.vcard, 'change', () => this.requestUpdate());
-        if (contact.vcard) { // Refresh the vcard
+        if (contact.vcard) {
+            // Refresh the vcard
             api.vcard.update(contact.vcard, true);
         }
     }
 
+    /**
+     * @param {MouseEvent} ev
+     */
+    async addContact(ev) {
+        ev?.preventDefault?.();
+        this.modal.hide();
+        api.modal.show('converse-add-contact-modal', {
+            contact: this.model,
+            model: new Model()
+        }, ev);
+    }
+
     /**
      * @param {MouseEvent} ev
      */
@@ -120,6 +134,30 @@ export default class UserDetailsModal extends BaseModal {
         setTimeout(() => unblockContact(this.getContact()), 1);
         this.modal.hide();
     }
+
+    /**
+     * @param {MouseEvent} ev
+     */
+    async acceptContactRequest(ev) {
+        ev?.preventDefault?.();
+        setTimeout(() => {
+            api.modal.show(
+                'converse-accept-contact-request-modal',
+                { model: new Model(), contact: this.getContact() },
+                ev
+            );
+        });
+        this.modal.hide();
+    }
+
+    /**
+     * @param {MouseEvent} ev
+     */
+    async declineContactRequest(ev) {
+        ev?.preventDefault?.();
+        setTimeout(() => declineContactRequest(this.getContact()));
+        this.modal.hide();
+    }
 }
 
 api.elements.define('converse-user-details-modal', UserDetailsModal);

+ 11 - 9
src/shared/styles/buttons.scss

@@ -23,6 +23,7 @@
                 color: var(--button-text-color);
             }
         }
+
     }
 
     .btn-primary,
@@ -34,7 +35,7 @@
         --converse-btn-disabled-border-color: var(--primary-color);
         --converse-btn-disabled-color: var(--primary-color);
         --converse-btn-hover-bg: var(--primary-color);
-        --converse-btn-hover-border-color: var(--primary-color-hover);
+        --converse-btn-hover-border-color: var(--primary-color);
         --converse-btn-hover-color: var(--background-color);
     }
     .btn-primary {
@@ -55,7 +56,7 @@
         --converse-btn-disabled-border-color: var(--secondary-color);
         --converse-btn-disabled-color: var(--secondary-color);
         --converse-btn-hover-bg: var(--secondary-color);
-        --converse-btn-hover-border-color: var(--secondary-color-hover);
+        --converse-btn-hover-border-color: var(--secondary-color);
         --converse-btn-hover-color: var(--background-color);
     }
     .btn-secondary {
@@ -75,8 +76,9 @@
         --converse-btn-disabled-bg: var(--disabled-color);
         --converse-btn-disabled-border-color: var(--success-color);
         --converse-btn-disabled-color: var(--success-color);
-        --converse-btn-hover-bg: var(--success-color-hover);
-        --converse-btn-hover-border-color: var(--success-color-hover);
+        --converse-btn-hover-bg: var(--success-color);
+        --converse-btn-hover-color: var(--button-text-color);
+        --converse-btn-hover-border-color: var(--success-color);
     }
     .btn-success {
         --converse-btn-color: var(--button-text-color);
@@ -95,8 +97,8 @@
         --converse-btn-disabled-bg: var(--disabled-color);
         --converse-btn-disabled-border-color: var(--warning-color);
         --converse-btn-disabled-color: var(--warning-color);
-        --converse-btn-hover-bg: var(--warning-color-hover);
-        --converse-btn-hover-border-color: var(--warning-color-hover);
+        --converse-btn-hover-bg: var(--warning-color);
+        --converse-btn-hover-border-color: var(--warning-color);
     }
     .btn-warning {
         --converse-btn-color: var(--button-text-color);
@@ -115,8 +117,8 @@
         --converse-btn-disabled-bg: var(--disabled-color);
         --converse-btn-disabled-border-color: var(--danger-color);
         --converse-btn-disabled-color: var(--danger-color);
-        --converse-btn-hover-bg: var(--danger-color-hover);
-        --converse-btn-hover-border-color: var(--danger-color-hover);
+        --converse-btn-hover-bg: var(--danger-color);
+        --converse-btn-hover-border-color: var(--danger-color);
         --converse-btn-hover-color: var(--background-color);
     }
     .btn-danger {
@@ -137,7 +139,7 @@
         --converse-btn-disabled-border-color: var(--info-color);
         --converse-btn-disabled-color: var(--info-color);
         --converse-btn-hover-bg: var(--info-color-dark);
-        --converse-btn-hover-border-color: var(--danger-color-hover);
+        --converse-btn-hover-border-color: var(--danger-color);
         --converse-btn-hover-border-color: var(--info-color-dark);
     }
     .btn-info {

+ 0 - 1
src/shared/styles/themes/cyberpunk.scss

@@ -52,7 +52,6 @@
     .shadow-lg {
         --converse-box-shadow-lg: 0 1rem 3rem var(--background-color);
     }
-
     .navbar-nav {
         --converse-nav-link-color: var(--link-color) !important;
         --converse-nav-link-hover-color: var(--link-color-hover) !important;

+ 20 - 0
src/shared/tests/mock.js

@@ -54,6 +54,25 @@ function initConverse (promise_names=[], settings=null, func) {
     }
 }
 
+export async function checkHeaderToggling(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 .open-chat').length ===
+        Array.from(group.querySelectorAll('li .open-chat')).filter(u.isVisible).length);
+
+    expect(u.hasClass('fa-caret-right', toggle.firstElementChild)).toBeFalsy();
+    expect(u.hasClass('fa-caret-down', toggle.firstElementChild)).toBeTruthy();
+};
+
 async function waitUntilDiscoConfirmed (_converse, entity_jid, identities, features=[], items=[], type='info') {
     const sel = `iq[to="${entity_jid}"] query[xmlns="http://jabber.org/protocol/disco#${type}"]`;
     const iq = await u.waitUntil(() => _converse.api.connection.get().IQ_stanzas.find(iq => sizzle(sel, iq).length));
@@ -925,6 +944,7 @@ Object.assign(mock, {
     bundleHasBeenPublished,
     chatroom_names,
     chatroom_roles,
+    checkHeaderToggling,
     closeAllChatBoxes,
     closeControlBox,
     createChatMessage,

+ 5 - 1
src/types/plugins/modal/toast.d.ts

@@ -1,5 +1,8 @@
 export default class Toast extends CustomElement {
     static get properties(): {
+        type: {
+            type: StringConstructor;
+        };
         name: {
             type: StringConstructor;
         };
@@ -13,7 +16,8 @@ export default class Toast extends CustomElement {
     name: string;
     body: string;
     header: string;
-    initialize(): void;
+    type: string;
+    timeoutId: NodeJS.Timeout;
     render(): import("lit-html").TemplateResult<1>;
     /**
      * @param {MouseEvent} [ev]

+ 1 - 0
src/types/plugins/modal/types.d.ts

@@ -12,5 +12,6 @@ export type ToastProperties = {
     title?: string;
     body?: string;
     name: string;
+    type: 'info' | 'danger';
 };
 //# sourceMappingURL=types.d.ts.map

+ 1 - 1
src/types/plugins/rosterview/contactview.d.ts

@@ -39,7 +39,7 @@ export default class RosterContactView extends ObservableElement {
     /**
      * @param {MouseEvent} ev
      */
-    declineRequest(ev: MouseEvent): Promise<this>;
+    declineRequest(ev: MouseEvent): Promise<void>;
 }
 import { ObservableElement } from 'shared/components/observable.js';
 //# sourceMappingURL=contactview.d.ts.map

+ 1 - 1
src/types/plugins/rosterview/modals/accept-contact-request.d.ts

@@ -7,5 +7,5 @@ export default class AcceptContactRequest extends BaseModal {
      */
     acceptContactRequest(ev: Event): Promise<void>;
 }
-import BaseModal from "plugins/modal/modal.js";
+import BaseModal from 'plugins/modal/modal.js';
 //# sourceMappingURL=accept-contact-request.d.ts.map

+ 4 - 3
src/types/plugins/rosterview/modals/add-contact.d.ts

@@ -1,4 +1,6 @@
 export default class AddContactModal extends BaseModal {
+    constructor();
+    contact: any;
     renderModal(): import("lit-html").TemplateResult<1>;
     getModalTitle(): any;
     /**
@@ -6,16 +8,15 @@ export default class AddContactModal extends BaseModal {
      */
     validateSubmission(jid: string): boolean;
     /**
-     * @param {HTMLFormElement} form
      * @param {string} jid
      * @param {string} name
      * @param {string[]} groups
      */
-    afterSubmission(form: HTMLFormElement, jid: string, name: string, groups: string[]): Promise<void>;
+    afterSubmission(jid: string, name: string, groups: string[]): Promise<void>;
     /**
      * @param {Event} ev
      */
     addContactFromForm(ev: Event): Promise<void>;
 }
-import BaseModal from "plugins/modal/modal.js";
+import BaseModal from 'plugins/modal/modal.js';
 //# sourceMappingURL=add-contact.d.ts.map

+ 4 - 0
src/types/plugins/rosterview/utils.d.ts

@@ -4,6 +4,10 @@
  * @returns {Promise<boolean>}
  */
 export function removeContact(contact: RosterContact, unauthorize?: boolean): Promise<boolean>;
+/**
+ * @param {RosterContact} contact
+ */
+export function declineContactRequest(contact: RosterContact): Promise<any>;
 /**
  * @param {RosterContact} contact
  * @returns {Promise<boolean>}

+ 1 - 1
src/types/shared/modals/templates/user-details.d.ts

@@ -1,5 +1,5 @@
 /**
  * @param {import('../user-details').default} el
  */
-export function tplUserDetailsModal(el: import("../user-details").default): import("lit-html").TemplateResult<1> | "";
+export function tplUserDetailsModal(el: import("../user-details").default): import("lit-html").TemplateResult<1>;
 //# sourceMappingURL=user-details.d.ts.map

+ 13 - 1
src/types/shared/modals/user-details.d.ts

@@ -6,12 +6,16 @@ export default class UserDetailsModal extends BaseModal {
      * @param {Map<string, any>} changed
      */
     shouldUpdate(changed: Map<string, any>): boolean;
-    renderModal(): import("lit-html").TemplateResult<1> | "";
+    renderModal(): import("lit-html").TemplateResult<1>;
     getModalTitle(): any;
     /**
      * @param {import('@converse/headless/types/plugins/roster/contact').default} contact
      */
     registerContactEventHandlers(contact: import("@converse/headless/types/plugins/roster/contact").default): void;
+    /**
+     * @param {MouseEvent} ev
+     */
+    addContact(ev: MouseEvent): Promise<void>;
     /**
      * @param {MouseEvent} ev
      */
@@ -28,6 +32,14 @@ export default class UserDetailsModal extends BaseModal {
      * @param {MouseEvent} ev
      */
     unblockContact(ev: MouseEvent): Promise<void>;
+    /**
+     * @param {MouseEvent} ev
+     */
+    acceptContactRequest(ev: MouseEvent): Promise<void>;
+    /**
+     * @param {MouseEvent} ev
+     */
+    declineContactRequest(ev: MouseEvent): Promise<void>;
 }
 import BaseModal from 'plugins/modal/modal.js';
 //# sourceMappingURL=user-details.d.ts.map