Sfoglia il codice sorgente

Create an ElementView base modal and use it for all modals

Modals are now all web components and are opened by component name.
JC Brand 2 anni fa
parent
commit
fbe86e5af8
92 ha cambiato i file con 1783 aggiunte e 1841 eliminazioni
  1. 1 0
      karma.conf.js
  2. 271 289
      package-lock.json
  3. 1 1
      package.json
  4. 0 1
      src/headless/plugins/roster/contacts.js
  5. 0 14
      src/modals/image.js
  6. 0 10
      src/modals/message-versions.js
  7. 0 20
      src/modals/templates/image.js
  8. 0 20
      src/modals/templates/message-versions.js
  9. 0 71
      src/modals/templates/user-details.js
  10. 2 2
      src/plugins/bookmark-views/mixins.js
  11. 14 13
      src/plugins/bookmark-views/modal.js
  12. 0 19
      src/plugins/bookmark-views/templates/modal.js
  13. 8 8
      src/plugins/bookmark-views/tests/bookmarks.js
  14. 2 2
      src/plugins/bookmark-views/utils.js
  15. 2 2
      src/plugins/chatview/heading.js
  16. 3 3
      src/plugins/chatview/tests/corrections.js
  17. 8 200
      src/plugins/controlbox/tests/controlbox.js
  18. 15 11
      src/plugins/modal/alert.js
  19. 41 17
      src/plugins/modal/api.js
  20. 1 1
      src/plugins/modal/base.js
  21. 25 28
      src/plugins/modal/confirm.js
  22. 70 0
      src/plugins/modal/modal.js
  23. 0 45
      src/plugins/modal/styles/_modal.scss
  24. 3 12
      src/plugins/modal/templates/alert.js
  25. 0 0
      src/plugins/modal/templates/modal-alert.js
  26. 29 0
      src/plugins/modal/templates/modal.js
  27. 13 26
      src/plugins/modal/templates/prompt.js
  28. 6 6
      src/plugins/muc-views/heading.js
  29. 28 38
      src/plugins/muc-views/modals/add-muc.js
  30. 18 14
      src/plugins/muc-views/modals/moderator-tools.js
  31. 21 13
      src/plugins/muc-views/modals/muc-details.js
  32. 23 31
      src/plugins/muc-views/modals/muc-invite.js
  33. 27 24
      src/plugins/muc-views/modals/muc-list.js
  34. 14 12
      src/plugins/muc-views/modals/nickname.js
  35. 15 15
      src/plugins/muc-views/modals/occupant.js
  36. 57 0
      src/plugins/muc-views/modals/templates/add-muc.js
  37. 0 19
      src/plugins/muc-views/modals/templates/moderator-tools.js
  38. 24 39
      src/plugins/muc-views/modals/templates/muc-details.js
  39. 23 37
      src/plugins/muc-views/modals/templates/muc-invite.js
  40. 0 21
      src/plugins/muc-views/modals/templates/nickname.js
  41. 29 43
      src/plugins/muc-views/modals/templates/occupant.js
  42. 10 0
      src/plugins/muc-views/modtools.js
  43. 10 8
      src/plugins/muc-views/styles/add-muc-modal.scss
  44. 0 1
      src/plugins/muc-views/styles/index.scss
  45. 8 3
      src/plugins/muc-views/styles/muc-details-modal.scss
  46. 0 8
      src/plugins/muc-views/styles/muc-details.scss
  47. 0 56
      src/plugins/muc-views/templates/add-muc.js
  48. 18 6
      src/plugins/muc-views/templates/moderator-tools.js
  49. 17 30
      src/plugins/muc-views/templates/muc-list.js
  50. 7 4
      src/plugins/muc-views/templates/muc-nickname-form.js
  51. 6 6
      src/plugins/muc-views/tests/corrections.js
  52. 51 51
      src/plugins/muc-views/tests/modtools.js
  53. 34 34
      src/plugins/muc-views/tests/muc-add-modal.js
  54. 16 16
      src/plugins/muc-views/tests/muc-list-modal.js
  55. 18 18
      src/plugins/muc-views/tests/muc.js
  56. 19 19
      src/plugins/muc-views/tests/nickname.js
  57. 5 5
      src/plugins/muc-views/utils.js
  58. 1 1
      src/plugins/omemo/index.js
  59. 3 3
      src/plugins/omemo/templates/profile.js
  60. 9 9
      src/plugins/omemo/tests/omemo.js
  61. 3 2
      src/plugins/profile/index.js
  62. 22 37
      src/plugins/profile/modals/chat-status.js
  63. 29 40
      src/plugins/profile/modals/profile.js
  64. 38 0
      src/plugins/profile/modals/styles/profile.scss
  65. 40 35
      src/plugins/profile/modals/templates/user-settings.js
  66. 25 17
      src/plugins/profile/modals/user-settings.js
  67. 4 6
      src/plugins/profile/statusview.js
  68. 46 47
      src/plugins/profile/templates/chat-status-modal.js
  69. 69 68
      src/plugins/profile/templates/profile_modal.js
  70. 4 4
      src/plugins/roomslist/templates/roomslist.js
  71. 6 6
      src/plugins/roomslist/tests/roomslist.js
  72. 2 2
      src/plugins/roomslist/view.js
  73. 34 34
      src/plugins/rosterview/modals/add-contact.js
  74. 29 41
      src/plugins/rosterview/modals/templates/add-contact.js
  75. 8 7
      src/plugins/rosterview/rosterview.js
  76. 195 0
      src/plugins/rosterview/tests/add-contact-modal.js
  77. 11 10
      src/plugins/rosterview/tests/presence.js
  78. 3 3
      src/plugins/rosterview/tests/protocol.js
  79. 14 0
      src/plugins/rosterview/utils.js
  80. 2 0
      src/shared/autocomplete/component.js
  81. 2 2
      src/shared/chat/message-body.js
  82. 7 7
      src/shared/chat/message.js
  83. 22 0
      src/shared/modals/image.js
  84. 19 0
      src/shared/modals/message-versions.js
  85. 6 0
      src/shared/modals/styles/image.scss
  86. 3 0
      src/shared/modals/templates/image.js
  87. 69 0
      src/shared/modals/templates/user-details.js
  88. 14 14
      src/shared/modals/tests/user-details-modal.js
  89. 23 48
      src/shared/modals/user-details.js
  90. 5 5
      src/shared/tests/mock.js
  91. 2 1
      src/utils/html.js
  92. 1 0
      webpack.html

+ 1 - 0
karma.conf.js

@@ -106,6 +106,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/register/tests/register.js", type: 'module' },
       { pattern: "src/plugins/roomslist/tests/roomslist.js", type: 'module' },
       { pattern: "src/plugins/rootview/tests/root.js", type: 'module' },
+      { pattern: "src/plugins/rosterview/tests/add-contact-modal.js", type: 'module' },
       { 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' },

File diff suppressed because it is too large
+ 271 - 289
package-lock.json


+ 1 - 1
package.json

@@ -87,7 +87,7 @@
     "karma-jasmine": "^5.0.0",
     "karma-jasmine-html-reporter": "^1.7.0",
     "karma-webpack": "^5.0.0",
-    "lerna": "^5.1.8",
+    "lerna": "^5.5.1",
     "mini-css-extract-plugin": "^2.6.0",
     "minimist": "^1.2.6",
     "po-loader": "0.6.1",

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

@@ -1,6 +1,5 @@
 import RosterContact from './contact.js';
 import log from "@converse/headless/log";
-import sum from 'lodash-es/sum';
 import { Collection } from "@converse/skeletor/src/collection";
 import { Model } from "@converse/skeletor/src/model";
 import { _converse, api, converse } from "@converse/headless/core";

+ 0 - 14
src/modals/image.js

@@ -1,14 +0,0 @@
-import BootstrapModal from "plugins/modal/base.js";
-import tpl_image_modal from "./templates/image.js";
-
-
-export default BootstrapModal.extend({
-    id: 'image-modal',
-
-    toHTML () {
-        return tpl_image_modal({
-            'src': this.src,
-            'onload': ev => (ev.target.parentElement.style.height = `${ev.target.height}px`)
-        });
-    }
-});

+ 0 - 10
src/modals/message-versions.js

@@ -1,10 +0,0 @@
-import BootstrapModal from "plugins/modal/base.js";
-import tpl_message_versions_modal from "./templates/message-versions.js";
-
-
-export default BootstrapModal.extend({
-    id: "message-versions-modal",
-    toHTML () {
-        return tpl_message_versions_modal(this.model);
-    }
-});

+ 0 - 20
src/modals/templates/image.js

@@ -1,20 +0,0 @@
-import { html } from "lit";
-import { __ } from 'i18n';
-import { modal_close_button, modal_header_close_button } from "plugins/modal/templates/buttons.js"
-
-
-export default (o) => {
-    return html`
-        <div class="modal-dialog fit-content" role="document">
-            <div class="modal-content fit-content">
-                <div class="modal-header">
-                    <h4 class="modal-title" id="message-versions-modal-label">${__('Image: ')}<a target="_blank" rel="noopener" href="${o.src}">${o.src}</a></h4>
-                    ${modal_header_close_button}
-                </div>
-                <div class="modal-body modal-body--image fit-content">
-                    <img class="chat-image" src="${o.src}" @load=${o.onload}>
-                </div>
-                <div class="modal-footer">${modal_close_button}</div>
-            </div>
-        </div>`;
-}

+ 0 - 20
src/modals/templates/message-versions.js

@@ -1,20 +0,0 @@
-import 'shared/components/message-versions.js';
-import { __ } from 'i18n';
-import { html } from "lit";
-import { modal_close_button, modal_header_close_button } from "plugins/modal/templates/buttons.js"
-
-
-export default (model) => html`
-    <div class="modal-dialog" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h4 class="modal-title" id="message-versions-modal-label">${__('Message versions')}</h4>
-                ${modal_header_close_button}
-            </div>
-            <div class="modal-body">
-                <converse-message-versions .model=${model}></converse-message-versions>
-            </div>
-            <div class="modal-footer">${modal_close_button}</div>
-        </div>
-    </div>
-`;

+ 0 - 71
src/modals/templates/user-details.js

@@ -1,71 +0,0 @@
-import avatar from 'shared/avatar/templates/avatar.js';
-import { __ } from 'i18n';
-import { html } from 'lit';
-import { modal_close_button, modal_header_close_button } from 'plugins/modal/templates/buttons.js'
-
-
-const remove_button = (o) => {
-    const i18n_remove_contact = __('Remove as contact');
-    return html`
-        <button type="button" @click="${o.removeContact}" class="btn btn-danger remove-contact">
-            <converse-icon
-                class="fas fa-trash-alt"
-                color="var(--text-color-lighten-15-percent)"
-                size="1em"
-            ></converse-icon>
-            ${i18n_remove_contact}
-        </button>
-    `;
-}
-
-
-export default (o) => {
-    const i18n_address = __('XMPP Address');
-    const i18n_email = __('Email');
-    const i18n_full_name = __('Full Name');
-    const i18n_nickname = __('Nickname');
-    const i18n_profile = __('The User\'s Profile Image');
-    const i18n_refresh = __('Refresh');
-    const i18n_role = __('Role');
-    const i18n_url = __('URL');
-    const avatar_data = {
-        'alt_text': i18n_profile,
-        'extra_classes': 'mb-3',
-        'height': '120',
-        'width': '120'
-    }
-
-    return html`
-        <div class="modal-dialog" role="document">
-            <div class="modal-content">
-                <div class="modal-header">
-                    <h5 class="modal-title" id="user-details-modal-label">${o.display_name}</h5>
-                    ${modal_header_close_button}
-                </div>
-                <div class="modal-body">
-                    ${ o.image ? html`<div class="mb-4">${avatar(Object.assign(o, avatar_data))}</div>` : '' }
-                    ${ o.fullname ? html`<p><label>${i18n_full_name}:</label> ${o.fullname}</p>` : '' }
-                    <p><label>${i18n_address}:</label> <a href="xmpp:${o.jid}">${o.jid}</a></p>
-                    ${ o.nickname ? html`<p><label>${i18n_nickname}:</label> ${o.nickname}</p>` : '' }
-                    ${ o.url ? html`<p><label>${i18n_url}:</label> <a target="_blank" rel="noopener" href="${o.url}">${o.url}</a></p>` : '' }
-                    ${ o.email ? html`<p><label>${i18n_email}:</label> <a href="mailto:${o.email}">${o.email}</a></p>` : '' }
-                    ${ o.role ? html`<p><label>${i18n_role}:</label> ${o.role}</p>` : '' }
-
-                    <converse-omemo-fingerprints jid=${o.jid}></converse-omemo-fingerprints>
-                </div>
-                <div class="modal-footer">
-                    ${modal_close_button}
-                    <button type="button" class="btn btn-info refresh-contact">
-                        <converse-icon
-                            class="fa fa-refresh"
-                            color="var(--text-color-lighten-15-percent)"
-                            size="1em"
-                        ></converse-icon>
-                        ${i18n_refresh}</button>
-                    ${ (o.allow_contact_removal && o.is_roster_contact) ? remove_button(o) : '' }
-
-                </div>
-            </div>
-        </div>
-    `;
-}

+ 2 - 2
src/plugins/bookmark-views/mixins.js

@@ -1,4 +1,4 @@
-import MUCBookmarkFormModal from './modal.js';
+import './modal.js';
 import { _converse, api, converse } from '@converse/headless/core';
 
 const { u } = converse.env;
@@ -34,6 +34,6 @@ export const bookmarkableChatRoomView = {
     showBookmarkModal(ev) {
         ev?.preventDefault();
         const jid = this.model.get('jid');
-        api.modal.show(MUCBookmarkFormModal, { jid }, ev);
+        api.modal.show('converse-bookmark-form-modal', { jid }, ev);
     }
 };

+ 14 - 13
src/plugins/bookmark-views/modal.js

@@ -1,19 +1,20 @@
 import './form.js';
-import BaseModal from "plugins/modal/base.js";
-import tpl_modal from './templates/modal.js';
+import BaseModal from "plugins/modal/modal.js";
+import { html } from "lit";
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core";
 
-const MUCBookmarkFormModal = BaseModal.extend({
-    id: "converse-bookmark-modal",
+export default class BookmarkFormModal extends BaseModal {
 
-    initialize (attrs) {
-        this.jid = attrs.jid;
-        this.affiliation = attrs.affiliation;
-        BaseModal.prototype.initialize.apply(this, arguments);
-    },
+    renderModal () {
+        return html`
+            <converse-muc-bookmark-form class="muc-form-container" jid="${this.jid}">
+            </converse-muc-bookmark-form>`;
+    }
 
-    toHTML () {
-        return tpl_modal(this);
+    getModalTitle () { // eslint-disable-line class-methods-use-this
+        return __('Bookmark');
     }
-});
+}
 
-export default MUCBookmarkFormModal;
+api.elements.define('converse-bookmark-form-modal', BookmarkFormModal);

+ 0 - 19
src/plugins/bookmark-views/templates/modal.js

@@ -1,19 +0,0 @@
-import { __ } from 'i18n';
-import { html } from "lit";
-import { modal_header_close_button } from "plugins/modal/templates/buttons.js"
-
-export default (o) => {
-    const i18n_moderator_tools = __('Bookmark');
-    return html`
-        <div class="modal-dialog" role="document">
-            <div class="modal-content">
-                <div class="modal-header">
-                    <h5 class="modal-title" id="converse-modtools-modal-label">${i18n_moderator_tools}</h5>
-                    ${modal_header_close_button}
-                </div>
-                <div class="modal-body d-flex flex-column">
-                    <converse-muc-bookmark-form class="muc-form-container" jid="${o.jid}"></converse-muc-bookmark-form>
-                </div>
-            </div>
-        </div>`;
-}

+ 8 - 8
src/plugins/bookmark-views/tests/bookmarks.js

@@ -31,8 +31,8 @@ describe("A chat room", function () {
         expect(toggle.title).toBe('Bookmark this groupchat');
         toggle.click();
 
-        const modal = _converse.api.modal.get('converse-bookmark-modal');
-        await u.waitUntil(() => u.isVisible(modal.el), 1000);
+        const modal = _converse.api.modal.get('converse-bookmark-form-modal');
+        await u.waitUntil(() => u.isVisible(modal), 1000);
 
         /* Client uploads data:
          * --------------------
@@ -66,13 +66,13 @@ describe("A chat room", function () {
          *  </iq>
          */
         expect(view.model.get('bookmarked')).toBeFalsy();
-        const form = await u.waitUntil(() => modal.el.querySelector('.chatroom-form'));
+        const form = await u.waitUntil(() => modal.querySelector('.chatroom-form'));
         form.querySelector('input[name="name"]').value = 'Play&apos;s the Thing';
         form.querySelector('input[name="autojoin"]').checked = 'checked';
         form.querySelector('input[name="nick"]').value = 'JC';
 
         const IQ_stanzas = _converse.connection.IQ_stanzas;
-        modal.el.querySelector('converse-muc-bookmark-form .btn-primary').click();
+        modal.querySelector('converse-muc-bookmark-form .btn-primary').click();
 
         const sent_stanza = await u.waitUntil(
             () => IQ_stanzas.filter(s => sizzle('iq publish[node="storage:bookmarks"]', s).length).pop());
@@ -250,16 +250,16 @@ describe("A chat room", function () {
             bookmark_icon.click();
             expect(view.showBookmarkModal).toHaveBeenCalled();
 
-            const modal = _converse.api.modal.get('converse-bookmark-modal');
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
-            const form = await u.waitUntil(() => modal.el.querySelector('.chatroom-form'));
+            const modal = _converse.api.modal.get('converse-bookmark-form-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000);
+            const form = await u.waitUntil(() => modal.querySelector('.chatroom-form'));
 
             expect(form.querySelector('input[name="name"]').value).toBe('The Play');
             expect(form.querySelector('input[name="autojoin"]').checked).toBeFalsy();
             expect(form.querySelector('input[name="nick"]').value).toBe('Othello');
 
             // Remove the bookmark
-            modal.el.querySelector('.button-remove').click();
+            modal.querySelector('.button-remove').click();
 
             await u.waitUntil(() => view.querySelector('.chatbox-title__text .fa-bookmark') === null);
             expect(_converse.bookmarks.length).toBe(0);

+ 2 - 2
src/plugins/bookmark-views/utils.js

@@ -1,4 +1,4 @@
-import MUCBookmarkFormModal from './modal.js';
+import './modal.js';
 import invokeMap from 'lodash-es/invokeMap';
 import { Model } from '@converse/skeletor/src/model.js';
 import { __ } from 'i18n';
@@ -38,7 +38,7 @@ export async function removeBookmarkViaEvent (ev) {
 export function addBookmarkViaEvent (ev) {
     ev.preventDefault();
     const jid = ev.currentTarget.getAttribute('data-room-jid');
-    api.modal.show(MUCBookmarkFormModal, { jid }, ev);
+    api.modal.show('converse-bookmark-form-modal', { jid }, ev);
 }
 
 

+ 2 - 2
src/plugins/chatview/heading.js

@@ -1,4 +1,4 @@
-import UserDetailsModal from 'modals/user-details.js';
+import 'shared/modals/user-details.js';
 import tpl_chatbox_head from './templates/chat-head.js';
 import { CustomElement } from 'shared/components/element.js';
 import { __ } from 'i18n';
@@ -39,7 +39,7 @@ export default class ChatHeading extends CustomElement {
 
     showUserDetailsModal (ev) {
         ev.preventDefault();
-        api.modal.show(UserDetailsModal, { model: this.model }, ev);
+        api.modal.show('converse-user-details-modal', { model: this.model }, ev);
     }
 
     close (ev) {

+ 3 - 3
src/plugins/chatview/tests/corrections.js

@@ -343,9 +343,9 @@ describe("A Chat Message", function () {
             expect(view.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
             view.querySelector('.chat-msg__content .fa-edit').click();
 
-            const modal = _converse.api.modal.get('message-versions-modal');
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
-            const older_msgs = modal.el.querySelectorAll('.older-msg');
+            const modal = _converse.api.modal.get('converse-message-versions-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000);
+            const older_msgs = modal.querySelectorAll('.older-msg');
             expect(older_msgs.length).toBe(2);
             expect(older_msgs[0].textContent.includes('But soft, what light through yonder airlock breaks?')).toBe(true);
             expect(view.model.messages.models.length).toBe(1);

+ 8 - 200
src/plugins/controlbox/tests/controlbox.js

@@ -3,8 +3,6 @@
 const $msg = converse.env.$msg;
 const u = converse.env.utils;
 const Strophe = converse.env.Strophe;
-const sizzle = converse.env.sizzle;
-
 
 describe("The Controlbox", function () {
 
@@ -124,10 +122,10 @@ describe("The Controlbox", function () {
             await mock.openControlBox(_converse);
             var cbview = _converse.chatboxviews.get('controlbox');
             cbview.querySelector('.change-status').click()
-            const modal = _converse.api.modal.get('modal-status-change');
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
-            modal.el.querySelector('label[for="radio-busy"]').click(); // Change status to "dnd"
-            modal.el.querySelector('[type="submit"]').click();
+            const modal = _converse.api.modal.get('converse-chat-status-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000);
+            modal.querySelector('label[for="radio-busy"]').click(); // Change status to "dnd"
+            modal.querySelector('[type="submit"]').click();
             const sent_stanzas = _converse.connection.sent_stanzas;
             const sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop());
             expect(Strophe.serialize(sent_presence)).toBe(
@@ -149,12 +147,12 @@ describe("The Controlbox", function () {
             await mock.openControlBox(_converse);
             const cbview = _converse.chatboxviews.get('controlbox');
             cbview.querySelector('.change-status').click()
-            const modal = _converse.api.modal.get('modal-status-change');
+            const modal = _converse.api.modal.get('converse-chat-status-modal');
 
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
+            await u.waitUntil(() => u.isVisible(modal), 1000);
             const msg = 'I am happy';
-            modal.el.querySelector('input[name="status_message"]').value = msg;
-            modal.el.querySelector('[type="submit"]').click();
+            modal.querySelector('input[name="status_message"]').value = msg;
+            modal.querySelector('[type="submit"]').click();
             const sent_stanzas = _converse.connection.sent_stanzas;
             const sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop());
             expect(Strophe.serialize(sent_presence)).toBe(
@@ -171,193 +169,3 @@ describe("The Controlbox", function () {
         }));
     });
 });
-
-describe("The 'Add Contact' widget", function () {
-
-    it("opens up an add modal when you click on it",
-            mock.initConverse([], {}, async function (_converse) {
-
-        await mock.waitForRoster(_converse, 'all');
-        await mock.openControlBox(_converse);
-
-        const cbview = _converse.chatboxviews.get('controlbox');
-        cbview.querySelector('.add-contact').click()
-        const modal = _converse.api.modal.get('add-contact-modal');
-        await u.waitUntil(() => u.isVisible(modal.el), 1000);
-        expect(modal.el.querySelector('form.add-xmpp-contact')).not.toBe(null);
-
-        const input_jid = modal.el.querySelector('input[name="jid"]');
-        const input_name = modal.el.querySelector('input[name="name"]');
-        input_jid.value = 'someone@';
-
-        const evt = new Event('input');
-        input_jid.dispatchEvent(evt);
-        expect(modal.el.querySelector('.suggestion-box li').textContent).toBe('someone@montague.lit');
-        input_jid.value = 'someone@montague.lit';
-        input_name.value = 'Someone';
-        modal.el.querySelector('button[type="submit"]').click();
-
-        const sent_IQs = _converse.connection.IQ_stanzas;
-        const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
-        expect(Strophe.serialize(sent_stanza)).toEqual(
-            `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit" name="Someone"/></query>`+
-            `</iq>`);
-    }));
-
-    it("can be configured to not provide search suggestions",
-            mock.initConverse([], {'autocomplete_add_contact': false}, async function (_converse) {
-
-        await mock.waitForRoster(_converse, 'all', 0);
-        await mock.openControlBox(_converse);
-        const cbview = _converse.chatboxviews.get('controlbox');
-        cbview.querySelector('.add-contact').click()
-        const modal = _converse.api.modal.get('add-contact-modal');
-        expect(modal.jid_auto_complete).toBe(undefined);
-        expect(modal.name_auto_complete).toBe(undefined);
-
-        await u.waitUntil(() => u.isVisible(modal.el), 1000);
-        expect(modal.el.querySelector('form.add-xmpp-contact')).not.toBe(null);
-        const input_jid = modal.el.querySelector('input[name="jid"]');
-        input_jid.value = 'someone@montague.lit';
-        modal.el.querySelector('button[type="submit"]').click();
-
-        const IQ_stanzas = _converse.connection.IQ_stanzas;
-        const sent_stanza = await u.waitUntil(
-            () => IQ_stanzas.filter(s => sizzle(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`, s).length).pop()
-        );
-        expect(Strophe.serialize(sent_stanza)).toEqual(
-            `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-                `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit"/></query>`+
-            `</iq>`
-        );
-    }));
-
-    it("integrates with xhr_user_search_url to search for contacts",
-            mock.initConverse([], { 'xhr_user_search_url': 'http://example.org/?' },
-            async function (_converse) {
-
-        await mock.waitForRoster(_converse, 'all', 0);
-
-        class MockXHR extends XMLHttpRequest {
-            open () {} // eslint-disable-line
-            responseText  = ''
-            send () {
-                this.responseText = JSON.stringify([
-                    {"jid": "marty@mcfly.net", "fullname": "Marty McFly"},
-                    {"jid": "doc@brown.com", "fullname": "Doc Brown"}
-                ]);
-                this.onload();
-            }
-        }
-        const XMLHttpRequestBackup = window.XMLHttpRequest;
-        window.XMLHttpRequest = MockXHR;
-
-        await mock.openControlBox(_converse);
-        const cbview = _converse.chatboxviews.get('controlbox');
-        cbview.querySelector('.add-contact').click()
-        const modal = _converse.api.modal.get('add-contact-modal');
-        await u.waitUntil(() => u.isVisible(modal.el), 1000);
-
-        // We only have autocomplete for the name input
-        expect(modal.jid_auto_complete).toBe(undefined);
-        expect(modal.name_auto_complete instanceof _converse.AutoComplete).toBe(true);
-
-        const input_el = modal.el.querySelector('input[name="name"]');
-        input_el.value = 'marty';
-        input_el.dispatchEvent(new Event('input'));
-        await u.waitUntil(() => modal.el.querySelector('.suggestion-box li'), 1000);
-        expect(modal.el.querySelectorAll('.suggestion-box li').length).toBe(1);
-        const suggestion = modal.el.querySelector('.suggestion-box li');
-        expect(suggestion.textContent).toBe('Marty McFly');
-
-        // Mock selection
-        modal.name_auto_complete.select(suggestion);
-
-        expect(input_el.value).toBe('Marty McFly');
-        expect(modal.el.querySelector('input[name="jid"]').value).toBe('marty@mcfly.net');
-        modal.el.querySelector('button[type="submit"]').click();
-
-        const sent_IQs = _converse.connection.IQ_stanzas;
-        const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
-        expect(Strophe.serialize(sent_stanza)).toEqual(
-        `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-            `<query xmlns="jabber:iq:roster"><item jid="marty@mcfly.net" name="Marty McFly"/></query>`+
-        `</iq>`);
-        window.XMLHttpRequest = XMLHttpRequestBackup;
-    }));
-
-    it("can be configured to not provide search suggestions for XHR search results",
-        mock.initConverse([],
-            { 'autocomplete_add_contact': false,
-              'xhr_user_search_url': 'http://example.org/?' },
-            async function (_converse) {
-
-        await mock.waitForRoster(_converse, 'all');
-        await mock.openControlBox(_converse);
-
-        class MockXHR extends XMLHttpRequest {
-            open () {} // eslint-disable-line
-            responseText  = ''
-            send () {
-                const value = modal.el.querySelector('input[name="name"]').value;
-                if (value === 'existing') {
-                    const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    this.responseText = JSON.stringify([{"jid": contact_jid, "fullname": mock.cur_names[0]}]);
-                } else if (value === 'romeo') {
-                    this.responseText = JSON.stringify([{"jid": "romeo@montague.lit", "fullname": "Romeo Montague"}]);
-                } else if (value === 'ambiguous') {
-                    this.responseText = JSON.stringify([
-                        {"jid": "marty@mcfly.net", "fullname": "Marty McFly"},
-                        {"jid": "doc@brown.com", "fullname": "Doc Brown"}
-                    ]);
-                } else if (value === 'insufficient') {
-                    this.responseText = JSON.stringify([]);
-                } else {
-                    this.responseText = JSON.stringify([{"jid": "marty@mcfly.net", "fullname": "Marty McFly"}]);
-                }
-                this.onload();
-            }
-        }
-
-        const XMLHttpRequestBackup = window.XMLHttpRequest;
-        window.XMLHttpRequest = MockXHR;
-
-        const cbview = _converse.chatboxviews.get('controlbox');
-        cbview.querySelector('.add-contact').click()
-        const modal = _converse.api.modal.get('add-contact-modal');
-        await u.waitUntil(() => u.isVisible(modal.el), 1000);
-
-        expect(modal.jid_auto_complete).toBe(undefined);
-        expect(modal.name_auto_complete).toBe(undefined);
-
-        const input_el = modal.el.querySelector('input[name="name"]');
-        input_el.value = 'ambiguous';
-        modal.el.querySelector('button[type="submit"]').click();
-        let feedback_el = modal.el.querySelector('.invalid-feedback');
-        expect(feedback_el.textContent).toBe('Sorry, could not find a contact with that name');
-        feedback_el.textContent = '';
-
-        input_el.value = 'insufficient';
-        modal.el.querySelector('button[type="submit"]').click();
-        feedback_el = modal.el.querySelector('.invalid-feedback');
-        expect(feedback_el.textContent).toBe('Sorry, could not find a contact with that name');
-        feedback_el.textContent = '';
-
-        input_el.value = 'existing';
-        modal.el.querySelector('button[type="submit"]').click();
-        feedback_el = modal.el.querySelector('.invalid-feedback');
-        expect(feedback_el.textContent).toBe('This contact has already been added');
-
-        input_el.value = 'Marty McFly';
-        modal.el.querySelector('button[type="submit"]').click();
-
-        const sent_IQs = _converse.connection.IQ_stanzas;
-        const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
-        expect(Strophe.serialize(sent_stanza)).toEqual(
-        `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
-            `<query xmlns="jabber:iq:roster"><item jid="marty@mcfly.net" name="Marty McFly"/></query>`+
-        `</iq>`);
-        window.XMLHttpRequest = XMLHttpRequestBackup;
-    }));
-});

+ 15 - 11
src/plugins/modal/alert.js

@@ -1,19 +1,23 @@
-import BootstrapModal from "./base.js";
+import BaseModal from "plugins/modal/modal.js";
 import tpl_alert_modal from "./templates/alert.js";
-import { __ } from 'i18n';
+import { api } from "@converse/headless/core";
 
 
-const Alert = BootstrapModal.extend({
-    id: 'alert-modal',
+export default class Alert extends BaseModal {
 
     initialize () {
-        BootstrapModal.prototype.initialize.apply(this, arguments);
-        this.listenTo(this.model, 'change', this.render)
-    },
+        super.initialize();
+        this.listenTo(this.model, 'change', () => this.render())
+        this.addEventListener('hide.bs.modal', () => this.remove(), false);
+    }
+
+    renderModal () {
+        return tpl_alert_modal(this.model.toJSON());
+    }
 
-    toHTML () {
-        return tpl_alert_modal(Object.assign({__}, this.model.toJSON()));
+    getModalTitle () {
+        return this.model.get('title');
     }
-});
+}
 
-export default Alert;
+api.elements.define('converse-alert-modal', Alert);

+ 41 - 17
src/plugins/modal/api.js

@@ -1,9 +1,9 @@
-import Alert from './alert.js';
+import './alert.js';
 import Confirm from './confirm.js';
 import { Model } from '@converse/skeletor/src/model.js';
 
 let modals = [];
-
+let modals_map = {};
 
 const modal_api = {
     /**
@@ -17,13 +17,20 @@ const modal_api = {
          * Will create a new instance of that class if an existing one isn't
          * found.
          * @param { Class } ModalClass
-         * @param { Object } [properties] - Optional properties that will be
-         *  set on a newly created modal instance (if no pre-existing modal was
-         *  found).
+         * @param { Object } [properties] - Optional properties that will be set on a newly created modal instance.
          * @param { Event } [event] - The DOM event that causes the modal to be shown.
          */
-        show (ModalClass, properties, ev) {
-            const modal = this.get(ModalClass.id) || this.create(ModalClass, properties);
+        show (name, properties, ev) {
+            let modal;
+            if (typeof name === 'string') {
+                modal = this.get(name) ?? this.create(name, properties);
+                Object.assign(modal, properties);
+            } else {
+                // Legacy...
+                const ModalClass = name;
+                const id = ModalClass.id ?? properties.id;
+                modal = this.get(id) ?? this.create(ModalClass, properties);
+            }
             modal.show(ev);
             return modal;
         },
@@ -33,28 +40,44 @@ const modal_api = {
          * @param { String } id
          */
         get (id) {
-            return modals.filter(m => m.id == id).pop();
+            return modals_map[id] ?? modals.filter(m => m.id == id).pop();
         },
 
         /**
          * Create a modal of the passed-in type.
-         * @param { Class } ModalClass
+         * @param { String } name
          * @param { Object } [properties] - Optional properties that will be
          *  set on the modal instance.
          */
-        create (ModalClass, properties) {
-            const modal = new ModalClass(properties);
-            modals.push(modal);
+        create (name, properties) {
+            let modal;
+            if (typeof name === 'string') {
+                const ModalClass = customElements.get(name);
+                modal = modals_map[name] = new ModalClass(properties);
+            } else {
+                // Legacy...
+                const ModalClass = name;
+                modal = new ModalClass(properties);
+                modals.push(modal);
+            }
             return modal;
         },
 
         /**
          * Remove a particular modal
-         * @param { View } modal
+         * @param { String } name
          */
-        remove (modal) {
-            modals = modals.filter(m => m !== modal);
-            modal.remove();
+        remove (name) {
+            let modal;
+            if (typeof name === 'string') {
+                modal = modals_map[name];
+                delete modals_map[name];
+            } else {
+                // Legacy...
+                modal = name;
+                modals = modals.filter(m => m !== modal);
+            }
+            modal?.remove();
         },
 
         /**
@@ -63,6 +86,7 @@ const modal_api = {
         removeAll () {
             modals.forEach(m => m.remove());
             modals = [];
+            modals_map = {};
         }
     },
 
@@ -157,7 +181,7 @@ const modal_api = {
             'level': level,
             'type': 'alert'
         })
-        modal_api.modal.show(Alert, {model});
+        modal_api.modal.show('converse-alert-modal', { model });
     }
 }
 

+ 1 - 1
src/plugins/modal/base.js

@@ -1,6 +1,6 @@
 import bootstrap from "bootstrap.native";
 import log from "@converse/headless/log";
-import tpl_alert_component from "templates/alert.js";
+import tpl_alert_component from "./templates/modal-alert.js";
 import { View } from '@converse/skeletor/src/view.js';
 import { api, converse } from "@converse/headless/core";
 import { render } from 'lit';

+ 25 - 28
src/plugins/modal/confirm.js

@@ -1,35 +1,32 @@
-import BootstrapModal from './base.js';
+import BaseModal from "plugins/modal/modal.js";
 import tpl_prompt from "./templates/prompt.js";
 import { getOpenPromise } from '@converse/openpromise';
+import { api } from "@converse/headless/core";
 
+export default class Confirm extends BaseModal {
 
-const Confirm = BootstrapModal.extend({
-    id: 'confirm-modal',
-    events: {
-        'submit .confirm': 'onConfimation'
-    },
+    constructor (options) {
+        super(options);
+        this.confirmation = getOpenPromise();
+    }
 
     initialize () {
-        this.confirmation = getOpenPromise();
-        BootstrapModal.prototype.initialize.apply(this, arguments);
-        this.listenTo(this.model, 'change', this.render)
-        this.el.addEventListener('closed.bs.modal', () => this.confirmation.reject(), false);
-    },
-
-    toHTML () {
-        return tpl_prompt(this.model.toJSON());
-    },
-
-    afterRender () {
-        if (!this.close_handler_registered) {
-            this.el.addEventListener('closed.bs.modal', () => {
-                if (!this.confirmation.isResolved) {
-                    this.confirmation.reject()
-                }
-            }, false);
-            this.close_handler_registered = true;
-        }
-    },
+        super.initialize();
+        this.listenTo(this.model, 'change', () => this.render())
+        this.addEventListener('hide.bs.modal', () => {
+            if (!this.confirmation.isResolved) {
+                this.confirmation.reject()
+            }
+        }, false);
+    }
+
+    renderModal () {
+        return tpl_prompt(this);
+    }
+
+    getModalTitle () {
+        this.model.get('title');
+    }
 
     onConfimation (ev) {
         ev.preventDefault();
@@ -53,6 +50,6 @@ const Confirm = BootstrapModal.extend({
         this.confirmation.resolve(fields);
         this.modal.hide();
     }
-});
+}
 
-export default Confirm;
+api.elements.define('converse-confirm-modal', Confirm);

+ 70 - 0
src/plugins/modal/modal.js

@@ -0,0 +1,70 @@
+import bootstrap from "bootstrap.native";
+import tpl_modal from './templates/modal.js';
+import { ElementView } from '@converse/skeletor/src/element.js';
+import { getOpenPromise } from '@converse/openpromise';
+
+
+import './styles/_modal.scss';
+
+class BaseModal extends ElementView {
+
+    constructor (options) {
+        super();
+        this.className = 'modal';
+        this.initialized = getOpenPromise();
+
+        // Allow properties to be set via passed in options
+        Object.assign(this, options);
+        setTimeout(() => this.insertIntoDOM());
+    }
+
+    initialize () {
+        this.modal = new bootstrap.Modal(this, {
+            backdrop: true,
+            keyboard: true
+        });
+        this.addEventListener('hide.bs.modal', () => this.onHide(), false);
+        this.initialized.resolve();
+        this.render()
+    }
+
+    toHTML () {
+        return tpl_modal(this);
+    }
+
+    getModalTitle () { // eslint-disable-line class-methods-use-this
+        // Intended to be overwritten
+        return '';
+    }
+
+    switchTab (ev) {
+        ev?.stopPropagation();
+        ev?.preventDefault();
+        this.tab = ev.target.getAttribute('data-name');
+        this.render();
+    }
+
+    onHide () {
+        this.modal.hide();
+    }
+
+    insertIntoDOM () {
+        const container_el = document.querySelector("#converse-modals");
+        container_el.insertAdjacentElement('beforeEnd', this);
+    }
+
+    alert (message, type='primary') {
+        this.model.set('alert', { message, type });
+        setTimeout(() => {
+            this.model.set('alert', undefined);
+        }, 5000);
+    }
+
+    async show () {
+        await this.initialized;
+        this.modal.show();
+        this.render();
+    }
+}
+
+export default BaseModal;

+ 0 - 45
src/plugins/modal/styles/_modal.scss

@@ -55,7 +55,6 @@
                         }
                     }
                 }
-
                 &.fit-content {
                     box-sizing: content-box;
 
@@ -64,12 +63,6 @@
                     }
                 }
             }
-            .modal-body--image {
-                .chat-image {
-                    max-height: 99%;
-                    max-width: 100%;
-                }
-            }
             .modal-footer {
                 justify-content: flex-start;
             }
@@ -103,43 +96,5 @@
         .btn {
             font-weight: normal;
         }
-
-        #user-profile-modal {
-            .profile-form {
-                label {
-                    font-weight: bold;
-                }
-            }
-            .fingerprint-removal {
-                label {
-                    display: flex;
-                    padding: 0.75rem 1.25rem;
-                }
-            }
-
-            .list-group-item {
-                display: flex;
-                justify-content: left;
-                font-size: 95%;
-
-                input[type="checkbox"] {
-                    margin-right: 1em;
-                }
-            }
-        }
-
-        .fingerprints {
-            width: 100%;
-            margin-bottom: 1em;
-        }
-
-        .fingerprint-trust {
-            display: flex;
-            justify-content: space-between;
-            font-size: 95%;
-            .fingerprint {
-                margin-left: 1em;
-            }
-        }
     }
 }

+ 3 - 12
src/plugins/modal/templates/alert.js

@@ -1,17 +1,8 @@
 import { html } from "lit";
-import { modal_header_close_button } from "./buttons.js"
 
 
 export default (o) => html`
-    <div class="modal-dialog" role="document">
-        <div class="modal-content">
-            <div class="modal-header ${o.level}">
-              <h5 class="modal-title">${o.title}</h5>
-              ${modal_header_close_button}
-            </div>
-            <div class="modal-body">
-              <span class="modal-alert"></span>
-              ${ o.messages.map(message => html`<p>${message}</p>`) }
-            </div>
-        </div>
+    <div class="modal-body">
+        <span class="modal-alert"></span>
+        ${ o.messages.map(message => html`<p>${message}</p>`) }
     </div>`;

+ 0 - 0
src/templates/alert.js → src/plugins/modal/templates/modal-alert.js


+ 29 - 0
src/plugins/modal/templates/modal.js

@@ -0,0 +1,29 @@
+import tpl_alert_component from "./modal-alert.js";
+import { html } from "lit";
+import { modal_close_button, modal_header_close_button } from "plugins/modal/templates/buttons.js";
+
+
+export default (el) => {
+    const alert = el.model?.get('alert');
+    const level = el.model?.get('level') ?? '';
+    return html`
+        <div class="modal-dialog" role="document" tabindex="-1" role="dialog" aria-hidden="true">
+            <div class="modal-content">
+                <div class="modal-header ${level}">
+                    <h5 class="modal-title">${el.getModalTitle()}</h5>
+                    ${modal_header_close_button}
+                </div>
+                <div class="modal-body">
+                    <span class="modal-alert">
+                        ${ alert ? tpl_alert_component({'type': `alert-${alert.type}`, 'message': alert.message}) :  ''}
+                    </span>
+                    ${ el.renderModal?.() ?? '' }
+                </div>
+                <div class="modal-footer">
+                    ${ modal_close_button }
+                    ${ el.renderModalFooter?.() ?? '' }
+                </div>
+            </div>
+        </div>
+    `;
+}

+ 13 - 26
src/plugins/modal/templates/prompt.js

@@ -15,29 +15,16 @@ const tpl_field = (f) => html`
     </div>
 `;
 
-
-export default (o) => html`
-    <div class="modal-dialog" role="document">
-      <div class="modal-content">
-        <div class="modal-header ${o.level || ''}">
-          <h5 class="modal-title">${o.title}</h5>
-          <button type="button" class="close" data-dismiss="modal" aria-label="Close">
-            <span aria-hidden="true">×</span>
-          </button>
-        </div>
-        <div class="modal-body">
-            <span class="modal-alert"></span>
-            <form class="converse-form converse-form--modal confirm" action="#">
-              <div class="form-group">
-                  ${ o.messages.map(message => html`<p>${message}</p>`) }
-              </div>
-              ${ o.fields.map(f => tpl_field(f)) }
-              <div class="form-group">
-                  <button type="submit" class="btn btn-primary">${__('OK')}</button>
-                  <input type="button" class="btn btn-secondary" data-dismiss="modal" value="${__('Cancel')}"/>
-              </div>
-          </form>
-        </div>
-      </div>
-    </div>
-`;
+export default (el) => {
+    return html`
+        <form class="converse-form converse-form--modal confirm" action="#" @submit=${ev => el.onConfimation(ev)}>
+            <div class="form-group">
+                ${ el.model.get('messages')?.map(message => html`<p>${message}</p>`) }
+            </div>
+            ${ el.model.get('fields')?.map(f => tpl_field(f)) }
+            <div class="form-group">
+                <button type="submit" class="btn btn-primary">${__('OK')}</button>
+                <input type="button" class="btn btn-secondary" data-dismiss="modal" value="${__('Cancel')}"/>
+            </div>
+        </form>`;
+}

+ 6 - 6
src/plugins/muc-views/heading.js

@@ -1,6 +1,6 @@
-import MUCInviteModal from './modals/muc-invite.js';
-import NicknameModal from './modals/nickname.js';
-import RoomDetailsModal from './modals/muc-details.js';
+import './modals/muc-details.js';
+import './modals/muc-invite.js';
+import './modals/nickname.js';
 import tpl_muc_head from './templates/muc-head.js';
 import { CustomElement } from 'shared/components/element.js';
 import { Model } from '@converse/skeletor/src/model.js';
@@ -48,12 +48,12 @@ export default class MUCHeading extends CustomElement {
 
     showRoomDetailsModal (ev) {
         ev.preventDefault();
-        api.modal.show(RoomDetailsModal, { 'model': this.model }, ev);
+        api.modal.show('converse-muc-details-modal', { 'model': this.model }, ev);
     }
 
     showInviteModal (ev) {
         ev.preventDefault();
-        api.modal.show(MUCInviteModal, { 'model': new Model(), 'chatroomview': this }, ev);
+        api.modal.show('converse-muc-invite-modal', { 'model': new Model(), 'chatroomview': this }, ev);
     }
 
     toggleTopic (ev) {
@@ -104,7 +104,7 @@ export default class MUCHeading extends CustomElement {
         buttons.push({
             'i18n_text': __('Nickname'),
             'i18n_title': __("Change the nickname you're using in this groupchat"),
-            'handler': ev => api.modal.show(NicknameModal, { 'model': this.model }, ev),
+            'handler': ev => api.modal.show('converse-muc-nickname-modal', { 'model': this.model }, ev),
             'a_class': 'open-nickname-modal',
             'icon_class': 'fa-smile',
             'name': 'nickname'

+ 28 - 38
src/plugins/muc-views/modals/add-muc.js

@@ -1,5 +1,5 @@
-import tpl_add_muc from "../templates/add-muc.js";
-import BootstrapModal from "plugins/modal/base.js";
+import tpl_add_muc from "./templates/add-muc.js";
+import BaseModal from "plugins/modal/modal.js";
 import { __ } from 'i18n';
 import { _converse, api, converse } from "@converse/headless/core";
 
@@ -9,43 +9,27 @@ const u = converse.env.utils;
 const { Strophe } = converse.env;
 
 
-export default BootstrapModal.extend({
-    persistent: true,
-    id: 'add-chatroom-modal',
-
-    events: {
-        'submit form.add-chatroom': 'openChatRoom',
-        'keyup .roomjid-input': 'checkRoomidPolicy',
-        'change .roomjid-input': 'checkRoomidPolicy'
-    },
+export default class AddMUCModal extends BaseModal {
 
     initialize () {
-        BootstrapModal.prototype.initialize.apply(this, arguments);
-        this.listenTo(this.model, 'change:muc_domain', this.render);
+        super.initialize();
+        this.listenTo(this.model, 'change:muc_domain', () => this.render());
         this.muc_roomid_policy_error_msg = null;
-    },
+        this.render();
+        this.addEventListener('shown.bs.modal', () => {
+            this.querySelector('input[name="chatroom"]').focus();
+        }, false);
+    }
 
-    toHTML () {
-        let placeholder = '';
-        if (!api.settings.get('locked_muc_domain')) {
-            const muc_domain = this.model.get('muc_domain') || api.settings.get('muc_domain');
-            placeholder = muc_domain ? `name@${muc_domain}` : __('name@conference.example.org');
-        }
-        return tpl_add_muc(Object.assign(this.model.toJSON(), {
-            'label_room_address': api.settings.get('muc_domain') ? __('Groupchat name') :  __('Groupchat address'),
-            'chatroom_placeholder': placeholder,
-            'muc_roomid_policy_error_msg': this.muc_roomid_policy_error_msg,
-            'muc_roomid_policy_hint': api.settings.get('muc_roomid_policy_hint')
-        }));
-    },
+    renderModal () {
+        return tpl_add_muc(this);
+    }
 
-    afterRender () {
-        this.el.addEventListener('shown.bs.modal', () => {
-            this.el.querySelector('input[name="chatroom"]').focus();
-        }, false);
-    },
+    getModalTitle () { // eslint-disable-line class-methods-use-this
+        return __('Enter a new Groupchat');
+    }
 
-    parseRoomDataFromEvent (form) {
+    parseRoomDataFromEvent (form) { // eslint-disable-line class-methods-use-this
         const data = new FormData(form);
         const jid = data.get('chatroom')?.trim();
         let nick;
@@ -61,10 +45,12 @@ export default BootstrapModal.extend({
             'jid': jid,
             'nick': nick
         }
-    },
+    }
 
     openChatRoom (ev) {
         ev.preventDefault();
+        if (this.checkRoomidPolicy()) return;
+
         const data = this.parseRoomDataFromEvent(ev.target);
         if (data.nick === "") {
             // Make sure defaults apply if no nick is provided.
@@ -77,14 +63,15 @@ export default BootstrapModal.extend({
             jid = data.jid
             this.model.setDomain(jid);
         }
+
         api.rooms.open(jid, Object.assign(data, {jid}), true);
-        this.modal.hide();
         ev.target.reset();
-    },
+        this.modal.hide();
+    }
 
     checkRoomidPolicy () {
         if (api.settings.get('muc_roomid_policy') && api.settings.get('muc_domain')) {
-            let jid = this.el.querySelector('.roomjid-input').value;
+            let jid = this.querySelector('converse-autocomplete input').value;
             if (api.settings.get('locked_muc_domain') || !u.isValidJID(jid)) {
                 jid = `${Strophe.escapeNode(jid)}@${api.settings.get('muc_domain')}`;
             }
@@ -95,8 +82,11 @@ export default BootstrapModal.extend({
                 this.muc_roomid_policy_error_msg = null;
             } else {
                 this.muc_roomid_policy_error_msg = __('Groupchat id is invalid.');
+                return true;
             }
             this.render();
         }
     }
-});
+}
+
+api.elements.define('converse-add-muc-modal', AddMUCModal);

+ 18 - 14
src/plugins/muc-views/modals/moderator-tools.js

@@ -1,20 +1,24 @@
 import '../modtools.js';
-import BaseModal from "plugins/modal/base.js";
-import tpl_moderator_tools from './templates/moderator-tools.js';
+import BaseModal from "plugins/modal/modal.js";
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core";
+import { html } from 'lit';
 
-const ModeratorToolsModal = BaseModal.extend({
-    id: "converse-modtools-modal",
-    persistent: true,
+export default class ModeratorToolsModal extends BaseModal {
 
-    initialize (attrs) {
-        this.jid = attrs.jid;
-        this.affiliation = attrs.affiliation;
-        BaseModal.prototype.initialize.apply(this, arguments);
-    },
+    constructor (options) {
+        super(options);
+        this.id = "converse-modtools-modal";
+    }
+
+    renderModal () {
+        return html`<converse-modtools jid=${this.jid} affiliation=${this.affiliation}></converse-modtools>`;
+    }
 
-    toHTML () {
-        return tpl_moderator_tools(this);
+    getModalTitle () { // eslint-disable-line class-methods-use-this
+        return __('Moderator Tools');
     }
-});
 
-export default ModeratorToolsModal;
+}
+
+api.elements.define('converse-modtools-modal', ModeratorToolsModal);

+ 21 - 13
src/plugins/muc-views/modals/muc-details.js

@@ -1,21 +1,29 @@
-import BaseModal from "plugins/modal/base.js";
+import BaseModal from "plugins/modal/modal.js";
 import tpl_muc_details from "./templates/muc-details.js";
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core";
 
-import '../styles/muc-details.scss';
+import '../styles/muc-details-modal.scss';
 
 
-export default BaseModal.extend({
-    id: "muc-details-modal",
+export default class MUCDetailsModal extends BaseModal {
 
     initialize () {
-        BaseModal.prototype.initialize.apply(this, arguments);
-        this.listenTo(this.model, 'change', this.render);
-        this.listenTo(this.model.features, 'change', this.render);
-        this.listenTo(this.model.occupants, 'add', this.render);
-        this.listenTo(this.model.occupants, 'change', this.render);
-    },
-
-    toHTML () {
+        super.initialize();
+        this.listenTo(this.model, 'change', () => this.render());
+        this.listenTo(this.model.features, 'change', () => this.render());
+        this.listenTo(this.model.occupants, 'add', () => this.render());
+        this.listenTo(this.model.occupants, 'change', () => this.render());
+    }
+
+    renderModal () {
         return tpl_muc_details(this.model);
     }
-});
+
+    getModalTitle () { // eslint-disable-line class-methods-use-this
+        return __('Groupchat info for %1$s', this.model.getDisplayName());
+    }
+
+}
+
+api.elements.define('converse-muc-details-modal', MUCDetailsModal);

+ 23 - 31
src/plugins/muc-views/modals/muc-invite.js

@@ -1,45 +1,35 @@
 import 'shared/autocomplete/index.js';
-import BaseModal from "plugins/modal/base.js";
+import BaseModal from "plugins/modal/modal.js";
 import tpl_muc_invite_modal from "./templates/muc-invite.js";
-import { _converse, converse } from "@converse/headless/core";
+import { __ } from 'i18n';
+import { _converse, api, converse } from "@converse/headless/core";
 
 const u = converse.env.utils;
 
-
-export default BaseModal.extend({
-    id: "muc-invite-modal",
+export default class MUCInviteModal extends BaseModal {
 
     initialize () {
-        BaseModal.prototype.initialize.apply(this, arguments);
-        this.listenTo(this.model, 'change', this.render);
-        this.initInviteWidget();
-    },
-
-    toHTML () {
-        return tpl_muc_invite_modal(Object.assign(
-            this.model.toJSON(), {
-                'submitInviteForm': ev => this.submitInviteForm(ev)
-            })
-        );
-    },
-
-    initInviteWidget () {
-        if (this.invite_auto_complete) {
-            this.invite_auto_complete.destroy();
-        }
-        const list = _converse.roster.map(i => ({'label': i.getDisplayName(), 'value': i.get('jid')}));
-        const el = this.el.querySelector('.suggestion-box').parentElement;
-        this.invite_auto_complete = new _converse.AutoComplete(el, {
-            'min_chars': 1,
-            'list': list
-        });
-    },
+        super.initialize();
+        this.listenTo(this.model, 'change', () => this.render());
+    }
+
+    renderModal () {
+        return tpl_muc_invite_modal(this);
+    }
+
+    getModalTitle () { // eslint-disable-line class-methods-use-this
+        return __('Invite someone to this groupchat');
+    }
+
+    getAutoCompleteList () { // eslint-disable-line class-methods-use-this
+        return _converse.roster.map(i => ({'label': i.getDisplayName(), 'value': i.get('jid')}));
+    }
 
     submitInviteForm (ev) {
         ev.preventDefault();
         // TODO: Add support for sending an invite to multiple JIDs
         const data = new FormData(ev.target);
-        const jid = data.get('invitee_jids');
+        const jid = data.get('invitee_jids')?.trim();
         const reason = data.get('reason');
         if (u.isValidJID(jid)) {
             // TODO: Create and use API here
@@ -49,4 +39,6 @@ export default BaseModal.extend({
             this.model.set({'invalid_invite_jid': true});
         }
     }
-});
+}
+
+api.elements.define('converse-muc-invite-modal', MUCInviteModal);

+ 27 - 24
src/plugins/muc-views/modals/muc-list.js

@@ -1,8 +1,8 @@
-import BootstrapModal from "plugins/modal/base.js";
+import BaseModal from "plugins/modal/modal.js";
 import head from "lodash-es/head";
 import log from "@converse/headless/log";
-import tpl_muc_list from "../templates/muc-list.js";
 import tpl_muc_description from "../templates/muc-description.js";
+import tpl_muc_list from "../templates/muc-list.js";
 import tpl_spinner from "templates/spinner.js";
 import { __ } from 'i18n';
 import { _converse, api, converse } from "@converse/headless/core";
@@ -65,28 +65,25 @@ function toggleRoomInfo (ev) {
 }
 
 
-export default BootstrapModal.extend({
-    id: "muc-list-modal",
-    persistent: true,
+export default class MUCListModal extends BaseModal {
 
-    initialize () {
+    constructor (options) {
+        super(options);
         this.items = [];
         this.loading_items = false;
+    }
 
-        BootstrapModal.prototype.initialize.apply(this, arguments);
+    initialize () {
+        super.initialize();
         this.listenTo(this.model, 'change:muc_domain', this.onDomainChange);
         this.listenTo(this.model, 'change:feedback_text', () => this.render());
 
-
-        this.el.addEventListener('shown.bs.modal', () => api.settings.get('locked_muc_domain')
-          ? this.updateRoomsList()
-          : this.el.querySelector('input[name="server"]').focus()
-        );
+        this.addEventListener('shown.bs.modal', () => api.settings.get('locked_muc_domain') && this.updateRoomsList());
 
         this.model.save('feedback_text', '');
-    },
+    }
 
-    toHTML () {
+    renderModal () {
         return tpl_muc_list(
             Object.assign(this.model.toJSON(), {
                 'show_form': !api.settings.get('locked_muc_domain'),
@@ -98,7 +95,11 @@ export default BootstrapModal.extend({
                 'submitForm': ev => this.showRooms(ev),
                 'toggleRoomInfo': ev => this.toggleRoomInfo(ev)
             }));
-    },
+    }
+
+    getModalTitle () { // eslint-disable-line class-methods-use-this
+        return __('Query for Groupchats');
+    }
 
     openRoom (ev) {
         ev.preventDefault();
@@ -106,16 +107,16 @@ export default BootstrapModal.extend({
         const name = ev.target.getAttribute('data-room-name');
         this.modal.hide();
         api.rooms.open(jid, {'name': name}, true);
-    },
+    }
 
-    toggleRoomInfo (ev) {
+    toggleRoomInfo (ev) { // eslint-disable-line
         ev.preventDefault();
         toggleRoomInfo(ev);
-    },
+    }
 
     onDomainChange () {
         api.settings.get('auto_list_rooms') && this.updateRoomsList();
-    },
+    }
 
     /**
      * Handle the IQ stanza returned from the server, containing
@@ -136,7 +137,7 @@ export default BootstrapModal.extend({
         }
         this.render();
         return true;
-    },
+    }
 
     /**
      * Send an IQ stanza to the server asking for all groupchats
@@ -152,7 +153,7 @@ export default BootstrapModal.extend({
         api.sendIQ(iq)
             .then(iq => this.onRoomsFound(iq))
             .catch(() => this.onRoomsFound())
-    },
+    }
 
     showRooms (ev) {
         ev.preventDefault();
@@ -162,13 +163,15 @@ export default BootstrapModal.extend({
         const data = new FormData(ev.target);
         this.model.setDomain(data.get('server'));
         this.updateRoomsList();
-    },
+    }
 
     setDomainFromEvent (ev) {
         this.model.setDomain(ev.target.value);
-    },
+    }
 
     setNick (ev) {
         this.model.save({nick: ev.target.value});
     }
-});
+}
+
+api.elements.define('converse-muc-list-modal', MUCListModal);

+ 14 - 12
src/plugins/muc-views/modals/nickname.js

@@ -1,15 +1,17 @@
-import tpl_nickname from "./templates/nickname.js";
-import BaseModal from "plugins/modal/base.js";
+import BaseModal from "plugins/modal/modal.js";
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core.js";
+import { html } from 'lit';
 
-export default BaseModal.extend({
-    id: 'change-nickname-modal',
+export default class MUCNicknameModal extends BaseModal {
 
-    initialize (attrs) {
-        this.model = attrs.model;
-        BaseModal.prototype.initialize.apply(this, arguments);
-    },
+    renderModal () {
+        return html`<converse-muc-nickname-form jid="${this.model.get('jid')}"></converse-muc-nickname-form>`;
+    }
 
-    toHTML () {
-        return tpl_nickname(this);
-    },
-});
+    getModalTitle () { // eslint-disable-line class-methods-use-this
+        return __('Change your nickname');
+    }
+}
+
+api.elements.define('converse-muc-nickname-modal', MUCNicknameModal);

+ 15 - 15
src/plugins/muc-views/modals/occupant.js

@@ -1,16 +1,14 @@
-import BaseModal from "plugins/modal/base.js";
+import BaseModal from "plugins/modal/modal.js";
 import tpl_occupant_modal from "./templates/occupant.js";
 import { _converse, api } from "@converse/headless/core";
 
 
-const OccupantModal = BaseModal.extend({
-    id: "muc-occupant",
+export default class OccupantModal extends BaseModal {
 
     initialize () {
-        BaseModal.prototype.initialize.apply(this, arguments);
-        if (this.model) {
-            this.listenTo(this.model, 'change', this.render);
-        }
+        super.initialize()
+        const model = this.model ?? this.message;
+        this.listenTo(model, 'change', () => this.render());
         /**
          * Triggered once the OccupantModal has been initialized
          * @event _converse#occupantModalInitialized
@@ -18,7 +16,7 @@ const OccupantModal = BaseModal.extend({
          * @example _converse.api.listen.on('occupantModalInitialized', data);
          */
         api.trigger('occupantModalInitialized', { 'model': this.model, 'message': this.message });
-    },
+    }
 
     getVcard () {
         const model = this.model ?? this.message;
@@ -27,22 +25,24 @@ const OccupantModal = BaseModal.extend({
         }
         const jid = model?.get('jid') || model?.get('from');
         return jid ? _converse.vcards.get(jid) : null;
-    },
+    }
 
-    toHTML () {
+    renderModal () {
         const model = this.model ?? this.message;
         const jid = model?.get('jid');
         const vcard = this.getVcard();
-        const display_name = model?.getDisplayName();
         const nick = model.get('nick');
         const occupant_id = model.get('occupant_id');
         const role = this.model?.get('role');
         const affiliation = this.model?.get('affiliation');
         const hats = this.model?.get('hats')?.length ? this.model.get('hats') : null;
-        return tpl_occupant_modal({ jid, vcard, display_name, nick, occupant_id, role, affiliation, hats });
+        return tpl_occupant_modal({ jid, vcard, nick, occupant_id, role, affiliation, hats });
     }
-});
 
-_converse.OccupantModal = OccupantModal;
+    getModalTitle () { // eslint-disable-line class-methods-use-this
+        const model = this.model ?? this.message;
+        return model?.getDisplayName();
+    }
+}
 
-export default OccupantModal;
+api.elements.define('converse-muc-occupant-modal', OccupantModal);

+ 57 - 0
src/plugins/muc-views/modals/templates/add-muc.js

@@ -0,0 +1,57 @@
+import DOMPurify from 'dompurify';
+import { __ } from 'i18n';
+import { api } from '@converse/headless/core.js';
+import { html } from "lit";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+import { getAutoCompleteList } from "../../search.js";
+
+
+const nickname_input = (el) => {
+    const i18n_nickname = __('Nickname');
+    const i18n_required_field = __('This field is required');
+        return html`
+            <div class="form-group" >
+                <label for="nickname">${i18n_nickname}:</label>
+                <input type="text"
+                    title="${i18n_required_field}"
+                    required="required"
+                    name="nickname"
+                    value="${el.model.get('nick') || ''}"
+                    class="form-control"/>
+            </div>
+    `;
+}
+
+export default (el) => {
+    const i18n_join = __('Join');
+    const muc_domain = el.model.get('muc_domain') || api.settings.get('muc_domain');
+
+    let placeholder = '';
+    if (!api.settings.get('locked_muc_domain')) {
+        placeholder = muc_domain ? `name@${muc_domain}` : __('name@conference.example.org');
+    }
+
+    const label_room_address = muc_domain ? __('Groupchat name') :  __('Groupchat address');
+    const muc_roomid_policy_error_msg = el.muc_roomid_policy_error_msg;
+    const muc_roomid_policy_hint = api.settings.get('muc_roomid_policy_hint');
+    return html`
+        <form class="converse-form add-chatroom" @submit=${(ev) => el.openChatRoom(ev)}>
+            <div class="form-group">
+                <label for="chatroom">${label_room_address}:</label>
+                ${ (muc_roomid_policy_error_msg) ? html`<label class="roomid-policy-error">${muc_roomid_policy_error_msg}</label>` : '' }
+                <converse-autocomplete
+                    .getAutoCompleteList=${getAutoCompleteList}
+                    ?autofocus=${true}
+                    min_chars="3"
+                    position="below"
+                    placeholder="${placeholder}"
+                    class="add-muc-autocomplete"
+                    name="chatroom">
+                </converse-autocomplete>
+            </div>
+            ${ muc_roomid_policy_hint ?  html`<div class="form-group">${unsafeHTML(DOMPurify.sanitize(muc_roomid_policy_hint, {'ALLOWED_TAGS': ['b', 'br', 'em']}))}</div>` : '' }
+            ${ !api.settings.get('locked_muc_nickname') ? nickname_input(el) : '' }
+            <input type="submit" class="btn btn-primary" name="join" value="${i18n_join || ''}" ?disabled=${muc_roomid_policy_error_msg}/>
+        </form>
+    `;
+}

+ 0 - 19
src/plugins/muc-views/modals/templates/moderator-tools.js

@@ -1,19 +0,0 @@
-import { __ } from 'i18n';
-import { html } from "lit";
-import { modal_header_close_button } from "plugins/modal/templates/buttons.js"
-
-export default (o) => {
-    const i18n_moderator_tools = __('Moderator Tools');
-    return html`
-        <div class="modal-dialog" role="document">
-            <div class="modal-content">
-                <div class="modal-header">
-                    <h5 class="modal-title" id="converse-modtools-modal-label">${i18n_moderator_tools}</h5>
-                    ${modal_header_close_button}
-                </div>
-                <div class="modal-body d-flex flex-column">
-                    <converse-modtools jid=${o.jid} affiliation=${o.affiliation}></converse-modtools>
-                </div>
-            </div>
-        </div>`;
-}

+ 24 - 39
src/plugins/muc-views/modals/templates/muc-details.js

@@ -1,6 +1,5 @@
 import { __ } from 'i18n';
 import { html } from "lit";
-import { modal_close_button, modal_header_close_button } from "plugins/modal/templates/buttons.js";
 
 
 const subject = (o) => {
@@ -16,7 +15,6 @@ const subject = (o) => {
 export default (model) => {
     const o = model.toJSON();
     const config = model.config.toJSON();
-    const display_name = __('Groupchat info for %1$s', model.getDisplayName());
     const features = model.features.toJSON();
     const num_occupants = model.occupants.filter(o => o.get('show') !== 'offline').length;
 
@@ -51,43 +49,30 @@ export default (model) => {
     const i18n_temporary = __('Temporary');
     const i18n_temporary_help = __('This groupchat will disappear once the last person leaves');
     return html`
-        <div class="modal-dialog" role="document">
-            <div class="modal-content">
-                <div class="modal-header">
-                    <h5 class="modal-title" id="muc-details-modal-label">${display_name}</h5>
-                    ${modal_header_close_button}
+        <div class="room-info">
+            <p class="room-info"><strong>${i18n_name}</strong>: ${o.name}</p>
+            <p class="room-info"><strong>${i18n_address}</strong>: <converse-rich-text text="xmpp:${o.jid}?join"></converse-rich-text></p>
+            <p class="room-info"><strong>${i18n_desc}</strong>: <converse-rich-text text="${config.description}" render_styling></converse-rich-text></p>
+            ${ (o.subject) ? subject(o) : '' }
+            <p class="room-info"><strong>${i18n_online_users}</strong>: ${num_occupants}</p>
+            <p class="room-info"><strong>${i18n_features}</strong>:
+                <div class="chatroom-features">
+                <ul class="features-list">
+                    ${ features.passwordprotected ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-lock"></converse-icon>${i18n_password_protected} - <em>${i18n_password_help}</em></li>` : '' }
+                    ${ features.unsecured ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-unlock"></converse-icon>${i18n_no_password_required} - <em>${i18n_no_pass_help}</em></li>` : '' }
+                    ${ features.hidden ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-eye-slash"></converse-icon>${i18n_hidden} - <em>${i18n_hidden_help}</em></li>` : '' }
+                    ${ features.public_room ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-eye"></converse-icon>${i18n_public} - <em>${o.__('This groupchat is publicly searchable') }</em></li>` : '' }
+                    ${ features.membersonly ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-address-book"></converse-icon>${i18n_members_only} - <em>${i18n_members_help}</em></li>` : '' }
+                    ${ features.open ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-globe"></converse-icon>${i18n_open} - <em>${i18n_open_help}</em></li>` : '' }
+                    ${ features.persistent ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-save"></converse-icon>${i18n_persistent} - <em>${i18n_persistent_help}</em></li>` : '' }
+                    ${ features.temporary ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-snowflake"></converse-icon>${i18n_temporary} - <em>${i18n_temporary_help}</em></li>` : '' }
+                    ${ features.nonanonymous ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-id-card"></converse-icon>${i18n_not_anonymous} - <em>${i18n_not_anonymous_help}</em></li>` : '' }
+                    ${ features.semianonymous ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-user-secret"></converse-icon>${i18n_semi_anon} - <em>${i18n_semi_anon_help}</em></li>` : '' }
+                    ${ features.moderated ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-gavel"></converse-icon>${i18n_moderated} - <em>${i18n_moderated_help}</em></li>` : '' }
+                    ${ features.unmoderated ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-info-circle"></converse-icon>${i18n_not_moderated} - <em>${i18n_not_moderated_help}</em></li>` : '' }
+                    ${ features.mam_enabled ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-database"></converse-icon>${i18n_archiving} - <em>${i18n_archiving_help}</em></li>` : '' }
+                </ul>
                 </div>
-                <div class="modal-body">
-                    <span class="modal-alert"></span>
-                    <div class="room-info">
-                        <p class="room-info"><strong>${i18n_name}</strong>: ${o.name}</p>
-                        <p class="room-info"><strong>${i18n_address}</strong>: <converse-rich-text text="xmpp:${o.jid}?join"></converse-rich-text></p>
-                        <p class="room-info"><strong>${i18n_desc}</strong>: <converse-rich-text text="${config.description}" render_styling></converse-rich-text></p>
-                        ${ (o.subject) ? subject(o) : '' }
-                        <p class="room-info"><strong>${i18n_online_users}</strong>: ${num_occupants}</p>
-                        <p class="room-info"><strong>${i18n_features}</strong>:
-                            <div class="chatroom-features">
-                            <ul class="features-list">
-                                ${ features.passwordprotected ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-lock"></converse-icon>${i18n_password_protected} - <em>${i18n_password_help}</em></li>` : '' }
-                                ${ features.unsecured ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-unlock"></converse-icon>${i18n_no_password_required} - <em>${i18n_no_pass_help}</em></li>` : '' }
-                                ${ features.hidden ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-eye-slash"></converse-icon>${i18n_hidden} - <em>${i18n_hidden_help}</em></li>` : '' }
-                                ${ features.public_room ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-eye"></converse-icon>${i18n_public} - <em>${o.__('This groupchat is publicly searchable') }</em></li>` : '' }
-                                ${ features.membersonly ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-address-book"></converse-icon>${i18n_members_only} - <em>${i18n_members_help}</em></li>` : '' }
-                                ${ features.open ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-globe"></converse-icon>${i18n_open} - <em>${i18n_open_help}</em></li>` : '' }
-                                ${ features.persistent ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-save"></converse-icon>${i18n_persistent} - <em>${i18n_persistent_help}</em></li>` : '' }
-                                ${ features.temporary ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-snowflake-o"></converse-icon>${i18n_temporary} - <em>${i18n_temporary_help}</em></li>` : '' }
-                                ${ features.nonanonymous ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-id-card"></converse-icon>${i18n_not_anonymous} - <em>${i18n_not_anonymous_help}</em></li>` : '' }
-                                ${ features.semianonymous ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-user-secret"></converse-icon>${i18n_semi_anon} - <em>${i18n_semi_anon_help}</em></li>` : '' }
-                                ${ features.moderated ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-gavel"></converse-icon>${i18n_moderated} - <em>${i18n_moderated_help}</em></li>` : '' }
-                                ${ features.unmoderated ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-info-circle"></converse-icon>${i18n_not_moderated} - <em>${i18n_not_moderated_help}</em></li>` : '' }
-                                ${ features.mam_enabled ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-database"></converse-icon>${i18n_archiving} - <em>${i18n_archiving_help}</em></li>` : '' }
-                            </ul>
-                            </div>
-                        </p>
-                    </div>
-                </div>
-                <div class="modal-footer">${modal_close_button}</div>
-            </div>
-        </div>
+            </p>
     `;
 }

+ 23 - 37
src/plugins/muc-views/modals/templates/muc-invite.js

@@ -1,49 +1,35 @@
 import { html } from "lit";
 import { __ } from 'i18n';
-import { modal_header_close_button } from "plugins/modal/templates/buttons.js"
 
-
-export default (o) => {
+export default (el) => {
     const i18n_invite = __('Invite');
-    const i18n_invite_heading = __('Invite someone to this groupchat');
     const i18n_jid_placeholder = __('user@example.org');
     const i18n_error_message = __('Please enter a valid XMPP address');
     const i18n_invite_label = __('XMPP Address');
     const i18n_reason = __('Optional reason for the invitation');
     return html`
-        <div class="modal-dialog" role="document">
-            <div class="modal-content">
-                <div class="modal-header">
-                    <h5 class="modal-title" id="add-chatroom-modal-label">${i18n_invite_heading}</h5>
-                    ${modal_header_close_button}
-                </div>
-                <div class="modal-body">
-                    <span class="modal-alert"></span>
-                    <div class="suggestion-box room-invite">
-                        <form @submit=${o.submitInviteForm}>
-                            <div class="form-group">
-                                <label class="clearfix" for="invitee_jids">${i18n_invite_label}:</label>
-                                ${ o.invalid_invite_jid ? html`<div class="error error-feedback">${i18n_error_message}</div>` : '' }
-                                <input class="form-control suggestion-box__input"
-                                    required="required"
-                                    name="invitee_jids"
-                                    id="invitee_jids"
-                                    placeholder="${i18n_jid_placeholder}"
-                                    type="text"/>
-                                <span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span>
-                                <ul class="suggestion-box__results suggestion-box__results--below" hidden=""></ul>
-                            </div>
-                            <div class="form-group">
-                                <label>${i18n_reason}:</label>
-                                <textarea class="form-control" name="reason"></textarea>
-                            </div>
-                            <div class="form-group">
-                                <button type="submit" class="btn btn-primary">${i18n_invite}</button>
-                            </div>
-                        </form>
-                    </div>
-                </div>
+        <form class="converse-form" @submit=${(ev) => el.submitInviteForm(ev)}>
+            <div class="form-group">
+                <label class="clearfix" for="invitee_jids">${i18n_invite_label}:</label>
+                ${ el.model.get('invalid_invite_jid') ? html`<div class="error error-feedback">${i18n_error_message}</div>` : '' }
+                <converse-autocomplete
+                    .getAutoCompleteList=${() => el.getAutoCompleteList()}
+                    ?autofocus=${true}
+                    min_chars="1"
+                    position="below"
+                    required="required"
+                    name="invitee_jids"
+                    id="invitee_jids"
+                    placeholder="${i18n_jid_placeholder}">
+                </converse-autocomplete>
+            </div>
+            <div class="form-group">
+                <label>${i18n_reason}:</label>
+                <textarea class="form-control" name="reason"></textarea>
+            </div>
+            <div class="form-group">
+                <input type="submit" class="btn btn-primary" value="${i18n_invite}"/>
             </div>
-        </div>
+        </form>
     `;
 }

+ 0 - 21
src/plugins/muc-views/modals/templates/nickname.js

@@ -1,21 +0,0 @@
-import { __ } from 'i18n';
-import { html } from "lit";
-import { modal_header_close_button } from "plugins/modal/templates/buttons.js"
-
-export default (modal) => {
-    const jid = modal.model.get('jid');
-    const i18n_heading = __('Change your nickname');
-    return html`
-        <div class="modal-dialog" role="document">
-            <div class="modal-content">
-                <div class="modal-header">
-                    <h5 class="modal-title" id="converse-modtools-modal-label">
-                        ${i18n_heading}</h5>
-                    ${modal_header_close_button}
-                </div>
-                <div class="modal-body d-flex flex-column">
-                    <converse-muc-nickname-form jid="${jid}"></converse-muc-nickname-form>
-                </div>
-            </div>
-        </div>`;
-}

+ 29 - 43
src/plugins/muc-views/modals/templates/occupant.js

@@ -1,53 +1,39 @@
 import 'shared/avatar/avatar.js';
 import { __ } from 'i18n';
 import { html } from "lit";
-import { modal_close_button, modal_header_close_button } from "plugins/modal/templates/buttons.js"
 
 
 export default (o) => {
     return html`
-        <div class="modal-dialog" role="document">
-            <div class="modal-content">
-                <div class="modal-header">
-                    <h5 class="modal-title" id="user-details-modal-label">${o.display_name}</h5>
-                    ${modal_header_close_button}
-                </div>
-                <div class="modal-body" class="d-flex">
-                    <div class="row">
-                        <div class="col-auto">
-                            <converse-avatar
-                                class="avatar modal-avatar"
-                                .data=${o.vcard?.attributes}
-                                nonce=${o.vcard?.get('vcard_updated')}
-                                height="120" width="120"></converse-avatar>
-                        </div>
-                        <div class="col">
-                            <ul class="occupant-details">
-                                <li>
-                                    ${ o.nick ? html`<div class="row"><strong>${__('Nickname')}:</strong></div><div class="row">${o.nick}</div>` : '' }
-                                </li>
-                                <li>
-                                    ${ o.jid ? html`<div class="row"><strong>${__('XMPP Address')}:</strong></div><div class="row">${o.jid}</div>` : '' }
-                                </li>
-                                <li>
-                                    ${ o.affiliation ? html`<div class="row"><strong>${__('Affiliation')}:</strong></div><div class="row">${o.affiliation}</div>` : '' }
-                                </li>
-                                <li>
-                                    ${ o.role ? html`<div class="row"><strong>${__('Roles')}:</strong></div><div class="row">${o.role}</div>` : '' }
-                                </li>
-                                <li>
-                                    ${ o.hats ? html`<div class="row"><strong>${__('Hats')}:</strong></div><div class="row">${o.hats}</div>` : '' }
-                                </li>
-                                <li>
-                                    ${ o.occupant_id ? html`<div class="row"><strong>${__('Occupant Id')}:</strong></div><div class="row">${o.occupant_id}</div>` : '' }
-                                </li>
-                            </ul>
-                        </div>
-                    </div>
-                </div>
-                <div class="modal-footer">
-                    ${modal_close_button}
-                </div>
+        <div class="row">
+            <div class="col-auto">
+                <converse-avatar
+                    class="avatar modal-avatar"
+                    .data=${o.vcard?.attributes}
+                    nonce=${o.vcard?.get('vcard_updated')}
+                    height="120" width="120"></converse-avatar>
+            </div>
+            <div class="col">
+                <ul class="occupant-details">
+                    <li>
+                        ${ o.nick ? html`<div class="row"><strong>${__('Nickname')}:</strong></div><div class="row">${o.nick}</div>` : '' }
+                    </li>
+                    <li>
+                        ${ o.jid ? html`<div class="row"><strong>${__('XMPP Address')}:</strong></div><div class="row">${o.jid}</div>` : '' }
+                    </li>
+                    <li>
+                        ${ o.affiliation ? html`<div class="row"><strong>${__('Affiliation')}:</strong></div><div class="row">${o.affiliation}</div>` : '' }
+                    </li>
+                    <li>
+                        ${ o.role ? html`<div class="row"><strong>${__('Roles')}:</strong></div><div class="row">${o.role}</div>` : '' }
+                    </li>
+                    <li>
+                        ${ o.hats ? html`<div class="row"><strong>${__('Hats')}:</strong></div><div class="row">${o.hats}</div>` : '' }
+                    </li>
+                    <li>
+                        ${ o.occupant_id ? html`<div class="row"><strong>${__('Occupant Id')}:</strong></div><div class="row">${o.occupant_id}</div>` : '' }
+                    </li>
+                </ul>
             </div>
         </div>
     `;

+ 10 - 0
src/plugins/muc-views/modtools.js

@@ -25,6 +25,7 @@ export default class ModeratorTools extends CustomElement {
             muc: { type: Object, attribute: false },
             role: { type: String },
             roles_filter: { type: String, attribute: false },
+            tab: { type: String },
             users_with_affiliation: { type: Array, attribute: false },
             users_with_role: { type: Array, attribute: false },
         };
@@ -32,6 +33,7 @@ export default class ModeratorTools extends CustomElement {
 
     constructor () {
         super();
+        this.tab = 'affiliations';
         this.affiliation = '';
         this.affiliations_filter = '';
         this.role = '';
@@ -72,6 +74,7 @@ export default class ModeratorTools extends CustomElement {
                 'queryable_roles': ROLES.filter(a => !api.settings.get('modtools_disable_query').includes(a)),
                 'roles_filter': this.roles_filter,
                 'switchTab': ev => this.switchTab(ev),
+                'tab': this.tab,
                 'toggleForm': ev => this.toggleForm(ev),
                 'users_with_affiliation': this.users_with_affiliation,
                 'users_with_role': this.users_with_role,
@@ -81,6 +84,13 @@ export default class ModeratorTools extends CustomElement {
         }
     }
 
+    switchTab (ev) {
+        ev.stopPropagation();
+        ev.preventDefault();
+        this.tab = ev.target.getAttribute('data-name');
+        this.requestUpdate();
+    }
+
     async onSearchAffiliationChange () {
         if (!this.affiliation) {
             return;

+ 10 - 8
src/plugins/muc-views/styles/add-muc-modal.scss

@@ -1,12 +1,14 @@
-#add-chatroom-modal {
-    converse-autocomplete {
-        .suggestion-box__results--below {
-            height: 10em;
-            overflow: auto;
-        }
+converse-add-muc-modal {
+    .add-chatroom {
+        converse-autocomplete {
+            .suggestion-box__results--below {
+                height: 10em;
+                overflow: auto;
+            }
 
-        .suggestion-box ul li {
-            display: block;
+            .suggestion-box ul li {
+                display: block;
+            }
         }
     }
 }

+ 0 - 1
src/plugins/muc-views/styles/index.scss

@@ -5,7 +5,6 @@
 
 @import "./controlbox.scss";
 @import "./muc.scss";
-@import "./muc-details-modal.scss";
 
 converse-muc-disconnected,
 converse-muc-destroyed {

+ 8 - 3
src/plugins/muc-views/styles/muc-details-modal.scss

@@ -1,8 +1,14 @@
-#muc-details-modal {
+converse-muc-details-modal {
     .features-list {
         margin-left: 1em;
     }
 
+    .room-info {
+        strong {
+            color: var(--muc-color);
+        }
+    }
+
     .chatroom-features {
         width: 100%;
         .features-list {
@@ -13,9 +19,8 @@
                 padding-right: 0;
                 font-size: 1em;
                 cursor: help;
-                .fa {
+                converse-icon {
                     margin-right: 0.5em;
-                    color: var(--text-color);
                 }
             }
         }

+ 0 - 8
src/plugins/muc-views/styles/muc-details.scss

@@ -1,8 +0,0 @@
-#muc-details-modal {
-    .room-info {
-        strong {
-            color: var(--muc-color);
-        }
-    }
-}
-

+ 0 - 56
src/plugins/muc-views/templates/add-muc.js

@@ -1,56 +0,0 @@
-import DOMPurify from 'dompurify';
-import { __ } from 'i18n';
-import { api } from '@converse/headless/core.js';
-import { html } from "lit";
-import { modal_header_close_button } from "plugins/modal/templates/buttons.js"
-import { unsafeHTML } from "lit/directives/unsafe-html.js";
-import { getAutoCompleteList } from "../search.js";
-
-
-const nickname_input = (o) => {
-    const i18n_nickname = __('Nickname');
-    const i18n_required_field = __('This field is required');
-        return html`
-        <div class="form-group" >
-            <label for="nickname">${i18n_nickname}:</label>
-            <input type="text" title="${i18n_required_field}" required="required" name="nickname" value="${o.nick || ''}" class="form-control"/>
-        </div>
-    `;
-}
-
-
-export default (o) => {
-    const i18n_join = __('Join');
-    const i18n_enter = __('Enter a new Groupchat');
-    return html`
-        <div class="modal-dialog" role="document">
-            <div class="modal-content">
-                <div class="modal-header">
-                    <h5 class="modal-title" id="add-chatroom-modal-label">${i18n_enter}</h5>
-                    ${modal_header_close_button}
-                </div>
-                <div class="modal-body">
-                    <span class="modal-alert"></span>
-                    <form class="converse-form add-chatroom">
-                        <div class="form-group">
-                            <label for="chatroom">${o.label_room_address}:</label>
-                            ${ (o.muc_roomid_policy_error_msg) ? html`<label class="roomid-policy-error">${o.muc_roomid_policy_error_msg}</label>` : '' }
-                            <converse-autocomplete
-                                .getAutoCompleteList=${getAutoCompleteList}
-                                ?autofocus=${true}
-                                min_chars="3"
-                                position="below"
-                                placeholder="${o.chatroom_placeholder}"
-                                class="add-muc-autocomplete"
-                                name="chatroom">
-                            </converse-autocomplete>
-                        </div>
-                        ${ o.muc_roomid_policy_hint ?  html`<div class="form-group">${unsafeHTML(DOMPurify.sanitize(o.muc_roomid_policy_hint, {'ALLOWED_TAGS': ['b', 'br', 'em']}))}</div>` : '' }
-                        ${ !api.settings.get('locked_muc_nickname') ? nickname_input(o) : '' }
-                        <input type="submit" class="btn btn-primary" name="join" value="${i18n_join || ''}" ?disabled=${o.muc_roomid_policy_error_msg}>
-                    </form>
-                </div>
-            </div>
-        </div>
-    `;
-}

+ 18 - 6
src/plugins/muc-views/templates/moderator-tools.js

@@ -146,13 +146,25 @@ const affiliation_list_item = (o) => html`
 `;
 
 
-const tpl_navigation = () => html`
+const tpl_navigation = (o) => html`
     <ul class="nav nav-pills justify-content-center">
         <li role="presentation" class="nav-item">
-            <a class="nav-link active" id="affiliations-tab" href="#affiliations-tabpanel" aria-controls="affiliations-tabpanel" role="tab" data-toggle="tab">Affiliations</a>
+            <a class="nav-link ${o.tab === "affiliations" ? "active" : ""}"
+               id="affiliations-tab"
+               href="#affiliations-tabpanel"
+               aria-controls="affiliations-tabpanel"
+               role="tab"
+               data-name="affiliations"
+               @click=${o.switchTab}>Affiliations</a>
         </li>
         <li role="presentation" class="nav-item">
-            <a class="nav-link" id="roles-tab" href="#roles-tabpanel" aria-controls="roles-tabpanel" role="tab" data-toggle="tab">Roles</a>
+            <a class="nav-link ${o.tab === "roles" ? "active" : ""}"
+               id="roles-tab"
+               href="#roles-tabpanel"
+               aria-controls="roles-tabpanel"
+               role="tab"
+               data-name="roles"
+               @click=${o.switchTab}>Roles</a>
         </li>
     </ul>
 `;
@@ -178,12 +190,12 @@ export default (o) => {
     const show_both_tabs = o.queryable_roles.length && o.queryable_affiliations.length;
     return html`
         ${o.alert_message ? html`<div class="alert alert-${o.alert_type}" role="alert">${o.alert_message}</div>` : '' }
-        ${ show_both_tabs ? tpl_navigation() : '' }
+        ${ show_both_tabs ? tpl_navigation(o) : '' }
 
         <div class="tab-content">
 
             ${ o.queryable_affiliations.length ? html`
-            <div class="tab-pane tab-pane--columns ${ o.queryable_affiliations.length ? 'active' : ''}" id="affiliations-tabpanel" role="tabpanel" aria-labelledby="affiliations-tab">
+            <div class="tab-pane tab-pane--columns ${ o.tab === 'affiliations' ? 'active' : ''}" id="affiliations-tabpanel" role="tabpanel" aria-labelledby="affiliations-tab">
                 <form class="converse-form query-affiliation" @submit=${o.queryAffiliation}>
                     <p class="helptext pb-3">${i18n_helptext_affiliation}</p>
                     <div class="form-group">
@@ -225,7 +237,7 @@ export default (o) => {
             </div>` : '' }
 
             ${ o.queryable_roles.length ? html`
-            <div class="tab-pane tab-pane--columns ${ !show_both_tabs && o.queryable_roles.length ? 'active' : ''}" id="roles-tabpanel" role="tabpanel" aria-labelledby="roles-tab">
+            <div class="tab-pane tab-pane--columns ${ o.tab === 'roles' ? 'active' : ''}" id="roles-tabpanel" role="tabpanel" aria-labelledby="roles-tab">
                 <form class="converse-form query-role" @submit=${o.queryRole}>
                     <p class="helptext pb-3">${i18n_helptext_role}</p>
                     <div class="form-group">

+ 17 - 30
src/plugins/muc-views/templates/muc-list.js

@@ -1,7 +1,6 @@
 import { __ } from 'i18n';
 import { html } from "lit";
 import { repeat } from 'lit/directives/repeat.js';
-import { modal_close_button, modal_header_close_button } from "plugins/modal/templates/buttons.js"
 import spinner from "templates/spinner.js";
 
 
@@ -14,6 +13,7 @@ const form = (o) => {
             <div class="form-group">
                 <label for="chatroom">${i18n_server_address}:</label>
                 <input type="text"
+                    autofocus
                     @change=${o.setDomainFromEvent}
                     value="${o.muc_domain || ''}"
                     required="required"
@@ -34,16 +34,16 @@ const tpl_item = (o, item) => {
         <li class="room-item list-group-item">
             <div class="available-chatroom d-flex flex-row">
                 <a class="open-room available-room w-100"
-                @click=${o.openRoom}
-                data-room-jid="${item.jid}"
-                data-room-name="${item.name}"
-                title="${i18n_open_title}"
-                href="#">${item.name || item.jid}</a>
-                <a class="right room-info icon-room-info"
-                @click=${o.toggleRoomInfo}
-                data-room-jid="${item.jid}"
-                title="${i18n_info_title}"
-                href="#"></a>
+                    @click=${o.openRoom}
+                    data-room-jid="${item.jid}"
+                    data-room-name="${item.name}"
+                    title="${i18n_open_title}"
+                    href="#">${item.name || item.jid}</a>
+                    <a class="right room-info icon-room-info"
+                    @click=${o.toggleRoomInfo}
+                    data-room-jid="${item.jid}"
+                    title="${i18n_info_title}"
+                    href="#"></a>
             </div>
         </li>
     `;
@@ -51,25 +51,12 @@ const tpl_item = (o, item) => {
 
 
 export default (o) => {
-    const i18n_list_chatrooms = __('Query for Groupchats');
     return html`
-        <div class="modal-dialog" role="document">
-            <div class="modal-content">
-                <div class="modal-header">
-                    <h5 class="modal-title" id="muc-list-modal-label">${i18n_list_chatrooms}</h5>
-                    ${modal_header_close_button}
-                </div>
-                <div class="modal-body d-flex flex-column">
-                    <span class="modal-alert"></span>
-                    ${o.show_form ? form(o) : '' }
-                    <ul class="available-chatrooms list-group">
-                        ${ o.loading_items ? html`<li class="list-group-item"> ${spinner()} </li>` : '' }
-                        ${ o.feedback_text ? html`<li class="list-group-item active">${ o.feedback_text }</li>` : '' }
-                        ${repeat(o.items, item => item.jid, item => tpl_item(o, item))}
-                    </ul>
-                </div>
-                <div class="modal-footer">${modal_close_button}</div>
-            </div>
-        </div>
+        ${o.show_form ? form(o) : '' }
+        <ul class="available-chatrooms list-group">
+            ${ o.loading_items ? html`<li class="list-group-item"> ${spinner()} </li>` : '' }
+            ${ o.feedback_text ? html`<li class="list-group-item active">${ o.feedback_text }</li>` : '' }
+            ${repeat(o.items, item => item.jid, item => tpl_item(o, item))}
+        </ul>
     `;
 }

+ 7 - 4
src/plugins/muc-views/templates/muc-nickname-form.js

@@ -12,9 +12,9 @@ export default (el) => {
     const validation_message = el.model?.get('nickname_validation_message');
 
     return html`
-        <div class="chatroom-form-container muc-nickname-form"
-                @submit=${ev => el.submitNickname(ev)}>
-            <form class="converse-form chatroom-form converse-centered-form">
+        <div class="chatroom-form-container muc-nickname-form">
+                <form class="converse-form chatroom-form converse-centered-form"
+                        @submit=${ev => el.submitNickname(ev)}>
                 <fieldset class="form-group">
                     <label>${i18n_heading}</label>
                     <p class="validation-message">${validation_message}</p>
@@ -26,7 +26,10 @@ export default (el) => {
                         placeholder="${i18n_nickname}"/>
                 </fieldset>
                 <fieldset class="form-group">
-                    <input type="submit" class="btn btn-primary" name="join" value="${i18n_join}"/>
+                    <input type="submit"
+                        class="btn btn-primary"
+                        name="join"
+                        value="${i18n_join}"/>
                 </fieldset>
             </form>
         </div>`;

+ 6 - 6
src/plugins/muc-views/tests/corrections.js

@@ -60,9 +60,9 @@ describe("A Groupchat Message", function () {
         expect(view.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
         const edit = await u.waitUntil(() => view.querySelector('.chat-msg__content .fa-edit'));
         edit.click();
-        const modal = _converse.api.modal.get('message-versions-modal');
-        await u.waitUntil(() => u.isVisible(modal.el), 1000);
-        const older_msgs = modal.el.querySelectorAll('.older-msg');
+        const modal = _converse.api.modal.get('converse-message-versions-modal');
+        await u.waitUntil(() => u.isVisible(modal), 1000);
+        const older_msgs = modal.querySelectorAll('.older-msg');
         expect(older_msgs.length).toBe(2);
         expect(older_msgs[0].textContent.includes('But soft, what light through yonder airlock breaks?')).toBe(true);
         expect(older_msgs[1].textContent.includes('But soft, what light through yonder chimney breaks?')).toBe(true);
@@ -151,9 +151,9 @@ describe("A Groupchat Message", function () {
         expect(view.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
         const edit = await u.waitUntil(() => view.querySelector('.chat-msg__content .fa-edit'));
         edit.click();
-        const modal = _converse.api.modal.get('message-versions-modal');
-        await u.waitUntil(() => u.isVisible(modal.el), 1000);
-        const older_msgs = modal.el.querySelectorAll('.older-msg');
+        const modal = _converse.api.modal.get('converse-message-versions-modal');
+        await u.waitUntil(() => u.isVisible(modal), 1000);
+        const older_msgs = modal.querySelectorAll('.older-msg');
         expect(older_msgs.length).toBe(2);
         expect(older_msgs[0].textContent.includes('But soft, what light through yonder airlock breaks?')).toBe(true);
         expect(older_msgs[1].textContent.includes('But soft, what light through yonder chimney breaks?')).toBe(true);

+ 51 - 51
src/plugins/muc-views/tests/modtools.js

@@ -14,7 +14,7 @@ async function openModtools (_converse, view) {
     const message_form = view.querySelector('converse-muc-message-form');
     message_form.onKeyDown(enter);
     const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal'));
-    await u.waitUntil(() => u.isVisible(modal.el), 1000);
+    await u.waitUntil(() => u.isVisible(modal), 1000);
     return modal;
 }
 
@@ -37,18 +37,18 @@ describe("The groupchat moderator tool", function () {
         await u.waitUntil(() => (view.model.occupants.length === 5), 1000);
 
         const modal = await openModtools(_converse, view);
-        let tab = modal.el.querySelector('#affiliations-tab');
+        let tab = modal.querySelector('#affiliations-tab');
         // Clear so that we don't match older stanzas
         _converse.connection.IQ_stanzas = [];
         tab.click();
-        let select = modal.el.querySelector('.select-affiliation');
+        let select = modal.querySelector('.select-affiliation');
         expect(select.value).toBe('owner');
         select.value = 'admin';
-        let button = modal.el.querySelector('.btn-primary[name="users_with_affiliation"]');
+        let button = modal.querySelector('.btn-primary[name="users_with_affiliation"]');
         button.click();
         await u.waitUntil(() => !modal.loading_users_with_affiliation);
-        await u.waitUntil(() => modal.el.querySelectorAll('.list-group--users > li').length);
-        let user_els = modal.el.querySelectorAll('.list-group--users > li');
+        await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length);
+        let user_els = modal.querySelectorAll('.list-group--users > li');
         expect(user_els.length).toBe(1);
         expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: wiccarocks@shakespeare.lit');
         expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: wiccan');
@@ -58,8 +58,8 @@ describe("The groupchat moderator tool", function () {
         select.value = 'owner';
         button.click();
         await u.waitUntil(() => !modal.loading_users_with_affiliation);
-        await u.waitUntil(() => modal.el.querySelectorAll('.list-group--users > li').length === 2);
-        user_els = modal.el.querySelectorAll('.list-group--users > li');
+        await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 2);
+        user_els = modal.querySelectorAll('.list-group--users > li');
         expect(user_els.length).toBe(2);
         expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: romeo@montague.lit');
         expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: romeo');
@@ -112,25 +112,25 @@ describe("The groupchat moderator tool", function () {
         ];
         await mock.returnMemberLists(_converse, muc_jid, members);
         await u.waitUntil(() => view.model.occupants.pluck('affiliation').filter(o => o === 'owner').length === 1);
-        const alert = modal.el.querySelector('.alert-primary');
+        const alert = modal.querySelector('.alert-primary');
         expect(alert.textContent.trim()).toBe('Affiliation changed');
 
-        await u.waitUntil(() => modal.el.querySelectorAll('.list-group--users > li').length === 1);
-        user_els = modal.el.querySelectorAll('.list-group--users > li');
+        await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 1);
+        user_els = modal.querySelectorAll('.list-group--users > li');
         expect(user_els.length).toBe(1);
         expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: romeo@montague.lit');
         expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: romeo');
         expect(user_els[0].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: owner');
 
-        tab = modal.el.querySelector('#roles-tab');
-        tab.click();
-        select = modal.el.querySelector('.select-role');
-        expect(u.isVisible(select)).toBe(true);
+        modal.querySelector('#roles-tab').click();
+        select = modal.querySelector('.select-role');
+        await u.waitUntil(() => u.isVisible(select));
+
         expect(select.value).toBe('moderator');
-        button = modal.el.querySelector('.btn-primary[name="users_with_role"]');
+        button = modal.querySelector('.btn-primary[name="users_with_role"]');
         button.click();
 
-        const roles_panel = modal.el.querySelector('#roles-tabpanel');
+        const roles_panel = modal.querySelector('#roles-tabpanel');
         await u.waitUntil(() => roles_panel.querySelectorAll('.list-group--users > li').length === 1);
         select.value = 'participant';
         button.click();
@@ -158,35 +158,35 @@ describe("The groupchat moderator tool", function () {
         // Clear so that we don't match older stanzas
         _converse.connection.IQ_stanzas = [];
         const modal = await openModtools(_converse, view);
-        const select = modal.el.querySelector('.select-affiliation');
+        const select = modal.querySelector('.select-affiliation');
         expect(select.value).toBe('owner');
         select.value = 'member';
-        const button = modal.el.querySelector('.btn-primary[name="users_with_affiliation"]');
+        const button = modal.querySelector('.btn-primary[name="users_with_affiliation"]');
         button.click();
         await u.waitUntil(() => !modal.loading_users_with_affiliation);
-        await u.waitUntil(() => modal.el.querySelectorAll('.list-group--users > li').length === 6);
+        await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 6);
 
-        const nicks = Array.from(modal.el.querySelectorAll('.list-group--users > li')).map(el => el.getAttribute('data-nick'));
+        const nicks = Array.from(modal.querySelectorAll('.list-group--users > li')).map(el => el.getAttribute('data-nick'));
         expect(nicks.join(' ')).toBe('gower juliet romeo thirdwitch wiccan witch');
 
-        const filter = modal.el.querySelector('[name="filter"]');
+        const filter = modal.querySelector('[name="filter"]');
         expect(filter).not.toBe(null);
 
         filter.value = 'romeo';
         u.triggerEvent(filter, "keyup", "KeyboardEvent");
-        await u.waitUntil(() => ( modal.el.querySelectorAll('.list-group--users > li').length === 1));
+        await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1));
 
         filter.value = 'r';
         u.triggerEvent(filter, "keyup", "KeyboardEvent");
-        await u.waitUntil(() => ( modal.el.querySelectorAll('.list-group--users > li').length === 3));
+        await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 3));
 
         filter.value = 'gower';
         u.triggerEvent(filter, "keyup", "KeyboardEvent");
-        await u.waitUntil(() => ( modal.el.querySelectorAll('.list-group--users > li').length === 1));
+        await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1));
 
         filter.value = 'RoMeO';
         u.triggerEvent(filter, "keyup", "KeyboardEvent");
-        await u.waitUntil(() => ( modal.el.querySelectorAll('.list-group--users > li').length === 1));
+        await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1));
 
     }));
 
@@ -259,40 +259,40 @@ describe("The groupchat moderator tool", function () {
         message_form.onKeyDown(enter);
 
         const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal'));
-        await u.waitUntil(() => u.isVisible(modal.el), 1000);
+        await u.waitUntil(() => u.isVisible(modal), 1000);
 
-        const tab = modal.el.querySelector('#roles-tab');
+        const tab = modal.querySelector('#roles-tab');
         tab.click();
 
         // Clear so that we don't match older stanzas
         _converse.connection.IQ_stanzas = [];
 
-        const select = modal.el.querySelector('.select-role');
+        const select = modal.querySelector('.select-role');
         expect(select.value).toBe('moderator');
         select.value = 'participant';
 
-        const button = modal.el.querySelector('.btn-primary[name="users_with_role"]');
+        const button = modal.querySelector('.btn-primary[name="users_with_role"]');
         button.click();
         await u.waitUntil(() => !modal.loading_users_with_role);
-        await u.waitUntil(() => modal.el.querySelectorAll('.list-group--users > li').length === 6);
+        await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 6);
 
-        const nicks = Array.from(modal.el.querySelectorAll('.list-group--users > li')).map(el => el.getAttribute('data-nick'));
+        const nicks = Array.from(modal.querySelectorAll('.list-group--users > li')).map(el => el.getAttribute('data-nick'));
         expect(nicks.join(' ')).toBe('crone newb nomorenicks oldhag some1 tux');
 
-        const filter = modal.el.querySelector('[name="filter"]');
+        const filter = modal.querySelector('[name="filter"]');
         expect(filter).not.toBe(null);
 
         filter.value = 'tux';
         u.triggerEvent(filter, "keyup", "KeyboardEvent");
-        await u.waitUntil(() => ( modal.el.querySelectorAll('.list-group--users > li').length === 1));
+        await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1));
 
         filter.value = 'r';
         u.triggerEvent(filter, "keyup", "KeyboardEvent");
-        await u.waitUntil(() => ( modal.el.querySelectorAll('.list-group--users > li').length === 2));
+        await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 2));
 
         filter.value = 'crone';
         u.triggerEvent(filter, "keyup", "KeyboardEvent");
-        await u.waitUntil(() => ( modal.el.querySelectorAll('.list-group--users > li').length === 1));
+        await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1));
     }));
 
     it("shows an error message if a particular affiliation list may not be retrieved",
@@ -310,14 +310,14 @@ describe("The groupchat moderator tool", function () {
         const view = _converse.chatboxviews.get(muc_jid);
         await u.waitUntil(() => (view.model.occupants.length === 5));
         const modal = await openModtools(_converse, view);
-        const tab = modal.el.querySelector('#affiliations-tab');
+        const tab = modal.querySelector('#affiliations-tab');
         // Clear so that we don't match older stanzas
         _converse.connection.IQ_stanzas = [];
         const IQ_stanzas = _converse.connection.IQ_stanzas;
         tab.click();
-        const select = modal.el.querySelector('.select-affiliation');
+        const select = modal.querySelector('.select-affiliation');
         select.value = 'outcast';
-        const button = modal.el.querySelector('.btn-primary[name="users_with_affiliation"]');
+        const button = modal.querySelector('.btn-primary[name="users_with_affiliation"]');
         button.click();
 
         const iq_query = await u.waitUntil(() => _.filter(
@@ -338,10 +338,10 @@ describe("The groupchat moderator tool", function () {
         _converse.connection._dataRecv(mock.createRequest(error));
         await u.waitUntil(() => !modal.loading_users_with_affiliation);
 
-        const alert = await u.waitUntil(() => modal.el.querySelector('.alert'));
+        const alert = await u.waitUntil(() => modal.querySelector('.alert'));
         expect(alert.textContent.trim()).toBe('Error: not allowed to fetch outcast list for MUC lounge@montague.lit');
 
-        const user_els = modal.el.querySelectorAll('.list-group--users > li');
+        const user_els = modal.querySelectorAll('.list-group--users > li');
         expect(user_els.length).toBe(1);
         expect(user_els[0].textContent.trim()).toBe('No users with that affiliation found.');
     }));
@@ -361,16 +361,16 @@ describe("The groupchat moderator tool", function () {
         // Clear so that we don't match older stanzas
         _converse.connection.IQ_stanzas = [];
 
-        const tab = modal.el.querySelector('#affiliations-tab');
+        const tab = modal.querySelector('#affiliations-tab');
         tab.click();
-        const select = modal.el.querySelector('.select-affiliation');
+        const select = modal.querySelector('.select-affiliation');
         select.value = 'member';
-        const button = modal.el.querySelector('.btn-primary[name="users_with_affiliation"]');
+        const button = modal.querySelector('.btn-primary[name="users_with_affiliation"]');
         button.click();
         await u.waitUntil(() => !modal.loading_users_with_affiliation);
-        await u.waitUntil(() => modal.el.querySelectorAll('.list-group--users > li').length === 1);
+        await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 1);
 
-        const user_els = modal.el.querySelectorAll('.list-group--users > li');
+        const user_els = modal.querySelectorAll('.list-group--users > li');
         const toggle = user_els[0].querySelector('.list-group-item:nth-child(3n) .toggle-form');
         const form = user_els[0].querySelector('.list-group-item:nth-child(3n) .affiliation-form');
         expect(u.hasClass('hidden', form)).toBeTruthy();
@@ -422,19 +422,19 @@ describe("The groupchat moderator tool", function () {
         const view = _converse.chatboxviews.get(muc_jid);
         await u.waitUntil(() => (view.model.occupants.length === 3));
         const modal = await openModtools(_converse, view);
-        const tab = modal.el.querySelector('#affiliations-tab');
+        const tab = modal.querySelector('#affiliations-tab');
         // Clear so that we don't match older stanzas
         _converse.connection.IQ_stanzas = [];
         tab.click();
-        const show_affiliation_dropdown = modal.el.querySelector('.select-affiliation');
+        const show_affiliation_dropdown = modal.querySelector('.select-affiliation');
         show_affiliation_dropdown.value = 'member';
-        const button = modal.el.querySelector('.btn-primary[name="users_with_affiliation"]');
+        const button = modal.querySelector('.btn-primary[name="users_with_affiliation"]');
         button.click();
 
         await u.waitUntil(() => !modal.loading_users_with_affiliation);
-        await u.waitUntil(() => modal.el.querySelectorAll('.list-group--users > li').length === 2);
+        await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 2);
 
-        const user_els = modal.el.querySelectorAll('.list-group--users > li');
+        const user_els = modal.querySelectorAll('.list-group--users > li');
         let change_affiliation_dropdown = user_els[0].querySelector('.select-affiliation');
         expect(Array.from(change_affiliation_dropdown.options).map(o => o.value)).toEqual(['member', 'outcast', 'none']);
 

+ 34 - 34
src/plugins/muc-views/tests/muc-add-modal.js

@@ -12,32 +12,32 @@ describe('The "Groupchats" Add modal', function () {
             const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
             roomspanel.querySelector('.show-add-muc-modal').click();
             mock.closeControlBox(_converse);
-            const modal = _converse.api.modal.get('add-chatroom-modal');
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
+            const modal = _converse.api.modal.get('converse-add-muc-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000);
 
-            let label_name = modal.el.querySelector('label[for="chatroom"]');
+            let label_name = modal.querySelector('label[for="chatroom"]');
             expect(label_name.textContent.trim()).toBe('Groupchat address:');
-            let name_input = modal.el.querySelector('input[name="chatroom"]');
+            const name_input = modal.querySelector('input[name="chatroom"]');
             expect(name_input.placeholder).toBe('name@conference.example.org');
 
-            const label_nick = modal.el.querySelector('label[for="nickname"]');
+            const label_nick = modal.querySelector('label[for="nickname"]');
             expect(label_nick.textContent.trim()).toBe('Nickname:');
-            const nick_input = modal.el.querySelector('input[name="nickname"]');
+            const nick_input = modal.querySelector('input[name="nickname"]');
             expect(nick_input.value).toBe('');
             nick_input.value = 'romeo';
 
-            expect(modal.el.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
+            expect(modal.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
             spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
-            modal.el.querySelector('input[name="chatroom"]').value = 'lounge@muc.montague.lit';
-            modal.el.querySelector('form input[type="submit"]').click();
+            modal.querySelector('input[name="chatroom"]').value = 'lounge@muc.montague.lit';
+            modal.querySelector('form input[type="submit"]').click();
             await u.waitUntil(() => _converse.chatboxes.length);
             await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1);
 
             roomspanel.model.set('muc_domain', 'muc.example.org');
             roomspanel.querySelector('.show-add-muc-modal').click();
-            label_name = modal.el.querySelector('label[for="chatroom"]');
-            expect(label_name.textContent.trim()).toBe('Groupchat address:');
-            await u.waitUntil(() => modal.el.querySelector('input[name="chatroom"]')?.placeholder === 'name@muc.example.org');
+            label_name = modal.querySelector('label[for="chatroom"]');
+            expect(label_name.textContent.trim()).toBe('Groupchat name:');
+            await u.waitUntil(() => modal.querySelector('input[name="chatroom"]')?.placeholder === 'name@muc.example.org');
         })
     );
 
@@ -46,31 +46,31 @@ describe('The "Groupchats" Add modal', function () {
             await mock.openControlBox(_converse);
             const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
             roomspanel.querySelector('.show-add-muc-modal').click();
-            const modal = _converse.api.modal.get('add-chatroom-modal');
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
-            expect(modal.el.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
+            const modal = _converse.api.modal.get('converse-add-muc-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000);
+            expect(modal.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
             spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
-            const label_name = modal.el.querySelector('label[for="chatroom"]');
+            const label_name = modal.querySelector('label[for="chatroom"]');
             expect(label_name.textContent.trim()).toBe('Groupchat name:');
-            let name_input = modal.el.querySelector('input[name="chatroom"]');
+            let name_input = modal.querySelector('input[name="chatroom"]');
             expect(name_input.placeholder).toBe('name@muc.example.org');
             name_input.value = 'lounge';
-            let nick_input = modal.el.querySelector('input[name="nickname"]');
+            let nick_input = modal.querySelector('input[name="nickname"]');
             nick_input.value = 'max';
 
-            modal.el.querySelector('form input[type="submit"]').click();
+            modal.querySelector('form input[type="submit"]').click();
             await u.waitUntil(() => _converse.chatboxes.length);
             await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1);
             expect(_converse.chatboxes.models.map(m => m.get('id')).includes('lounge@muc.example.org')).toBe(true);
 
             // However, you can still open MUCs with different domains
             roomspanel.querySelector('.show-add-muc-modal').click();
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
-            name_input = modal.el.querySelector('input[name="chatroom"]');
+            await u.waitUntil(() => u.isVisible(modal), 1000);
+            name_input = modal.querySelector('input[name="chatroom"]');
             name_input.value = 'lounge@conference.example.org';
-            nick_input = modal.el.querySelector('input[name="nickname"]');
+            nick_input = modal.querySelector('input[name="nickname"]');
             nick_input.value = 'max';
-            modal.el.querySelector('form input[type="submit"]').click();
+            modal.querySelector('form input[type="submit"]').click();
             await u.waitUntil(() => _converse.chatboxes.models.filter(c => c.get('type') === 'chatroom').length === 2);
             await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 2);
             expect(_converse.chatboxes.models.map(m => m.get('id')).includes('lounge@conference.example.org')).toBe(
@@ -87,30 +87,30 @@ describe('The "Groupchats" Add modal', function () {
                 await mock.openControlBox(_converse);
                 const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
                 roomspanel.querySelector('.show-add-muc-modal').click();
-                const modal = _converse.api.modal.get('add-chatroom-modal');
-                await u.waitUntil(() => u.isVisible(modal.el), 1000);
-                expect(modal.el.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
+                const modal = _converse.api.modal.get('converse-add-muc-modal');
+                await u.waitUntil(() => u.isVisible(modal), 1000);
+                expect(modal.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
                 spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
-                const label_name = modal.el.querySelector('label[for="chatroom"]');
+                const label_name = modal.querySelector('label[for="chatroom"]');
                 expect(label_name.textContent.trim()).toBe('Groupchat name:');
-                let name_input = modal.el.querySelector('input[name="chatroom"]');
+                let name_input = modal.querySelector('input[name="chatroom"]');
                 expect(name_input.placeholder).toBe('');
                 name_input.value = 'lounge';
-                let nick_input = modal.el.querySelector('input[name="nickname"]');
+                let nick_input = modal.querySelector('input[name="nickname"]');
                 nick_input.value = 'max';
-                modal.el.querySelector('form input[type="submit"]').click();
+                modal.querySelector('form input[type="submit"]').click();
                 await u.waitUntil(() => _converse.chatboxes.length);
                 await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1);
                 expect(_converse.chatboxes.models.map(m => m.get('id')).includes('lounge@muc.example.org')).toBe(true);
 
                 // However, you can still open MUCs with different domains
                 roomspanel.querySelector('.show-add-muc-modal').click();
-                await u.waitUntil(() => u.isVisible(modal.el), 1000);
-                name_input = modal.el.querySelector('input[name="chatroom"]');
+                await u.waitUntil(() => u.isVisible(modal), 1000);
+                name_input = modal.querySelector('input[name="chatroom"]');
                 name_input.value = 'lounge@conference';
-                nick_input = modal.el.querySelector('input[name="nickname"]');
+                nick_input = modal.querySelector('input[name="nickname"]');
                 nick_input.value = 'max';
-                modal.el.querySelector('form input[type="submit"]').click();
+                modal.querySelector('form input[type="submit"]').click();
                 await u.waitUntil(
                     () => _converse.chatboxes.models.filter(c => c.get('type') === 'chatroom').length === 2
                 );

+ 16 - 16
src/plugins/muc-views/tests/muc-list-modal.js

@@ -10,17 +10,17 @@ describe('The "Groupchats" List modal', function () {
             const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
             roomspanel.querySelector('.show-list-muc-modal').click();
             mock.closeControlBox(_converse);
-            const modal = _converse.api.modal.get('muc-list-modal');
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
+            const modal = _converse.api.modal.get('converse-muc-list-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000);
             spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
 
             // See: https://xmpp.org/extensions/xep-0045.html#disco-rooms
-            expect(modal.el.querySelectorAll('.available-chatrooms li').length).toBe(0);
+            expect(modal.querySelectorAll('.available-chatrooms li').length).toBe(0);
 
-            const server_input = modal.el.querySelector('input[name="server"]');
+            const server_input = modal.querySelector('input[name="server"]');
             expect(server_input.placeholder).toBe('conference.example.org');
             server_input.value = 'chat.shakespeare.lit';
-            modal.el.querySelector('input[type="submit"]').click();
+            modal.querySelector('input[type="submit"]').click();
             await u.waitUntil(() => _converse.chatboxes.length);
 
             const IQ_stanzas = _converse.connection.IQ_stanzas;
@@ -55,8 +55,8 @@ describe('The "Groupchats" List modal', function () {
                 .c('item', { jid: 'street@chat.shakespeare.lit', name: 'A street' }).nodeTree;
             _converse.connection._dataRecv(mock.createRequest(iq));
 
-            await u.waitUntil(() => modal.el.querySelectorAll('.available-chatrooms li').length === 11);
-            const rooms = modal.el.querySelectorAll('.available-chatrooms li');
+            await u.waitUntil(() => modal.querySelectorAll('.available-chatrooms li').length === 11);
+            const rooms = modal.querySelectorAll('.available-chatrooms li');
             expect(rooms[0].textContent.trim()).toBe('Groupchats found');
             expect(rooms[1].textContent.trim()).toBe('A Lonely Heath');
             expect(rooms[2].textContent.trim()).toBe('A Dark Cave');
@@ -83,9 +83,9 @@ describe('The "Groupchats" List modal', function () {
             const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
             roomspanel.querySelector('.show-list-muc-modal').click();
             mock.closeControlBox(_converse);
-            const modal = _converse.api.modal.get('muc-list-modal');
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
-            const server_input = modal.el.querySelector('input[name="server"]');
+            const modal = _converse.api.modal.get('converse-muc-list-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000);
+            const server_input = modal.querySelector('input[name="server"]');
             expect(server_input.value).toBe('muc.example.org');
         })
     );
@@ -99,12 +99,12 @@ describe('The "Groupchats" List modal', function () {
                 const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
                 roomspanel.querySelector('.show-list-muc-modal').click();
                 mock.closeControlBox(_converse);
-                const modal = _converse.api.modal.get('muc-list-modal');
-                await u.waitUntil(() => u.isVisible(modal.el), 1000);
+                const modal = _converse.api.modal.get('converse-muc-list-modal');
+                await u.waitUntil(() => u.isVisible(modal), 1000);
                 spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
 
-                expect(modal.el.querySelector('input[name="server"]')).toBe(null);
-                expect(modal.el.querySelector('input[type="submit"]')).toBe(null);
+                expect(modal.querySelector('input[name="server"]')).toBe(null);
+                expect(modal.querySelector('input[type="submit"]')).toBe(null);
                 await u.waitUntil(() => _converse.chatboxes.length);
                 const sent_stanza = await u.waitUntil(() =>
                     _converse.connection.sent_stanzas
@@ -129,8 +129,8 @@ describe('The "Groupchats" List modal', function () {
                     .c('item', { jid: 'forres@chat.shakespeare.lit', name: 'The Palace' }).up();
                 _converse.connection._dataRecv(mock.createRequest(iq));
 
-                await u.waitUntil(() => modal.el.querySelectorAll('.available-chatrooms li').length === 4);
-                const rooms = modal.el.querySelectorAll('.available-chatrooms li');
+                await u.waitUntil(() => modal.querySelectorAll('.available-chatrooms li').length === 4);
+                const rooms = modal.querySelectorAll('.available-chatrooms li');
                 expect(rooms[0].textContent.trim()).toBe('Groupchats found');
                 expect(rooms[1].textContent.trim()).toBe('A Lonely Heath');
                 expect(rooms[2].textContent.trim()).toBe('A Dark Cave');

+ 18 - 18
src/plugins/muc-views/tests/muc.js

@@ -1332,21 +1332,21 @@ describe("Groupchats", function () {
             await u.waitUntil(() => view.querySelector('.open-invite-modal'));
 
             view.querySelector('.open-invite-modal').click();
-            const modal = _converse.api.modal.get('muc-invite-modal');
-            await u.waitUntil(() => u.isVisible(modal.el), 1000)
+            const modal = _converse.api.modal.get('converse-muc-invite-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000)
 
-            expect(modal.el.querySelectorAll('#invitee_jids').length).toBe(1);
-            expect(modal.el.querySelectorAll('textarea').length).toBe(1);
+            expect(modal.querySelectorAll('#invitee_jids').length).toBe(1);
+            expect(modal.querySelectorAll('textarea').length).toBe(1);
 
             spyOn(view.model, 'directInvite').and.callThrough();
 
-            const input = modal.el.querySelector('#invitee_jids');
+            const input = modal.querySelector('#invitee_jids input');
             input.value = "Balt";
-            modal.el.querySelector('button[type="submit"]').click();
+            modal.querySelector('input[type="submit"]').click();
 
-            await u.waitUntil(() => modal.el.querySelector('.error'));
+            await u.waitUntil(() => modal.querySelector('.error'));
 
-            const error = modal.el.querySelector('.error');
+            const error = modal.querySelector('.error');
             expect(error.textContent).toBe('Please enter a valid XMPP address');
 
             let evt = new Event('input');
@@ -1354,7 +1354,7 @@ describe("Groupchats", function () {
 
             let sent_stanza;
             spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza));
-            const hint = await u.waitUntil(() => modal.el.querySelector('.suggestion-box__results li'));
+            const hint = await u.waitUntil(() => modal.querySelector('.suggestion-box__results li'));
             expect(input.value).toBe('Balt');
             expect(hint.textContent.trim()).toBe('Balthasar');
 
@@ -1362,9 +1362,9 @@ describe("Groupchats", function () {
             evt.button = 0;
             hint.dispatchEvent(evt);
 
-            const textarea = modal.el.querySelector('textarea');
+            const textarea = modal.querySelector('textarea');
             textarea.value = "Please join!";
-            modal.el.querySelector('button[type="submit"]').click();
+            modal.querySelector('input[type="submit"]').click();
 
             expect(view.model.directInvite).toHaveBeenCalled();
             expect(Strophe.serialize(sent_stanza)).toBe(
@@ -1634,10 +1634,10 @@ describe("Groupchats", function () {
 
             const info_el = view.querySelector(".show-muc-details-modal");
             info_el.click();
-            let modal = _converse.api.modal.get('muc-details-modal');
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
+            let modal = _converse.api.modal.get('converse-muc-details-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000);
 
-            let features_list = modal.el.querySelector('.features-list');
+            let features_list = modal.querySelector('.features-list');
             let features_shown = features_list.textContent.split('\n').map(s => s.trim()).filter(s => s);
 
             expect(features_shown.join(' ')).toBe(
@@ -1661,7 +1661,7 @@ describe("Groupchats", function () {
             expect(view.model.features.get('unsecured')).toBe(false);
             await u.waitUntil(() => view.querySelector('.chatbox-title__text').textContent.trim() === 'Room');
 
-            modal.el.querySelector('.close').click();
+            modal.querySelector('.close').click();
             view.querySelector('.configure-chatroom-button').click();
 
             const IQs = _converse.connection.IQ_stanzas;
@@ -1792,10 +1792,10 @@ describe("Groupchats", function () {
             await u.waitUntil(() => new Promise(success => view.model.features.on('change', success)));
 
             info_el.click();
-            modal = _converse.api.modal.get('muc-details-modal');
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
+            modal = _converse.api.modal.get('converse-muc-details-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000);
 
-            features_list = modal.el.querySelector('.features-list');
+            features_list = modal.querySelector('.features-list');
             features_shown = features_list.textContent.split('\n').map(s => s.trim()).filter(s => s);
             expect(features_shown.join(' ')).toBe(
                 'Password protected - This groupchat requires a password before entry '+

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

@@ -19,17 +19,17 @@ describe("A MUC", function () {
         const dropdown_item = view.querySelector(".open-nickname-modal");
         dropdown_item.click();
 
-        const modal = _converse.api.modal.get('change-nickname-modal');
-        await u.waitUntil(() => u.isVisible(modal.el));
+        const modal = _converse.api.modal.get('converse-muc-nickname-modal');
+        await u.waitUntil(() => u.isVisible(modal));
 
-        const input = modal.el.querySelector('input[name="nick"]');
+        const input = modal.querySelector('input[name="nick"]');
         expect(input.value).toBe(nick);
 
         const newnick = 'loverboy';
         input.value = newnick;
-        modal.el.querySelector('input[type="submit"]')?.click();
+        modal.querySelector('input[type="submit"]')?.click();
 
-        await u.waitUntil(() => !u.isVisible(modal.el));
+        await u.waitUntil(() => !u.isVisible(modal));
 
         const { sent_stanzas } = _converse.connection;
         const sent_stanza = sent_stanzas.pop()
@@ -422,13 +422,13 @@ describe("A MUC", function () {
             const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
             roomspanel.querySelector('.show-add-muc-modal').click();
             mock.closeControlBox(_converse);
-            const modal = _converse.api.modal.get('add-chatroom-modal');
-            await u.waitUntil(() => u.isVisible(modal.el), 1000)
-            const name_input = modal.el.querySelector('input[name="chatroom"]');
+            const modal = _converse.api.modal.get('converse-add-muc-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000)
+            const name_input = modal.querySelector('input[name="chatroom"]');
             name_input.value = 'lounge@montague.lit';
-            expect(modal.el.querySelector('label[for="nickname"]')).toBe(null);
-            expect(modal.el.querySelector('input[name="nickname"]')).toBe(null);
-            modal.el.querySelector('form input[type="submit"]').click();
+            expect(modal.querySelector('label[for="nickname"]')).toBe(null);
+            expect(modal.querySelector('input[name="nickname"]')).toBe(null);
+            modal.querySelector('form input[type="submit"]').click();
             await u.waitUntil(() => _converse.chatboxes.length > 1);
             const chatroom = _converse.chatboxes.get('lounge@montague.lit');
             expect(chatroom.get('nick')).toBe('romeo');
@@ -442,11 +442,11 @@ describe("A MUC", function () {
             const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
             roomspanel.querySelector('.show-add-muc-modal').click();
             mock.closeControlBox(_converse);
-            const modal = _converse.api.modal.get('add-chatroom-modal');
-            await u.waitUntil(() => u.isVisible(modal.el), 1000)
-            const label_nick = modal.el.querySelector('label[for="nickname"]');
+            const modal = _converse.api.modal.get('converse-add-muc-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000)
+            const label_nick = modal.querySelector('label[for="nickname"]');
             expect(label_nick.textContent.trim()).toBe('Nickname:');
-            const nick_input = modal.el.querySelector('input[name="nickname"]');
+            const nick_input = modal.querySelector('input[name="nickname"]');
             expect(nick_input.value).toBe('romeo');
         }));
 
@@ -458,11 +458,11 @@ describe("A MUC", function () {
             const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list');
             roomspanel.querySelector('.show-add-muc-modal').click();
             mock.closeControlBox(_converse);
-            const modal = _converse.api.modal.get('add-chatroom-modal');
-            await u.waitUntil(() => u.isVisible(modal.el), 1000)
-            const label_nick = modal.el.querySelector('label[for="nickname"]');
+            const modal = _converse.api.modal.get('converse-add-muc-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000)
+            const label_nick = modal.querySelector('label[for="nickname"]');
             expect(label_nick.textContent.trim()).toBe('Nickname:');
-            const nick_input = modal.el.querySelector('input[name="nickname"]');
+            const nick_input = modal.querySelector('input[name="nickname"]');
             expect(nick_input.value).toBe('st.nick');
         }));
     });

+ 5 - 5
src/plugins/muc-views/utils.js

@@ -1,5 +1,5 @@
-import ModeratorToolsModal from './modals/moderator-tools.js';
-import OccupantModal from './modals/occupant.js';
+import './modals/occupant.js';
+import './modals/moderator-tools.js';
 import log from "@converse/headless/log";
 import tpl_spinner from 'templates/spinner.js';
 import { __ } from 'i18n';
@@ -234,19 +234,19 @@ export function showModeratorToolsModal (muc, affiliation) {
     if (!muc.verifyRoles(['moderator'])) {
         return;
     }
-    let modal = api.modal.get(ModeratorToolsModal.id);
+    let modal = api.modal.get('converse-modtools-modal');
     if (modal) {
         modal.affiliation = affiliation;
         modal.render();
     } else {
-        modal = api.modal.create(ModeratorToolsModal, { affiliation, 'jid': muc.get('jid') });
+        modal = api.modal.create('converse-modtools-modal', { affiliation, 'jid': muc.get('jid') });
     }
     modal.show();
 }
 
 
 export function showOccupantModal (ev, occupant) {
-    api.modal.show(OccupantModal, { 'model': occupant }, ev);
+    api.modal.show('converse-muc-occupant-modal', { 'model': occupant }, ev);
 }
 
 

+ 1 - 1
src/plugins/omemo/index.js

@@ -4,7 +4,7 @@
  */
 import './fingerprints.js';
 import './profile.js';
-import 'modals/user-details.js';
+import 'shared/modals/user-details.js';
 import 'plugins/profile/index.js';
 import ConverseMixins from './mixins/converse.js';
 import Device from './device.js';

+ 3 - 3
src/plugins/omemo/templates/profile.js

@@ -11,7 +11,7 @@ const fingerprint = (el) => html`
 const device_with_fingerprint = (el) => {
     const i18n_fingerprint_checkbox_label = __('Checkbox for selecting the following fingerprint');
     return html`
-        <li class="fingerprint-removal-item list-group-item nopadding">
+        <li class="fingerprint-removal-item list-group-item">
             <label>
             <input type="checkbox" value="${el.device.get('id')}"
                 aria-label="${i18n_fingerprint_checkbox_label}"/>
@@ -26,7 +26,7 @@ const device_without_fingerprint = (el) => {
     const i18n_device_without_fingerprint = __('Device without a fingerprint');
     const i18n_fingerprint_checkbox_label = __('Checkbox for selecting the following device');
     return html`
-        <li class="fingerprint-removal-item list-group-item nopadding">
+        <li class="fingerprint-removal-item list-group-item">
             <label>
             <input type="checkbox" value="${el.device.get('id')}"
                 aria-label="${i18n_fingerprint_checkbox_label}"/>
@@ -49,7 +49,7 @@ const device_list = (el) => {
     const i18n_select_all = __('Select all');
     return html`
         <ul class="list-group fingerprints">
-            <li class="list-group-item nopadding active">
+            <li class="list-group-item active">
                 <label>
                     <input type="checkbox" class="select-all" @change=${el.selectAll} title="${i18n_select_all}" aria-label="${i18n_other_devices_label}"/>
                     ${i18n_other_devices}

+ 9 - 9
src/plugins/omemo/tests/omemo.js

@@ -1047,8 +1047,8 @@ describe("The OMEMO module", function() {
         const view = _converse.chatboxviews.get(contact_jid);
         const show_modal_button = view.querySelector('.show-user-details-modal');
         show_modal_button.click();
-        const modal = _converse.api.modal.get('user-details-modal');
-        await u.waitUntil(() => u.isVisible(modal.el), 1000);
+        const modal = _converse.api.modal.get('converse-user-details-modal');
+        await u.waitUntil(() => u.isVisible(modal), 1000);
 
         let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid));
         expect(Strophe.serialize(iq_stanza)).toBe(
@@ -1068,7 +1068,7 @@ describe("The OMEMO module", function() {
                         .c('device', {'id': '555'})
         ));
 
-        await u.waitUntil(() => u.isVisible(modal.el), 1000);
+        await u.waitUntil(() => u.isVisible(modal), 1000);
 
         iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555'));
         expect(Strophe.serialize(iq_stanza)).toBe(
@@ -1097,21 +1097,21 @@ describe("The OMEMO module", function() {
                             .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003'))
         ));
 
-        await u.waitUntil(() => modal.el.querySelectorAll('.fingerprints .fingerprint').length);
-        expect(modal.el.querySelectorAll('.fingerprints .fingerprint').length).toBe(1);
-        const el = modal.el.querySelector('.fingerprints .fingerprint');
+        await u.waitUntil(() => modal.querySelectorAll('.fingerprints .fingerprint').length);
+        expect(modal.querySelectorAll('.fingerprints .fingerprint').length).toBe(1);
+        const el = modal.querySelector('.fingerprints .fingerprint');
         expect(el.textContent.trim()).toBe(
             omemo.formatFingerprint(u.arrayBufferToHex(u.base64ToArrayBuffer('BQmHEOHjsYm3w5M8VqxAtqJmLCi7CaxxsdZz6G0YpuMI')))
         );
-        expect(modal.el.querySelectorAll('input[type="radio"]').length).toBe(2);
+        expect(modal.querySelectorAll('input[type="radio"]').length).toBe(2);
 
         const devicelist = _converse.devicelists.get(contact_jid);
         expect(devicelist.devices.get('555').get('trusted')).toBe(0);
 
-        let trusted_radio = modal.el.querySelector('input[type="radio"][name="555"][value="1"]');
+        let trusted_radio = modal.querySelector('input[type="radio"][name="555"][value="1"]');
         expect(trusted_radio.checked).toBe(true);
 
-        let untrusted_radio = modal.el.querySelector('input[type="radio"][name="555"][value="-1"]');
+        let untrusted_radio = modal.querySelector('input[type="radio"][name="555"][value="-1"]');
         expect(untrusted_radio.checked).toBe(false);
 
         // Test that the device can be set to untrusted

+ 3 - 2
src/plugins/profile/index.js

@@ -3,11 +3,12 @@
  * @license Mozilla Public License (MPLv2)
  */
 import '../modal/index.js';
+import './modals/chat-status.js';
+import './modals/profile.js';
+import './modals/user-settings.js';
 import './statusview.js';
 import '@converse/headless/plugins/status';
 import '@converse/headless/plugins/vcard';
-import './modals/chat-status.js';
-import './modals/profile.js';
 import { api, converse } from '@converse/headless/core';
 
 

+ 22 - 37
src/plugins/profile/modals/chat-status.js

@@ -1,51 +1,37 @@
-import BootstrapModal from "plugins/modal/base.js";
+import BaseModal from "plugins/modal/modal.js";
 import tpl_chat_status_modal from "../templates/chat-status-modal.js";
 import { __ } from 'i18n';
-import { _converse, converse } from "@converse/headless/core";
+import { _converse, api, converse } from "@converse/headless/core";
 
 const u = converse.env.utils;
 
 
-const ChatStatusModal = BootstrapModal.extend({
-    id: "modal-status-change",
-    events: {
-        "submit form#set-xmpp-status": "onFormSubmitted",
-        "click .clear-input": "clearStatusMessage"
-    },
+export default class ChatStatusModal extends BaseModal {
 
-    toHTML () {
-        return tpl_chat_status_modal(
-            Object.assign(
-                this.model.toJSON(),
-                this.model.vcard.toJSON(), {
-                'label_away': __('Away'),
-                'label_busy': __('Busy'),
-                'label_cancel': __('Cancel'),
-                'label_close': __('Close'),
-                'label_custom_status': __('Custom status'),
-                'label_offline': __('Offline'),
-                'label_online': __('Online'),
-                'label_save': __('Save'),
-                'label_xa': __('Away for long'),
-                'modal_title': __('Change chat status'),
-                'placeholder_status_message': __('Personal status message')
-            }));
-    },
-
-    afterRender () {
-        this.el.addEventListener('shown.bs.modal', () => {
-            this.el.querySelector('input[name="status_message"]').focus();
+    initialize () {
+        super.initialize();
+        this.render();
+        this.addEventListener('shown.bs.modal', () => {
+            this.querySelector('input[name="status_message"]').focus();
         }, false);
-    },
+    }
+
+    renderModal () {
+        return tpl_chat_status_modal(this);
+    }
+
+    getModalTitle () { // eslint-disable-line class-methods-use-this
+        return __('Change chat status');
+    }
 
     clearStatusMessage (ev) {
         if (ev && ev.preventDefault) {
             ev.preventDefault();
-            u.hideElement(this.el.querySelector('.clear-input'));
+            u.hideElement(this.querySelector('.clear-input'));
         }
-        const roster_filter = this.el.querySelector('input[name="status_message"]');
+        const roster_filter = this.querySelector('input[name="status_message"]');
         roster_filter.value = '';
-    },
+    }
 
     onFormSubmitted (ev) {
         ev.preventDefault();
@@ -56,9 +42,8 @@ const ChatStatusModal = BootstrapModal.extend({
         });
         this.modal.hide();
     }
-});
-
+}
 
 _converse.ChatStatusModal = ChatStatusModal;
 
-export default ChatStatusModal;
+api.elements.define('converse-chat-status-modal', ChatStatusModal);

+ 29 - 40
src/plugins/profile/modals/profile.js

@@ -1,52 +1,43 @@
-import BootstrapModal from "plugins/modal/base.js";
-import bootstrap from "bootstrap.native";
+import BaseModal from "plugins/modal/modal.js";
 import log from "@converse/headless/log";
 import tpl_profile_modal from "../templates/profile_modal.js";
 import Compress from 'client-compress';
 import { __ } from 'i18n';
-import { _converse, api, converse } from "@converse/headless/core";
+import { _converse, api } from "@converse/headless/core";
 
-const { sizzle } = converse.env;
-
-const options = {
-  targetSize: 0.1,
-  quality: 0.75,
-  maxWidth: 256,
-  maxHeight: 256
-}
-
-const compress = new Compress(options)
+const compress = new Compress({
+    targetSize: 0.1,
+    quality: 0.75,
+    maxWidth: 256,
+    maxHeight: 256
+});
 
+export default class ProfileModal extends BaseModal {
 
-const ProfileModal = BootstrapModal.extend({
-    id: "user-profile-modal",
-    events: {
-        'submit .profile-form': 'onFormSubmitted'
-    },
+    constructor (options) {
+        super(options);
+        this.tab = 'profile';
+    }
 
     initialize () {
+        super.initialize();
         this.listenTo(this.model, 'change', this.render);
-        BootstrapModal.prototype.initialize.apply(this, arguments);
         /**
-            * Triggered when the _converse.ProfileModal has been created and initialized.
-            * @event _converse#profileModalInitialized
-            * @type { _converse.XMPPStatus }
-            * @example _converse.api.listen.on('profileModalInitialized', status => { ... });
-            */
+         * Triggered when the _converse.ProfileModal has been created and initialized.
+         * @event _converse#profileModalInitialized
+         * @type { _converse.XMPPStatus }
+         * @example _converse.api.listen.on('profileModalInitialized', status => { ... });
+         */
         api.trigger('profileModalInitialized', this.model);
-    },
+    }
 
-    toHTML () {
-        return tpl_profile_modal(Object.assign(
-            this.model.toJSON(),
-            this.model.vcard.toJSON(),
-            { 'view': this }
-        ));
-    },
+    renderModal () {
+        return tpl_profile_modal(this);
+    }
 
-    afterRender () {
-        this.tabs = sizzle('.nav-item .nav-link', this.el).map(e => new bootstrap.Tab(e));
-    },
+    getModalTitle () { // eslint-disable-line class-methods-use-this
+        return __('Your Profile');
+    }
 
     async setVCard (data) {
         try {
@@ -60,7 +51,7 @@ const ProfileModal = BootstrapModal.extend({
             return;
         }
         this.modal.hide();
-    },
+    }
 
     onFormSubmitted (ev) {
         ev.preventDefault();
@@ -95,8 +86,6 @@ const ProfileModal = BootstrapModal.extend({
             });
         }
     }
-});
-
-_converse.ProfileModal = ProfileModal;
+}
 
-export default ProfileModal;
+api.elements.define('converse-profile-modal', ProfileModal);

+ 38 - 0
src/plugins/profile/modals/styles/profile.scss

@@ -0,0 +1,38 @@
+converse-profile-modal {
+    .profile-form {
+        label {
+            font-weight: bold;
+        }
+    }
+
+    .fingerprint-removal {
+        label {
+            display: flex;
+            padding: 0.75rem 1.25rem;
+        }
+    }
+
+    .list-group-item {
+        display: flex;
+        justify-content: left;
+        font-size: 95%;
+
+        input[type="checkbox"] {
+            margin-right: 1em;
+        }
+    }
+
+    .fingerprints {
+        width: 100%;
+        margin-bottom: 1em;
+    }
+
+    .fingerprint-trust {
+        display: flex;
+        justify-content: space-between;
+        font-size: 95%;
+        .fingerprint {
+            margin-left: 1em;
+        }
+    }
+}

+ 40 - 35
src/plugins/profile/modals/templates/user-settings.js

@@ -1,29 +1,41 @@
 import DOMPurify from 'dompurify';
 import { __ } from 'i18n';
-import { api } from "@converse/headless/core";
+import { _converse, api } from "@converse/headless/core.js";
 import { html } from "lit";
-import { modal_header_close_button } from "plugins/modal/templates/buttons.js"
 import { unsafeHTML } from 'lit/directives/unsafe-html.js';
 
 
-const tpl_navigation = (o) => {
+const tpl_navigation = (el) => {
     const i18n_about = __('About');
     const i18n_commands = __('Commands');
     return html`
         <ul class="nav nav-pills justify-content-center">
             <li role="presentation" class="nav-item">
-                <a class="nav-link active" id="about-tab" href="#about-tabpanel" aria-controls="about-tabpanel" role="tab" data-toggle="tab" @click=${o.switchTab}>${i18n_about}</a>
+                <a class="nav-link ${el.tab === "about" ? "active" : ""}"
+                   id="about-tab"
+                   href="#about-tabpanel"
+                   aria-controls="about-tabpanel"
+                   role="tab"
+                   data-toggle="tab"
+                   data-name="about"
+                   @click=${ev => el.switchTab(ev)}>${i18n_about}</a>
             </li>
             <li role="presentation" class="nav-item">
-                <a class="nav-link" id="commands-tab" href="#commands-tabpanel" aria-controls="commands-tabpanel" role="tab" data-toggle="tab" @click=${o.switchTab}>${i18n_commands}</a>
+                <a class="nav-link ${el.tab === "commands" ? "active" : ""}"
+                   id="commands-tab"
+                   href="#commands-tabpanel"
+                   aria-controls="commands-tabpanel"
+                   role="tab"
+                   data-toggle="tab"
+                   data-name="commands"
+                   @click=${ev => el.switchTab(ev)}>${i18n_commands}</a>
             </li>
         </ul>
     `;
 }
 
 
-export default (o) => {
-    const i18n_modal_title = __('Settings');
+export default (el) => {
     const first_subtitle = __(
         '%1$s Open Source %2$s XMPP chat client brought to you by %3$s Opkode %2$s',
         '<a target="_blank" rel="nofollow" href="https://conversejs.org">',
@@ -39,38 +51,31 @@ export default (o) => {
     const show_client_info = api.settings.get('show_client_info');
     const allow_adhoc_commands = api.settings.get('allow_adhoc_commands');
     const show_both_tabs = show_client_info && allow_adhoc_commands;
+
     return html`
-    <div class="modal-dialog" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title" id="converse-modtools-modal-label">${i18n_modal_title}</h5>
-                ${modal_header_close_button}
-            </div>
-            <div class="modal-body">
-                ${ show_both_tabs ? tpl_navigation(o) : '' }
+        ${ show_both_tabs ? tpl_navigation(el) : '' }
 
-                <div class="tab-content">
-                    <div class="tab-pane tab-pane--columns ${show_client_info ? 'active' : ''}"
-                         id="about-tabpanel" role="tabpanel" aria-labelledby="about-tab">
+        <div class="tab-content">
+            ${ show_client_info ? html`
+                <div class="tab-pane tab-pane--columns ${ el.tab === 'about' ? 'active' : ''}"
+                        id="about-tabpanel" role="tabpanel" aria-labelledby="about-tab">
 
-                        <span class="modal-alert"></span>
-                        <br/>
-                        <div class="container">
-                            <h6 class="brand-heading">Converse</h6>
-                            <p class="brand-subtitle">${o.version_name}</p>
-                            <p class="brand-subtitle">${unsafeHTML(DOMPurify.sanitize(first_subtitle))}</p>
-                            <p class="brand-subtitle">${unsafeHTML(DOMPurify.sanitize(second_subtitle))}</p>
-                        </div>
+                    <span class="modal-alert"></span>
+                    <br/>
+                    <div class="container">
+                        <h6 class="brand-heading">Converse</h6>
+                        <p class="brand-subtitle">${_converse.VERSION_NAME}</p>
+                        <p class="brand-subtitle">${unsafeHTML(DOMPurify.sanitize(first_subtitle))}</p>
+                        <p class="brand-subtitle">${unsafeHTML(DOMPurify.sanitize(second_subtitle))}</p>
                     </div>
+                </div>` : '' }
 
-                    <div class="tab-pane tab-pane--columns ${!show_client_info  && allow_adhoc_commands ? 'active' : ''}"
-                         id="commands-tabpanel"
-                         role="tabpanel"
-                         aria-labelledby="commands-tab">
-                        <converse-adhoc-commands/>
-                    </div>
-                </div>
-            </div>
+            ${ allow_adhoc_commands ? html`
+                <div class="tab-pane tab-pane--columns ${ el.tab === 'commands' ? 'active' : ''}"
+                        id="commands-tabpanel"
+                        role="tabpanel"
+                        aria-labelledby="commands-tab">
+                    <converse-adhoc-commands/>
+                </div> ` : '' }
         </div>
-    </div>
 `};

+ 25 - 17
src/plugins/profile/modals/user-settings.js

@@ -1,23 +1,31 @@
-import BootstrapModal from "plugins/modal/base.js";
+import BaseModal from "plugins/modal/modal.js";
 import tpl_user_settings_modal from "./templates/user-settings.js";
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core";
 
-let _converse;
+export default class UserSettingsModal extends BaseModal {
 
-export default BootstrapModal.extend({
-    id: "converse-client-info-modal",
+    constructor (options) {
+        super(options);
 
-    initialize (settings) {
-        _converse  = settings._converse;
-        BootstrapModal.prototype.initialize.apply(this, arguments);
-    },
+        const show_client_info = api.settings.get('show_client_info');
+        const allow_adhoc_commands = api.settings.get('allow_adhoc_commands');
+        const show_both_tabs = show_client_info && allow_adhoc_commands;
 
-    toHTML () {
-        return tpl_user_settings_modal(
-            Object.assign(
-                this.model.toJSON(),
-                this.model.vcard.toJSON(),
-                { 'version_name': _converse.VERSION_NAME }
-            )
-        );
+        if (show_both_tabs || show_client_info) {
+            this.tab = 'about';
+        } else if (allow_adhoc_commands) {
+            this.tab = 'commands';
+        }
     }
-});
+
+    renderModal () {
+        return tpl_user_settings_modal(this);
+    }
+
+    getModalTitle () { // eslint-disable-line class-methods-use-this
+        return __('Settings');
+    }
+}
+
+api.elements.define('converse-user-settings-modal', UserSettingsModal);

+ 4 - 6
src/plugins/profile/statusview.js

@@ -1,10 +1,8 @@
-import UserSettingsModal from './modals/user-settings';
 import tpl_profile from './templates/profile.js';
 import { CustomElement } from 'shared/components/element.js';
 import { _converse, api } from '@converse/headless/core';
 
 class Profile extends CustomElement {
-
     initialize () {
         this.model = _converse.xmppstatus;
         this.listenTo(this.model, "change", () => this.requestUpdate());
@@ -18,17 +16,17 @@ class Profile extends CustomElement {
 
     showProfileModal (ev) {
         ev?.preventDefault();
-        api.modal.show(_converse.ProfileModal, {model: this.model}, ev);
+        api.modal.show('converse-profile-modal', { model: this.model }, ev);
     }
 
     showStatusChangeModal (ev) {
         ev?.preventDefault();
-        api.modal.show(_converse.ChatStatusModal, {model: this.model}, ev);
+        api.modal.show('converse-chat-status-modal', { model: this.model }, ev);
     }
 
-    showUserSettingsModal(ev) {
+    showUserSettingsModal (ev) {
         ev?.preventDefault();
-        api.modal.show(UserSettingsModal, {model: this.model, _converse}, ev);
+        api.modal.show('converse-user-settings-modal', { model: this.model, _converse }, ev);
     }
 }
 

+ 46 - 47
src/plugins/profile/templates/chat-status-modal.js

@@ -1,53 +1,52 @@
 import { html } from "lit";
-import { modal_header_close_button } from "plugins/modal/templates/buttons.js";
+import { __ } from 'i18n';
 
 
-export default (o) => html`
-    <div class="modal-dialog" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title" id="changeStatusModalLabel">${o.modal_title}</h5>
-                ${modal_header_close_button}
+export default (el) => {
+    const label_away = __('Away');
+    const label_busy = __('Busy');
+    const label_online = __('Online');
+    const label_save = __('Save');
+    const label_xa = __('Away for long');
+    const placeholder_status_message = __('Personal status message');
+    const status = el.model.get('status');
+    const status_message = el.model.get('status_message');
+
+    return html`
+    <form class="converse-form set-xmpp-status" id="set-xmpp-status" @submit=${ev => el.onFormSubmitted(ev)}>
+        <div class="form-group">
+            <div class="custom-control custom-radio">
+                <input ?checked=${status === 'online'}
+                    type="radio" id="radio-online" value="online" name="chat_status" class="custom-control-input"/>
+                <label class="custom-control-label" for="radio-online">
+                    <converse-icon size="1em" class="fa fa-circle chat-status chat-status--online"></converse-icon>${label_online}</label>
+            </div>
+            <div class="custom-control custom-radio">
+                <input ?checked=${status === 'busy'}
+                    type="radio" id="radio-busy" value="dnd" name="chat_status" class="custom-control-input"/>
+                <label class="custom-control-label" for="radio-busy">
+                    <converse-icon size="1em" class="fa fa-minus-circle  chat-status chat-status--busy"></converse-icon>${label_busy}</label>
+            </div>
+            <div class="custom-control custom-radio">
+                <input ?checked=${status === 'away'}
+                    type="radio" id="radio-away" value="away" name="chat_status" class="custom-control-input"/>
+                <label class="custom-control-label" for="radio-away">
+                    <converse-icon size="1em" class="fa fa-circle chat-status chat-status--away"></converse-icon>${label_away}</label>
             </div>
-            <div class="modal-body">
-                <span class="modal-alert"></span>
-                <form class="converse-form set-xmpp-status" id="set-xmpp-status">
-                    <div class="form-group">
-                        <div class="custom-control custom-radio">
-                            <input ?checked=${o.status === 'online'}
-                                type="radio" id="radio-online" value="online" name="chat_status" class="custom-control-input"/>
-                            <label class="custom-control-label" for="radio-online">
-                                <converse-icon size="1em" class="fa fa-circle chat-status chat-status--online"></converse-icon>${o.label_online}</label>
-                        </div>
-                        <div class="custom-control custom-radio">
-                            <input ?checked=${o.status === 'busy'}
-                                type="radio" id="radio-busy" value="dnd" name="chat_status" class="custom-control-input"/>
-                            <label class="custom-control-label" for="radio-busy">
-                                <converse-icon size="1em" class="fa fa-minus-circle  chat-status chat-status--busy"></converse-icon>${o.label_busy}</label>
-                        </div>
-                        <div class="custom-control custom-radio">
-                            <input ?checked=${o.status === 'away'}
-                                type="radio" id="radio-away" value="away" name="chat_status" class="custom-control-input"/>
-                            <label class="custom-control-label" for="radio-away">
-                                <converse-icon size="1em" class="fa fa-circle chat-status chat-status--away"></converse-icon>${o.label_away}</label>
-                        </div>
-                        <div class="custom-control custom-radio">
-                            <input ?checked=${o.status === 'xa'}
-                                type="radio" id="radio-xa" value="xa" name="chat_status" class="custom-control-input"/>
-                            <label class="custom-control-label" for="radio-xa">
-                                <converse-icon size="1em" class="far fa-circle chat-status chat-status--xa"></converse-icon>${o.label_xa}</label>
-                        </div>
-                    </div>
-                    <div class="form-group">
-                        <div class="btn-group w-100">
-                            <input name="status_message" type="text" class="form-control"
-                                value="${o.status_message || ''}" placeholder="${o.placeholder_status_message}"/>
-                            <converse-icon size="1em" class="fa fa-times clear-input ${o.status_message ? '' : 'hidden'}"></converse-icon>
-                        </div>
-                    </div>
-                    <button type="submit" class="btn btn-primary">${o.label_save}</button>
-                </form>
+            <div class="custom-control custom-radio">
+                <input ?checked=${status === 'xa'}
+                    type="radio" id="radio-xa" value="xa" name="chat_status" class="custom-control-input"/>
+                <label class="custom-control-label" for="radio-xa">
+                    <converse-icon size="1em" class="far fa-circle chat-status chat-status--xa"></converse-icon>${label_xa}</label>
+            </div>
+        </div>
+        <div class="form-group">
+            <div class="btn-group w-100">
+                <input name="status_message" type="text" class="form-control" autofocus
+                    value="${status_message || ''}" placeholder="${placeholder_status_message}"/>
+                <converse-icon size="1em" class="fa fa-times clear-input ${status_message ? '' : 'hidden'}" @click=${ev => el.clearStatusMessage(ev)}></converse-icon>
             </div>
         </div>
-    </div>
-`;
+        <button type="submit" class="btn btn-primary">${label_save}</button>
+    </form>`;
+}

+ 69 - 68
src/plugins/profile/templates/profile_modal.js

@@ -2,16 +2,40 @@ import "shared/components/image-picker.js";
 import { __ } from 'i18n';
 import { _converse } from  "@converse/headless/core";
 import { html } from "lit";
-import { modal_header_close_button } from "plugins/modal/templates/buttons.js";
 
-const omemo_page = () => html`
-    <div class="tab-pane" id="omemo-tabpanel" role="tabpanel" aria-labelledby="omemo-tab">
+const omemo_page = (el) => html`
+    <div class="tab-pane ${ el.tab === 'omemo' ? 'active' : ''}" id="omemo-tabpanel" role="tabpanel" aria-labelledby="omemo-tab">
         <converse-omemo-profile></converse-omemo-profile>
     </div>`;
 
+const navigation = (el) => {
+    const i18n_omemo = __('OMEMO');
+    const i18n_profile = __('Profile');
+
+    return html`<ul class="nav nav-pills justify-content-center">
+        <li role="presentation" class="nav-item">
+            <a class="nav-link ${el.tab === "profile" ? "active" : ""}"
+               id="profile-tab"
+               href="#profile-tabpanel"
+               aria-controls="profile-tabpanel" role="tab"
+               @click=${ev => el.switchTab(ev)}
+               data-name="profile"
+               data-toggle="tab">${i18n_profile}</a>
+        </li>
+        <li role="presentation" class="nav-item">
+            <a class="nav-link ${el.tab === "omemo" ? "active" : ""}"
+               id="omemo-tab"
+               href="#omemo-tabpanel"
+               aria-controls="omemo-tabpanel" role="tab"
+               @click=${ev => el.switchTab(ev)}
+               data-name="omemo"
+               data-toggle="tab">${i18n_omemo}</a>
+        </li>
+    </ul>`;
+}
 
-export default (o) => {
-    const heading_profile = __('Your Profile');
+export default (el) => {
+    const o = { ...el.model.toJSON(), ...el.model.vcard.toJSON() };
     const i18n_email = __('Email');
     const i18n_fullname = __('Full Name');
     const i18n_jid = __('XMPP Address');
@@ -20,74 +44,51 @@ export default (o) => {
     const i18n_save = __('Save and close');
     const i18n_role_help = __('Use commas to separate multiple roles. Your roles are shown next to your name on your chat messages.');
     const i18n_url = __('URL');
-    const i18n_omemo = __('OMEMO');
-    const i18n_profile = __('Profile');
-
-    const navigation =
-        html`<ul class="nav nav-pills justify-content-center">
-            <li role="presentation" class="nav-item">
-                <a class="nav-link active" id="profile-tab" href="#profile-tabpanel" aria-controls="profile-tabpanel" role="tab" data-toggle="tab">${i18n_profile}</a>
-            </li>
-            <li role="presentation" class="nav-item">
-                <a class="nav-link" id="omemo-tab" href="#omemo-tabpanel" aria-controls="omemo-tabpanel" role="tab" data-toggle="tab">${i18n_omemo}</a>
-            </li>
-        </ul>`;
 
     return html`
-        <div class="modal-dialog" role="document">
-            <div class="modal-content">
-                <div class="modal-header">
-                    <h5 class="modal-title" id="user-profile-modal-label">${heading_profile}</h5>
-                    ${modal_header_close_button}
-                </div>
-                <div class="modal-body">
-                    <span class="modal-alert"></span>
-                    ${_converse.pluggable.plugins['converse-omemo']?.enabled(_converse) ? navigation : ''}
-                    <div class="tab-content">
-                        <div class="tab-pane active" id="profile-tabpanel" role="tabpanel" aria-labelledby="profile-tab">
-                            <form class="converse-form converse-form--modal profile-form" action="#">
-                                <div class="row">
-                                    <div class="col-auto">
-                                        <converse-image-picker .data="${{image: o.image, image_type: o.image_type}}" width="128" height="128"></converse-image-picker>
-                                    </div>
-                                    <div class="col">
-                                        <div class="form-group">
-                                            <label class="col-form-label">${i18n_jid}:</label>
-                                            <div>${o.jid}</div>
-                                        </div>
-                                    </div>
-                                </div>
-                                <div class="form-group">
-                                    <label for="vcard-fullname" class="col-form-label">${i18n_fullname}:</label>
-                                    <input id="vcard-fullname" type="text" class="form-control" name="fn" value="${o.fullname || ''}"/>
-                                </div>
-                                <div class="form-group">
-                                    <label for="vcard-nickname" class="col-form-label">${i18n_nickname}:</label>
-                                    <input id="vcard-nickname" type="text" class="form-control" name="nickname" value="${o.nickname || ''}"/>
-                                </div>
-                                <div class="form-group">
-                                    <label for="vcard-url" class="col-form-label">${i18n_url}:</label>
-                                    <input id="vcard-url" type="url" class="form-control" name="url" value="${o.url || ''}"/>
-                                </div>
-                                <div class="form-group">
-                                    <label for="vcard-email" class="col-form-label">${i18n_email}:</label>
-                                    <input id="vcard-email" type="email" class="form-control" name="email" value="${o.email || ''}"/>
-                                </div>
-                                <div class="form-group">
-                                    <label for="vcard-role" class="col-form-label">${i18n_role}:</label>
-                                    <input id="vcard-role" type="text" class="form-control" name="role" value="${o.role || ''}" aria-describedby="vcard-role-help"/>
-                                    <small id="vcard-role-help" class="form-text text-muted">${i18n_role_help}</small>
-                                </div>
-                                <hr/>
-                                <div class="form-group">
-                                    <button type="submit" class="save-form btn btn-primary">${i18n_save}</button>
-                                </div>
-                            </form>
+        ${_converse.pluggable.plugins['converse-omemo']?.enabled(_converse) ? navigation(el) : ''}
+        <div class="tab-content">
+            <div class="tab-pane ${ el.tab === 'profile' ? 'active' : ''}" id="profile-tabpanel" role="tabpanel" aria-labelledby="profile-tab">
+                <form class="converse-form converse-form--modal profile-form" action="#" @submit=${ev => el.onFormSubmitted(ev)}>
+                    <div class="row">
+                        <div class="col-auto">
+                            <converse-image-picker .data="${{image: o.image, image_type: o.image_type}}" width="128" height="128"></converse-image-picker>
                         </div>
-                        ${ _converse.pluggable.plugins['converse-omemo']?.enabled(_converse) ? omemo_page() : '' }
+                        <div class="col">
+                            <div class="form-group">
+                                <label class="col-form-label">${i18n_jid}:</label>
+                                <div>${o.jid}</div>
+                            </div>
+                        </div>
+                    </div>
+                    <div class="form-group">
+                        <label for="vcard-fullname" class="col-form-label">${i18n_fullname}:</label>
+                        <input id="vcard-fullname" type="text" class="form-control" name="fn" value="${o.fullname || ''}"/>
+                    </div>
+                    <div class="form-group">
+                        <label for="vcard-nickname" class="col-form-label">${i18n_nickname}:</label>
+                        <input id="vcard-nickname" type="text" class="form-control" name="nickname" value="${o.nickname || ''}"/>
+                    </div>
+                    <div class="form-group">
+                        <label for="vcard-url" class="col-form-label">${i18n_url}:</label>
+                        <input id="vcard-url" type="url" class="form-control" name="url" value="${o.url || ''}"/>
+                    </div>
+                    <div class="form-group">
+                        <label for="vcard-email" class="col-form-label">${i18n_email}:</label>
+                        <input id="vcard-email" type="email" class="form-control" name="email" value="${o.email || ''}"/>
+                    </div>
+                    <div class="form-group">
+                        <label for="vcard-role" class="col-form-label">${i18n_role}:</label>
+                        <input id="vcard-role" type="text" class="form-control" name="role" value="${o.role || ''}" aria-describedby="vcard-role-help"/>
+                        <small id="vcard-role-help" class="form-text text-muted">${i18n_role_help}</small>
+                    </div>
+                    <hr/>
+                    <div class="form-group">
+                        <button type="submit" class="save-form btn btn-primary">${i18n_save}</button>
                     </div>
-                </div>
+                </form>
             </div>
+            ${ _converse.pluggable.plugins['converse-omemo']?.enabled(_converse) ? omemo_page(el) : '' }
         </div>
     `;
 }

+ 4 - 4
src/plugins/roomslist/templates/roomslist.js

@@ -1,5 +1,5 @@
-import AddMUCModal from 'plugins/muc-views/modals/add-muc.js';
-import MUCListModal from 'plugins/muc-views/modals/muc-list.js';
+import 'plugins/muc-views/modals/add-muc.js';
+import 'plugins/muc-views/modals/muc-list.js';
 import { __ } from 'i18n';
 import { _converse, api } from "@converse/headless/core";
 import { html } from "lit";
@@ -80,12 +80,12 @@ export default (o) => {
         <div class="d-flex controlbox-padded">
             <span class="w-100 controlbox-heading controlbox-heading--groupchats">${i18n_heading_chatrooms}</span>
             <a class="controlbox-heading__btn show-list-muc-modal"
-                @click=${(ev) => api.modal.show(MUCListModal, { 'model': o.model }, ev)}
+                @click=${(ev) => api.modal.show('converse-muc-list-modal', { 'model': o.model }, ev)}
                 title="${i18n_title_list_rooms}" data-toggle="modal" data-target="#muc-list-modal">
                     <converse-icon class="fa fa-list-ul right" size="1em"></converse-icon>
             </a>
             <a class="controlbox-heading__btn show-add-muc-modal"
-                @click=${(ev) => api.modal.show(AddMUCModal, { 'model': o.model }, ev)}
+                @click=${(ev) => api.modal.show('converse-add-muc-modal', { 'model': o.model }, ev)}
                 title="${i18n_title_new_room}" data-toggle="modal" data-target="#add-chatrooms-modal">
                     <converse-icon class="fa fa-plus right" size="1em"></converse-icon>
             </a>

+ 6 - 6
src/plugins/roomslist/tests/roomslist.js

@@ -255,9 +255,9 @@ describe("A groupchat shown in the groupchats list", function () {
         const info_el = rooms_list.querySelector(".room-info");
         info_el.click();
 
-        const modal = _converse.api.modal.get('muc-details-modal');
-        await u.waitUntil(() => u.isVisible(modal.el), 1000);
-        let els = modal.el.querySelectorAll('p.room-info');
+        const modal = _converse.api.modal.get('converse-muc-details-modal');
+        await u.waitUntil(() => u.isVisible(modal), 1000);
+        let els = modal.querySelectorAll('p.room-info');
         expect(els[0].textContent).toBe("Name: A Dark Cave")
 
         expect(els[1].querySelector('strong').textContent).toBe("XMPP address");
@@ -266,7 +266,7 @@ describe("A groupchat shown in the groupchats list", function () {
         expect(els[2].querySelector('converse-rich-text').textContent).toBe("This is the description");
 
         expect(els[3].textContent).toBe("Online users: 1")
-        const features_list = modal.el.querySelector('.features-list');
+        const features_list = modal.querySelector('.features-list');
         expect(features_list.textContent.replace(/(\n|\s{2,})/g, '')).toBe(
             'Password protected - This groupchat requires a password before entry'+
             'Hidden - This groupchat is not publicly searchable'+
@@ -287,11 +287,11 @@ describe("A groupchat shown in the groupchats list", function () {
             });
         _converse.connection._dataRecv(mock.createRequest(presence));
 
-        els = modal.el.querySelectorAll('p.room-info');
+        els = modal.querySelectorAll('p.room-info');
         expect(els[3].textContent).toBe("Online users: 2")
 
         view.model.set({'subject': {'author': 'someone', 'text': 'Hatching dark plots'}});
-        els = modal.el.querySelectorAll('p.room-info');
+        els = modal.querySelectorAll('p.room-info');
         expect(els[0].textContent).toBe("Name: A Dark Cave")
 
         expect(els[1].querySelector('strong').textContent).toBe("XMPP address");

+ 2 - 2
src/plugins/roomslist/view.js

@@ -1,4 +1,4 @@
-import RoomDetailsModal from 'plugins/muc-views/modals/muc-details.js';
+import 'plugins/muc-views/modals/muc-details.js';
 import RoomsListModel from './model.js';
 import tpl_roomslist from "./templates/roomslist.js";
 import { CustomElement } from 'shared/components/element.js';
@@ -58,7 +58,7 @@ export class RoomsList extends CustomElement {
         const jid = ev.currentTarget.getAttribute('data-room-jid');
         const room = _converse.chatboxes.get(jid);
         ev.preventDefault();
-        api.modal.show(RoomDetailsModal, {'model': room}, ev);
+        api.modal.show('converse-muc-details-modal', {'model': room}, ev);
     }
 
     async openRoom (ev) { // eslint-disable-line class-methods-use-this

+ 34 - 34
src/plugins/rosterview/modals/add-contact.js

@@ -1,5 +1,5 @@
 import 'shared/autocomplete/index.js';
-import BootstrapModal from "plugins/modal/base.js";
+import BaseModal from "plugins/modal/modal.js";
 import compact from 'lodash-es/compact';
 import debounce from 'lodash-es/debounce';
 import tpl_add_contact_modal from "./templates/add-contact.js";
@@ -9,18 +9,22 @@ import { _converse, api, converse } from "@converse/headless/core";
 const { Strophe } = converse.env;
 const u = converse.env.utils;
 
-
-const AddContactModal = BootstrapModal.extend({
-    id: "add-contact-modal",
+export default class AddContactModal extends BaseModal {
 
     initialize () {
-        BootstrapModal.prototype.initialize.apply(this, arguments);
-        this.listenTo(this.model, 'change', this.render);
-    },
+        super.initialize();
+        this.listenTo(this.model, 'change', () => this.render());
+        this.render();
+        this.addEventListener('shown.bs.modal', () => this.querySelector('input[name="jid"]')?.focus(), false);
+    }
 
-    toHTML () {
+    renderModal () {
         return tpl_add_contact_modal(this);
-    },
+    }
+
+    getModalTitle () { // eslint-disable-line class-methods-use-this
+        return __('Add a Contact');
+    }
 
     afterRender () {
         if (typeof api.settings.get('xhr_user_search_url') === 'string') {
@@ -28,39 +32,37 @@ const AddContactModal = BootstrapModal.extend({
         } else {
             this.initJIDAutoComplete();
         }
-        const jid_input = this.el.querySelector('input[name="jid"]');
-        this.el.addEventListener('shown.bs.modal', () => jid_input.focus(), false);
-    },
+    }
 
     initJIDAutoComplete () {
         if (!api.settings.get('autocomplete_add_contact')) {
             return;
         }
-        const el = this.el.querySelector('.suggestion-box__jid').parentElement;
+        const el = this.querySelector('.suggestion-box__jid').parentElement;
         this.jid_auto_complete = new _converse.AutoComplete(el, {
             'data': (text, input) => `${input.slice(0, input.indexOf("@"))}@${text}`,
             'filter': _converse.FILTER_STARTSWITH,
             'list': [...new Set(_converse.roster.map(item => Strophe.getDomainFromJid(item.get('jid'))))]
         });
-    },
+    }
 
     initGroupAutoComplete () {
         if (!api.settings.get('autocomplete_add_contact')) {
             return;
         }
-        const el = this.el.querySelector('.suggestion-box__jid').parentElement;
+        const el = this.querySelector('.suggestion-box__jid').parentElement;
         this.jid_auto_complete = new _converse.AutoComplete(el, {
             'data': (text, input) => `${input.slice(0, input.indexOf("@"))}@${text}`,
             'filter': _converse.FILTER_STARTSWITH,
             'list': [...new Set(_converse.roster.map(item => Strophe.getDomainFromJid(item.get('jid'))))]
         });
-    },
+    }
 
     initXHRAutoComplete () {
         if (!api.settings.get('autocomplete_add_contact')) {
             return this.initXHRFetch();
         }
-        const el = this.el.querySelector('.suggestion-box__name').parentElement;
+        const el = this.querySelector('.suggestion-box__name').parentElement;
         this.name_auto_complete = new _converse.AutoComplete(el, {
             'auto_evaluate': false,
             'filter': _converse.FILTER_STARTSWITH,
@@ -76,16 +78,16 @@ const AddContactModal = BootstrapModal.extend({
                 this.name_auto_complete.evaluate();
             }
         };
-        const input_el = this.el.querySelector('input[name="name"]');
+        const input_el = this.querySelector('input[name="name"]');
         input_el.addEventListener('input', debounce(() => {
             xhr.open("GET", `${api.settings.get('xhr_user_search_url')}q=${encodeURIComponent(input_el.value)}`, true);
             xhr.send()
         } , 300));
         this.name_auto_complete.on('suggestion-box-selectcomplete', ev => {
-            this.el.querySelector('input[name="name"]').value = ev.text.label;
-            this.el.querySelector('input[name="jid"]').value = ev.text.value;
+            this.querySelector('input[name="name"]').value = ev.text.label;
+            this.querySelector('input[name="jid"]').value = ev.text.value;
         });
-    },
+    }
 
     initXHRFetch () {
         this.xhr = new window.XMLHttpRequest();
@@ -94,25 +96,25 @@ const AddContactModal = BootstrapModal.extend({
                 const r = this.xhr.responseText;
                 const list = JSON.parse(r).map(i => ({'label': i.fullname || i.jid, 'value': i.jid}));
                 if (list.length !== 1) {
-                    const el = this.el.querySelector('.invalid-feedback');
+                    const el = this.querySelector('.invalid-feedback');
                     el.textContent = __('Sorry, could not find a contact with that name')
                     u.addClass('d-block', el);
                     return;
                 }
                 const jid = list[0].value;
                 if (this.validateSubmission(jid)) {
-                    const form = this.el.querySelector('form');
+                    const form = this.querySelector('form');
                     const name = list[0].label;
                     this.afterSubmission(form, jid, name);
                 }
             }
         };
-    },
+    }
 
     validateSubmission (jid) {
-        const el = this.el.querySelector('.invalid-feedback');
+        const el = this.querySelector('.invalid-feedback');
         if (!jid || compact(jid.split('@')).length < 2) {
-            u.addClass('is-invalid', this.el.querySelector('input[name="jid"]'));
+            u.addClass('is-invalid', this.querySelector('input[name="jid"]'));
             u.addClass('d-block', el);
             return false;
         } else if (_converse.roster.get(Strophe.getBareJidFromJid(jid))) {
@@ -122,16 +124,16 @@ const AddContactModal = BootstrapModal.extend({
         }
         u.removeClass('d-block', el);
         return true;
-    },
+    }
 
-    afterSubmission (form, jid, name, group) {
+    afterSubmission (_form, jid, name, group) {
         if (group && !Array.isArray(group)) {
             group = [group];
         }
         _converse.roster.addAndSubscribe(jid, name, group);
         this.model.clear();
         this.modal.hide();
-    },
+    }
 
     addContactFromForm (ev) {
         ev.preventDefault();
@@ -139,7 +141,7 @@ const AddContactModal = BootstrapModal.extend({
         const jid = (data.get('jid') || '').trim();
 
         if (!jid && typeof api.settings.get('xhr_user_search_url') === 'string') {
-            const input_el = this.el.querySelector('input[name="name"]');
+            const input_el = this.querySelector('input[name="name"]');
             this.xhr.open("GET", `${api.settings.get('xhr_user_search_url')}q=${encodeURIComponent(input_el.value)}`, true);
             this.xhr.send()
             return;
@@ -148,8 +150,6 @@ const AddContactModal = BootstrapModal.extend({
             this.afterSubmission(ev.target, jid, data.get('name'), data.get('group'));
         }
     }
-});
-
-_converse.AddContactModal = AddContactModal;
+}
 
-export default AddContactModal;
+api.elements.define('converse-add-contact-modal', AddContactModal);

+ 29 - 41
src/plugins/rosterview/modals/templates/add-contact.js

@@ -2,7 +2,6 @@ import { __ } from 'i18n';
 import { api } from '@converse/headless/core.js';
 import { getGroupsAutoCompleteList } from '@converse/headless/plugins/roster/utils.js';
 import { html } from "lit";
-import { modal_header_close_button } from "plugins/modal/templates/buttons.js"
 
 
 export default (el) => {
@@ -10,51 +9,40 @@ export default (el) => {
     const i18n_contact_placeholder = __('name@example.org');
     const i18n_error_message = __('Please enter a valid XMPP address');
     const i18n_group = __('Group');
-    const i18n_new_contact = __('Add a Contact');
     const i18n_nickname = __('Name');
     const i18n_xmpp_address = __('XMPP Address');
+
     return html`
-        <div class="modal-dialog" role="document">
-            <div class="modal-content">
-                <div class="modal-header">
-                    <h5 class="modal-title" id="addContactModalLabel">${i18n_new_contact}</h5>
-                    ${modal_header_close_button}
+        <form class="converse-form add-xmpp-contact" @submit=${ev => el.addContactFromForm(ev)}>
+            <div class="modal-body">
+                <span class="modal-alert"></span>
+                <div class="form-group add-xmpp-contact__jid">
+                    <label class="clearfix" for="jid">${i18n_xmpp_address}:</label>
+                    <div class="suggestion-box suggestion-box__jid">
+                        <ul class="suggestion-box__results suggestion-box__results--below" hidden=""></ul>
+                        <input type="text" name="jid" ?required=${(!api.settings.get('xhr_user_search_url'))}
+                            value="${el.model.get('jid') || ''}"
+                            class="form-control suggestion-box__input"
+                            placeholder="${i18n_contact_placeholder}"/>
+                        <span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span>
+                    </div>
                 </div>
-                <form class="converse-form add-xmpp-contact" @submit=${ev => el.addContactFromForm(ev)}>
-                    <div class="modal-body">
-                        <span class="modal-alert"></span>
-                        <div class="form-group add-xmpp-contact__jid">
-                            <label class="clearfix" for="jid">${i18n_xmpp_address}:</label>
-                            <div class="suggestion-box suggestion-box__jid">
-                                <ul class="suggestion-box__results suggestion-box__results--below" hidden=""></ul>
-                                <input type="text" name="jid" ?required=${(!api.settings.get('xhr_user_search_url'))}
-                                    value="${el.model.get('jid') || ''}"
-                                    class="form-control suggestion-box__input"
-                                    placeholder="${i18n_contact_placeholder}"/>
-                                <span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span>
-                            </div>
-                        </div>
-
-                        <div class="form-group add-xmpp-contact__name">
-                            <label class="clearfix" for="name">${i18n_nickname}:</label>
-                            <div class="suggestion-box suggestion-box__name">
-                                <ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul>
-                                <input type="text" name="name" value="${el.model.get('nickname') || ''}"
-                                    class="form-control suggestion-box__input"/>
-                                <span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span>
-                            </div>
-                        </div>
 
-                        <div class="form-group add-xmpp-contact__group">
-                            <label class="clearfix" for="name">${i18n_group}:</label>
-                            <converse-autocomplete .list=${getGroupsAutoCompleteList()} name="group"></converse-autocomplete>
-                        </div>
-
-                        <div class="form-group"><div class="invalid-feedback">${i18n_error_message}</div></div>
-                        <button type="submit" class="btn btn-primary">${i18n_add}</button>
+                <div class="form-group add-xmpp-contact__name">
+                    <label class="clearfix" for="name">${i18n_nickname}:</label>
+                    <div class="suggestion-box suggestion-box__name">
+                        <ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul>
+                        <input type="text" name="name" value="${el.model.get('nickname') || ''}"
+                            class="form-control suggestion-box__input"/>
+                        <span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span>
                     </div>
-                </form>
+                </div>
+                <div class="form-group add-xmpp-contact__group">
+                    <label class="clearfix" for="name">${i18n_group}:</label>
+                    <converse-autocomplete .list=${getGroupsAutoCompleteList()} name="group"></converse-autocomplete>
+                </div>
+                <div class="form-group"><div class="invalid-feedback">${i18n_error_message}</div></div>
+                <button type="submit" class="btn btn-primary">${i18n_add}</button>
             </div>
-        </div>
-    `;
+        </form>`;
 }

+ 8 - 7
src/plugins/rosterview/rosterview.js

@@ -13,13 +13,14 @@ export default class RosterView extends CustomElement {
 
     async initialize () {
         await api.waitUntil('rosterInitialized')
+        const { presences, roster } = _converse;
         this.listenTo(_converse, 'rosterContactsFetched', () => this.requestUpdate());
-        this.listenTo(_converse.presences, 'change:show', () => this.requestUpdate());
-        this.listenTo(_converse.roster, 'add', () => this.requestUpdate());
-        this.listenTo(_converse.roster, 'destroy', () => this.requestUpdate());
-        this.listenTo(_converse.roster, 'remove', () => this.requestUpdate());
-        this.listenTo(_converse.roster, 'change', () => this.requestUpdate());
-        this.listenTo(_converse.roster.state, 'change', () => this.requestUpdate());
+        this.listenTo(presences, 'change:show', () => this.requestUpdate());
+        this.listenTo(roster, 'add', () => this.requestUpdate());
+        this.listenTo(roster, 'destroy', () => this.requestUpdate());
+        this.listenTo(roster, 'remove', () => this.requestUpdate());
+        this.listenTo(roster, 'change', () => this.requestUpdate());
+        this.listenTo(roster.state, 'change', () => this.requestUpdate());
         /**
          * Triggered once the _converse.RosterView instance has been created and initialized.
          * @event _converse#rosterViewInitialized
@@ -42,7 +43,7 @@ export default class RosterView extends CustomElement {
     }
 
     showAddContactModal (ev) { // eslint-disable-line class-methods-use-this
-        api.modal.show(_converse.AddContactModal, {'model': new Model()}, ev);
+        api.modal.show('converse-add-contact-modal', {'model': new Model()}, ev);
     }
 
     async syncContacts (ev) { // eslint-disable-line class-methods-use-this

+ 195 - 0
src/plugins/rosterview/tests/add-contact-modal.js

@@ -0,0 +1,195 @@
+/*global mock, converse */
+
+const u = converse.env.utils;
+const Strophe = converse.env.Strophe;
+const sizzle = converse.env.sizzle;
+
+describe("The 'Add Contact' widget", function () {
+
+    it("opens up an add modal when you click on it",
+            mock.initConverse([], {}, async function (_converse) {
+
+        await mock.waitForRoster(_converse, 'all');
+        await mock.openControlBox(_converse);
+
+        const cbview = _converse.chatboxviews.get('controlbox');
+        cbview.querySelector('.add-contact').click()
+        const modal = _converse.api.modal.get('converse-add-contact-modal');
+        await u.waitUntil(() => u.isVisible(modal), 1000);
+        expect(modal.querySelector('form.add-xmpp-contact')).not.toBe(null);
+
+        const input_jid = modal.querySelector('input[name="jid"]');
+        const input_name = modal.querySelector('input[name="name"]');
+        input_jid.value = 'someone@';
+
+        const evt = new Event('input');
+        input_jid.dispatchEvent(evt);
+        expect(modal.querySelector('.suggestion-box li').textContent).toBe('someone@montague.lit');
+        input_jid.value = 'someone@montague.lit';
+        input_name.value = 'Someone';
+        modal.querySelector('button[type="submit"]').click();
+
+        const sent_IQs = _converse.connection.IQ_stanzas;
+        const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
+        expect(Strophe.serialize(sent_stanza)).toEqual(
+            `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+                `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit" name="Someone"/></query>`+
+            `</iq>`);
+    }));
+
+    it("can be configured to not provide search suggestions",
+            mock.initConverse([], {'autocomplete_add_contact': false}, async function (_converse) {
+
+        await mock.waitForRoster(_converse, 'all', 0);
+        await mock.openControlBox(_converse);
+        const cbview = _converse.chatboxviews.get('controlbox');
+        cbview.querySelector('.add-contact').click()
+        const modal = _converse.api.modal.get('converse-add-contact-modal');
+        expect(modal.jid_auto_complete).toBe(undefined);
+        expect(modal.name_auto_complete).toBe(undefined);
+
+        await u.waitUntil(() => u.isVisible(modal), 1000);
+        expect(modal.querySelector('form.add-xmpp-contact')).not.toBe(null);
+        const input_jid = modal.querySelector('input[name="jid"]');
+        input_jid.value = 'someone@montague.lit';
+        modal.querySelector('button[type="submit"]').click();
+
+        const IQ_stanzas = _converse.connection.IQ_stanzas;
+        const sent_stanza = await u.waitUntil(
+            () => IQ_stanzas.filter(s => sizzle(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`, s).length).pop()
+        );
+        expect(Strophe.serialize(sent_stanza)).toEqual(
+            `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+                `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit"/></query>`+
+            `</iq>`
+        );
+    }));
+
+    it("integrates with xhr_user_search_url to search for contacts",
+            mock.initConverse([], { 'xhr_user_search_url': 'http://example.org/?' },
+            async function (_converse) {
+
+        await mock.waitForRoster(_converse, 'all', 0);
+
+        class MockXHR extends XMLHttpRequest {
+            open () {} // eslint-disable-line
+            responseText  = ''
+            send () {
+                this.responseText = JSON.stringify([
+                    {"jid": "marty@mcfly.net", "fullname": "Marty McFly"},
+                    {"jid": "doc@brown.com", "fullname": "Doc Brown"}
+                ]);
+                this.onload();
+            }
+        }
+        const XMLHttpRequestBackup = window.XMLHttpRequest;
+        window.XMLHttpRequest = MockXHR;
+
+        await mock.openControlBox(_converse);
+        const cbview = _converse.chatboxviews.get('controlbox');
+        cbview.querySelector('.add-contact').click()
+        const modal = _converse.api.modal.get('converse-add-contact-modal');
+        await u.waitUntil(() => u.isVisible(modal), 1000);
+
+        // We only have autocomplete for the name input
+        expect(modal.jid_auto_complete).toBe(undefined);
+        expect(modal.name_auto_complete instanceof _converse.AutoComplete).toBe(true);
+
+        const input_el = modal.querySelector('input[name="name"]');
+        input_el.value = 'marty';
+        input_el.dispatchEvent(new Event('input'));
+        await u.waitUntil(() => modal.querySelector('.suggestion-box li'), 1000);
+        expect(modal.querySelectorAll('.suggestion-box li').length).toBe(1);
+        const suggestion = modal.querySelector('.suggestion-box li');
+        expect(suggestion.textContent).toBe('Marty McFly');
+
+        // Mock selection
+        modal.name_auto_complete.select(suggestion);
+
+        expect(input_el.value).toBe('Marty McFly');
+        expect(modal.querySelector('input[name="jid"]').value).toBe('marty@mcfly.net');
+        modal.querySelector('button[type="submit"]').click();
+
+        const sent_IQs = _converse.connection.IQ_stanzas;
+        const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
+        expect(Strophe.serialize(sent_stanza)).toEqual(
+        `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+            `<query xmlns="jabber:iq:roster"><item jid="marty@mcfly.net" name="Marty McFly"/></query>`+
+        `</iq>`);
+        window.XMLHttpRequest = XMLHttpRequestBackup;
+    }));
+
+    it("can be configured to not provide search suggestions for XHR search results",
+        mock.initConverse([],
+            { 'autocomplete_add_contact': false,
+              'xhr_user_search_url': 'http://example.org/?' },
+            async function (_converse) {
+
+        await mock.waitForRoster(_converse, 'all');
+        await mock.openControlBox(_converse);
+
+        class MockXHR extends XMLHttpRequest {
+            open () {} // eslint-disable-line
+            responseText  = ''
+            send () {
+                const value = modal.querySelector('input[name="name"]').value;
+                if (value === 'existing') {
+                    const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                    this.responseText = JSON.stringify([{"jid": contact_jid, "fullname": mock.cur_names[0]}]);
+                } else if (value === 'romeo') {
+                    this.responseText = JSON.stringify([{"jid": "romeo@montague.lit", "fullname": "Romeo Montague"}]);
+                } else if (value === 'ambiguous') {
+                    this.responseText = JSON.stringify([
+                        {"jid": "marty@mcfly.net", "fullname": "Marty McFly"},
+                        {"jid": "doc@brown.com", "fullname": "Doc Brown"}
+                    ]);
+                } else if (value === 'insufficient') {
+                    this.responseText = JSON.stringify([]);
+                } else {
+                    this.responseText = JSON.stringify([{"jid": "marty@mcfly.net", "fullname": "Marty McFly"}]);
+                }
+                this.onload();
+            }
+        }
+
+        const XMLHttpRequestBackup = window.XMLHttpRequest;
+        window.XMLHttpRequest = MockXHR;
+
+        const cbview = _converse.chatboxviews.get('controlbox');
+        cbview.querySelector('.add-contact').click()
+        const modal = _converse.api.modal.get('converse-add-contact-modal');
+        await u.waitUntil(() => u.isVisible(modal), 1000);
+
+        expect(modal.jid_auto_complete).toBe(undefined);
+        expect(modal.name_auto_complete).toBe(undefined);
+
+        const input_el = modal.querySelector('input[name="name"]');
+        input_el.value = 'ambiguous';
+        modal.querySelector('button[type="submit"]').click();
+        let feedback_el = modal.querySelector('.invalid-feedback');
+        expect(feedback_el.textContent).toBe('Sorry, could not find a contact with that name');
+        feedback_el.textContent = '';
+
+        input_el.value = 'insufficient';
+        modal.querySelector('button[type="submit"]').click();
+        feedback_el = modal.querySelector('.invalid-feedback');
+        expect(feedback_el.textContent).toBe('Sorry, could not find a contact with that name');
+        feedback_el.textContent = '';
+
+        input_el.value = 'existing';
+        modal.querySelector('button[type="submit"]').click();
+        feedback_el = modal.querySelector('.invalid-feedback');
+        expect(feedback_el.textContent).toBe('This contact has already been added');
+
+        input_el.value = 'Marty McFly';
+        modal.querySelector('button[type="submit"]').click();
+
+        const sent_IQs = _converse.connection.IQ_stanzas;
+        const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop());
+        expect(Strophe.serialize(sent_stanza)).toEqual(
+        `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+
+            `<query xmlns="jabber:iq:roster"><item jid="marty@mcfly.net" name="Marty McFly"/></query>`+
+        `</iq>`);
+        window.XMLHttpRequest = XMLHttpRequestBackup;
+    }));
+});

+ 11 - 10
src/plugins/rosterview/tests/presence.js

@@ -17,11 +17,11 @@ describe("A sent presence stanza", function () {
         const cbview = _converse.chatboxviews.get('controlbox');
         const change_status_el = await u.waitUntil(() => cbview.querySelector('.change-status'));
         change_status_el.click()
-        let modal = _converse.api.modal.get('modal-status-change');
-        await u.waitUntil(() => u.isVisible(modal.el), 1000);
+        let modal = _converse.api.modal.get('converse-chat-status-modal');
+        await u.waitUntil(() => u.isVisible(modal), 1000);
         const msg = 'My custom status';
-        modal.el.querySelector('input[name="status_message"]').value = msg;
-        modal.el.querySelector('[type="submit"]').click();
+        modal.querySelector('input[name="status_message"]').value = msg;
+        modal.querySelector('[type="submit"]').click();
 
         const sent_stanzas = _converse.connection.sent_stanzas;
         let sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop());
@@ -31,14 +31,15 @@ describe("A sent presence stanza", function () {
                     `<priority>0</priority>`+
                     `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
                     `</presence>`)
-        await u.waitUntil(() => modal.el.getAttribute('aria-hidden') === "true");
-        await u.waitUntil(() => !u.isVisible(modal.el));
+        await u.waitUntil(() => modal.getAttribute('aria-hidden') === "true");
+        await u.waitUntil(() => !u.isVisible(modal));
 
         cbview.querySelector('.change-status').click()
-        modal = _converse.api.modal.get('modal-status-change');
-        await u.waitUntil(() => modal.el.getAttribute('aria-hidden') === "false", 1000);
-        modal.el.querySelector('label[for="radio-busy"]').click(); // Change status to "dnd"
-        modal.el.querySelector('[type="submit"]').click();
+        modal = _converse.api.modal.get('converse-chat-status-modal');
+        await u.waitUntil(() => modal.getAttribute('aria-hidden') === "false", 1000);
+        modal.querySelector('label[for="radio-busy"]').click(); // Change status to "dnd"
+        modal.querySelector('[type="submit"]').click();
+
         await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).length === 2);
         sent_presence = sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop();
         expect(Strophe.serialize(sent_presence))

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

@@ -55,12 +55,12 @@ describe("The Protocol", function () {
             spyOn(_converse.api.vcard, "get").and.callThrough();
 
             cbview.querySelector('.add-contact').click()
-            const modal = _converse.api.modal.get('add-contact-modal');
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
+            const modal = _converse.api.modal.get('converse-add-contact-modal');
+            await u.waitUntil(() => u.isVisible(modal), 1000);
             modal.delegateEvents();
 
             // Fill in the form and submit
-            const form = modal.el.querySelector('form.add-xmpp-contact');
+            const form = modal.querySelector('form.add-xmpp-contact');
             form.querySelector('input[name="jid"]').value = 'contact@example.org';
             form.querySelector('input[name="name"]').value = 'Chris Contact';
             form.querySelector('input[name="group"]').value = 'My Buddies';

+ 14 - 0
src/plugins/rosterview/utils.js

@@ -1,5 +1,19 @@
+import log from "@converse/headless/log";
+import { __ } from 'i18n';
 import { _converse, api } from "@converse/headless/core";
 
+export function removeContact (contact) {
+    contact.removeFromRoster(
+        () => contact.destroy(),
+        (e) => {
+            e && log.error(e);
+            api.alert('error', __('Error'), [
+                __('Sorry, there was an error while trying to remove %1$s as a contact.',
+                contact.getDisplayName())
+            ]);
+        }
+    );
+}
 
 export function highlightRosterItem (chatbox) {
     _converse.roster?.get(chatbox.get('jid'))?.trigger('highlight');

+ 2 - 0
src/shared/autocomplete/component.js

@@ -55,6 +55,7 @@ export default class AutoCompleteComponent extends CustomElement {
             'name': { type: String },
             'placeholder': { type: String },
             'triggers': { type: String },
+            'required': { type: Boolean },
         };
     }
 
@@ -78,6 +79,7 @@ export default class AutoCompleteComponent extends CustomElement {
                 <ul class="suggestion-box__results ${position_class}" hidden=""></ul>
                 <input
                     ?autofocus=${this.autofocus}
+                    ?required=${this.required}
                     type="text"
                     name="${this.name}"
                     autocomplete="off"

+ 2 - 2
src/shared/chat/message-body.js

@@ -1,5 +1,5 @@
 import 'shared/registry.js';
-import ImageModal from 'modals/image.js';
+import ImageModal from 'shared/modals/image.js';
 import renderRichText from 'shared/directives/rich-text.js';
 import { CustomElement } from 'shared/components/element.js';
 import { api } from "@converse/headless/core";
@@ -31,7 +31,7 @@ export default class MessageBody extends CustomElement {
 
     onImgClick (ev) { // eslint-disable-line class-methods-use-this
         ev.preventDefault();
-        api.modal.create(ImageModal, {'src': ev.target.src}, ev).show(ev);
+        api.modal.show('converse-image-modal', {'src': ev.target.src}, ev);
     }
 
     onImgLoad () {

+ 7 - 7
src/shared/chat/message.js

@@ -1,11 +1,11 @@
 import './message-actions.js';
 import './message-body.js';
 import 'shared/components/dropdown.js';
+import 'shared/modals/message-versions.js';
+import 'shared/modals/user-details.js';
 import 'shared/registry';
+import 'plugins/muc-views/modals/occupant.js';
 import tpl_file_progress from './templates/file-progress.js';
-import MessageVersionsModal from 'modals/message-versions.js';
-import OccupantModal from 'plugins/muc-views/modals/occupant.js';
-import UserDetailsModal from 'modals/user-details.js';
 import log from '@converse/headless/log';
 import tpl_info_message from './templates/info-message.js';
 import tpl_mep_message from 'plugins/muc-views/templates/mep-message.js';
@@ -214,20 +214,20 @@ export default class Message extends CustomElement {
 
     showUserModal (ev) {
         if (this.model.get('sender') === 'me') {
-            api.modal.show(_converse.ProfileModal, {model: this.model}, ev);
+            api.modal.show('converse-profile-modal', {model: this.model}, ev);
         } else if (this.model.get('type') === 'groupchat') {
             ev.preventDefault();
-            api.modal.show(OccupantModal, { 'model': this.model.occupant, 'message': this.model }, ev);
+            api.modal.show('converse-muc-occupant-modal', { 'model': this.model.occupant, 'message': this.model }, ev);
         } else {
             ev.preventDefault();
             const chatbox = this.model.collection.chatbox;
-            api.modal.show(UserDetailsModal, { model: chatbox }, ev);
+            api.modal.show('converse-user-details-modal', { model: chatbox }, ev);
         }
     }
 
     showMessageVersionsModal (ev) {
         ev.preventDefault();
-        api.modal.show(MessageVersionsModal, {'model': this.model}, ev);
+        api.modal.show('converse-message-versions-modal', {'model': this.model}, ev);
     }
 
     toggleSpoilerMessage (ev) {

+ 22 - 0
src/shared/modals/image.js

@@ -0,0 +1,22 @@
+import BaseModal from "plugins/modal/modal.js";
+import tpl_image_modal from "./templates/image.js";
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core";
+import { getFileName } from 'utils/html.js';
+import { html } from "lit";
+
+import './styles/image.scss';
+
+
+export default class ImageModal extends BaseModal {
+
+    renderModal () {
+        return tpl_image_modal({ 'src': this.src });
+    }
+
+    getModalTitle () {
+        return html`${__('Image: ')}<a target="_blank" rel="noopener" href="${this.src}">${getFileName(this.src)}</a>`;
+    }
+}
+
+api.elements.define('converse-image-modal', ImageModal);

+ 19 - 0
src/shared/modals/message-versions.js

@@ -0,0 +1,19 @@
+import 'shared/components/message-versions.js';
+import BaseModal from "plugins/modal/modal.js";
+import { __ } from 'i18n';
+import { html } from "lit";
+import {api } from "@converse/headless/core";
+
+
+export default class MessageVersionsModal extends BaseModal {
+
+    renderModal () {
+        return html`<converse-message-versions .model=${this.model}></converse-message-versions>`;
+    }
+
+    getModalTitle () { // eslint-disable-line class-methods-use-this
+        return __('Message versions');
+    }
+}
+
+api.elements.define('converse-message-versions-modal', MessageVersionsModal);

+ 6 - 0
src/shared/modals/styles/image.scss

@@ -0,0 +1,6 @@
+converse-image-modal {
+    .chat-image--modal {
+        max-height: 99%;
+        max-width: 100%;
+    }
+}

+ 3 - 0
src/shared/modals/templates/image.js

@@ -0,0 +1,3 @@
+import { html } from "lit";
+
+export default (o) => html`<img class="chat-image chat-image--modal" src="${o.src}">`;

+ 69 - 0
src/shared/modals/templates/user-details.js

@@ -0,0 +1,69 @@
+import avatar from 'shared/avatar/templates/avatar.js';
+import { __ } from 'i18n';
+import { html } from 'lit';
+import { api } from "@converse/headless/core";
+
+const remove_button = (el) => {
+    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(--text-color-lighten-15-percent)"
+                size="1em"
+            ></converse-icon>
+            ${i18n_remove_contact}
+        </button>
+    `;
+}
+
+export const tpl_footer = (el) => {
+    const is_roster_contact = el.model.contact !== undefined;
+    const i18n_refresh = __('Refresh');
+    const allow_contact_removal = api.settings.get('allow_contact_removal');
+    return html`
+        <button type="button" class="btn btn-info refresh-contact" @click=${ev => el.refreshContact(ev)}>
+            <converse-icon
+                class="fa fa-refresh"
+                color="var(--text-color-lighten-15-percent)"
+                size="1em"
+            ></converse-icon>
+            ${i18n_refresh}</button>
+        ${ (allow_contact_removal && is_roster_contact) ? remove_button(el) : '' }
+    `;
+}
+
+
+export const tpl_user_details_modal = (el) => {
+    const vcard = el.model?.vcard;
+    const vcard_json = vcard ? vcard.toJSON() : {};
+    const o = { ...el.model.toJSON(), ...vcard_json };
+
+    const i18n_address = __('XMPP Address');
+    const i18n_email = __('Email');
+    const i18n_full_name = __('Full Name');
+    const i18n_nickname = __('Nickname');
+    const i18n_profile = __('The User\'s Profile Image');
+    const i18n_role = __('Role');
+    const i18n_url = __('URL');
+    const avatar_data = {
+        'alt_text': i18n_profile,
+        'extra_classes': 'mb-3',
+        'height': '120',
+        'width': '120'
+    }
+
+    return html`
+        <div class="modal-body">
+            ${ o.image ? html`<div class="mb-4">${avatar(Object.assign(o, avatar_data))}</div>` : '' }
+            ${ o.fullname ? html`<p><label>${i18n_full_name}:</label> ${o.fullname}</p>` : '' }
+            <p><label>${i18n_address}:</label> <a href="xmpp:${o.jid}">${o.jid}</a></p>
+            ${ o.nickname ? html`<p><label>${i18n_nickname}:</label> ${o.nickname}</p>` : '' }
+            ${ o.url ? html`<p><label>${i18n_url}:</label> <a target="_blank" rel="noopener" href="${o.url}">${o.url}</a></p>` : '' }
+            ${ o.email ? html`<p><label>${i18n_email}:</label> <a href="mailto:${o.email}">${o.email}</a></p>` : '' }
+            ${ o.role ? html`<p><label>${i18n_role}:</label> ${o.role}</p>` : '' }
+
+            <converse-omemo-fingerprints jid=${o.jid}></converse-omemo-fingerprints>
+        </div>
+    `;
+}

+ 14 - 14
src/modals/tests/user-details-modal.js → src/shared/modals/tests/user-details-modal.js

@@ -17,19 +17,19 @@ describe("The User Details Modal", function () {
         const view = _converse.chatboxviews.get(contact_jid);
         let show_modal_button = view.querySelector('.show-user-details-modal');
         show_modal_button.click();
-        const modal = _converse.api.modal.get('user-details-modal');
-        await u.waitUntil(() => u.isVisible(modal.el), 1000);
+        const modal = _converse.api.modal.get('converse-user-details-modal');
+        await u.waitUntil(() => u.isVisible(modal), 1000);
         spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
 
         spyOn(view.model.contact, 'removeFromRoster').and.callFake(callback => callback());
-        let remove_contact_button = modal.el.querySelector('button.remove-contact');
+        let remove_contact_button = modal.querySelector('button.remove-contact');
         expect(u.isVisible(remove_contact_button)).toBeTruthy();
         remove_contact_button.click();
-        await u.waitUntil(() => modal.el.getAttribute('aria-hidden'), 1000);
-        await u.waitUntil(() => !u.isVisible(modal.el));
+        await u.waitUntil(() => modal.getAttribute('aria-hidden'), 1000);
+        await u.waitUntil(() => !u.isVisible(modal));
         show_modal_button = view.querySelector('.show-user-details-modal');
         show_modal_button.click();
-        remove_contact_button = modal.el.querySelector('button.remove-contact');
+        remove_contact_button = modal.querySelector('button.remove-contact');
         expect(remove_contact_button === null).toBeTruthy();
     }));
 
@@ -44,15 +44,15 @@ describe("The User Details Modal", function () {
         const view = _converse.chatboxviews.get(contact_jid);
         let show_modal_button = view.querySelector('.show-user-details-modal');
         show_modal_button.click();
-        let modal = _converse.api.modal.get('user-details-modal');
-        await u.waitUntil(() => u.isVisible(modal.el), 2000);
+        let modal = _converse.api.modal.get('converse-user-details-modal');
+        await u.waitUntil(() => u.isVisible(modal), 2000);
         spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true));
 
         spyOn(view.model.contact, 'removeFromRoster').and.callFake((callback, errback) => errback());
-        let remove_contact_button = modal.el.querySelector('button.remove-contact');
+        let remove_contact_button = modal.querySelector('button.remove-contact');
         expect(u.isVisible(remove_contact_button)).toBeTruthy();
         remove_contact_button.click();
-        await u.waitUntil(() => !u.isVisible(modal.el))
+        await u.waitUntil(() => !u.isVisible(modal))
         await u.waitUntil(() => u.isVisible(document.querySelector('.alert-danger')), 2000);
 
         const header = document.querySelector('.alert-danger .modal-title');
@@ -62,14 +62,14 @@ describe("The User Details Modal", function () {
         document.querySelector('.alert-danger  button.close').click();
         show_modal_button = view.querySelector('.show-user-details-modal');
         show_modal_button.click();
-        modal = _converse.api.modal.get('user-details-modal');
-        await u.waitUntil(() => u.isVisible(modal.el), 2000)
+        modal = _converse.api.modal.get('converse-user-details-modal');
+        await u.waitUntil(() => u.isVisible(modal), 2000)
 
         show_modal_button = view.querySelector('.show-user-details-modal');
         show_modal_button.click();
-        await u.waitUntil(() => u.isVisible(modal.el), 2000)
+        await u.waitUntil(() => u.isVisible(modal), 2000)
 
-        remove_contact_button = modal.el.querySelector('button.remove-contact');
+        remove_contact_button = modal.querySelector('button.remove-contact');
         expect(u.isVisible(remove_contact_button)).toBeTruthy();
     }));
 });

+ 23 - 48
src/modals/user-details.js → src/shared/modals/user-details.js

@@ -1,36 +1,17 @@
-import BootstrapModal from "plugins/modal/base.js";
+import BaseModal from "plugins/modal/modal.js";
 import log from "@converse/headless/log";
-import tpl_user_details_modal from "./templates/user-details.js";
+import { tpl_user_details_modal, tpl_footer } from "./templates/user-details.js";
 import { __ } from 'i18n';
-import { _converse, api, converse } from "@converse/headless/core";
+import { api, converse } from "@converse/headless/core";
+import { removeContact } from 'plugins/rosterview/utils.js';
 
 const u = converse.env.utils;
 
 
-function removeContact (contact) {
-    contact.removeFromRoster(
-        () => contact.destroy(),
-        (e) => {
-            e && log.error(e);
-            api.alert('error', __('Error'), [
-                __('Sorry, there was an error while trying to remove %1$s as a contact.',
-                contact.getDisplayName())
-            ]);
-        }
-    );
-}
-
-
-const UserDetailsModal = BootstrapModal.extend({
-    id: 'user-details-modal',
-    persistent: true,
-
-    events: {
-        'click button.refresh-contact': 'refreshContact',
-    },
+export default class UserDetailsModal extends BaseModal {
 
     initialize () {
-        BootstrapModal.prototype.initialize.apply(this, arguments);
+        super.initialize();
         this.model.rosterContactAdded.then(() => this.registerContactEventHandlers());
         this.listenTo(this.model, 'change', this.render);
         this.registerContactEventHandlers();
@@ -41,23 +22,19 @@ const UserDetailsModal = BootstrapModal.extend({
          * @example _converse.api.listen.on('userDetailsModalInitialized', (chatbox) => { ... });
          */
         api.trigger('userDetailsModalInitialized', this.model);
-    },
+    }
 
-    toHTML () {
-        const vcard = this.model?.vcard;
-        const vcard_json = vcard ? vcard.toJSON() : {};
-        return tpl_user_details_modal(Object.assign(
-            this.model.toJSON(),
-            vcard_json, {
-            '_converse': _converse,
-            'allow_contact_removal': api.settings.get('allow_contact_removal'),
-            'display_name': this.model.getDisplayName(),
-            'is_roster_contact': this.model.contact !== undefined,
-            'removeContact': ev => this.removeContact(ev),
-            'view': this,
-            'utils': u
-        }));
-    },
+    renderModal () {
+        return tpl_user_details_modal(this);
+    }
+
+    renderModalFooter () {
+        return tpl_footer(this);
+    }
+
+    getModalTitle () {
+        return this.model.getDisplayName();
+    }
 
     registerContactEventHandlers () {
         if (this.model.contact !== undefined) {
@@ -68,7 +45,7 @@ const UserDetailsModal = BootstrapModal.extend({
                 this.render();
             });
         }
-    },
+    }
 
     async refreshContact (ev) {
         if (ev && ev.preventDefault) { ev.preventDefault(); }
@@ -81,7 +58,7 @@ const UserDetailsModal = BootstrapModal.extend({
             this.alert(__('Sorry, something went wrong while trying to refresh'), 'danger');
         }
         u.removeClass('fa-spin', refresh_icon);
-    },
+    }
 
     async removeContact (ev) {
         ev?.preventDefault?.();
@@ -94,9 +71,7 @@ const UserDetailsModal = BootstrapModal.extend({
             setTimeout(() => removeContact(this.model.contact), 1);
             this.modal.hide();
         }
-    },
-});
-
-_converse.UserDetailsModal = UserDetailsModal;
+    }
+}
 
-export default UserDetailsModal;
+api.elements.define('converse-user-details-modal', UserDetailsModal);

+ 5 - 5
src/shared/tests/mock.js

@@ -140,13 +140,13 @@ async function openChatRoomViaModal (_converse, jid, nick='') {
     await openControlBox(_converse);
     document.querySelector('converse-rooms-list .show-add-muc-modal').click();
     closeControlBox(_converse);
-    const modal = _converse.api.modal.get('add-chatroom-modal');
-    await u.waitUntil(() => u.isVisible(modal.el), 1500)
-    modal.el.querySelector('input[name="chatroom"]').value = jid;
+    const modal = _converse.api.modal.get('converse-add-muc-modal');
+    await u.waitUntil(() => u.isVisible(modal), 1500)
+    modal.querySelector('input[name="chatroom"]').value = jid;
     if (nick) {
-        modal.el.querySelector('input[name="nickname"]').value = nick;
+        modal.querySelector('input[name="nickname"]').value = nick;
     }
-    modal.el.querySelector('form input[type="submit"]').click();
+    modal.querySelector('form input[type="submit"]').click();
     await u.waitUntil(() => _converse.chatboxviews.get(jid), 1000);
     return _converse.chatboxviews.get(jid);
 }

+ 2 - 1
src/utils/html.js

@@ -74,7 +74,8 @@ function slideOutWrapup (el) {
     el.style.height = '';
 }
 
-function getFileName (uri) {
+export function getFileName (url) {
+    const uri = getURI(url);
     try {
         return decodeURI(uri.filename());
     } catch (error) {

+ 1 - 0
webpack.html

@@ -9,6 +9,7 @@
     <script src="3rdparty/libsignal-protocol.js"></script>
     <link rel="manifest" href="./manifest.json">
     <link rel="shortcut icon" type="image/ico" href="favicon.ico"/>
+    <script src="https://cdn.conversejs.org/3rdparty/libsignal-protocol.min.js"></script>
 </head>
 <body class="reset"></body>
 <script>

Some files were not shown because too many files changed in this diff