Browse Source

Add api for managing modals

Set passed-in properties on modal
JC Brand 4 years ago
parent
commit
1a161ad2c7

+ 1 - 1
karma.conf.js

@@ -24,6 +24,7 @@ module.exports = function(config) {
       { pattern: "node_modules/sinon/pkg/sinon.js", type: 'module' },
       { pattern: "spec/mock.js", type: 'module' },
 
+      { pattern: "spec/user-details-modal.js", type: 'module' },
       { pattern: "spec/spoilers.js", type: 'module' },
       { pattern: "spec/emojis.js", type: 'module' },
       { pattern: "spec/muclist.js", type: 'module' },
@@ -44,7 +45,6 @@ module.exports = function(config) {
       { pattern: "spec/controlbox.js", type: 'module' },
       { pattern: "spec/roster.js", type: 'module' },
       { pattern: "spec/chatbox.js", type: 'module' },
-      { pattern: "spec/user-details-modal.js", type: 'module' },
       { pattern: "spec/messages.js", type: 'module' },
       { pattern: "spec/corrections.js", type: 'module' },
       { pattern: "spec/styling.js", type: 'module' },

+ 6 - 7
spec/controlbox.js

@@ -154,8 +154,7 @@ describe("The Controlbox", function () {
             await mock.openControlBox(_converse);
             var cbview = _converse.chatboxviews.get('controlbox');
             cbview.el.querySelector('.change-status').click()
-            var modal = _converse.xmppstatusview.status_modal;
-
+            const modal = _converse.api.modal.get('modal-status-change');
             await u.waitUntil(() => u.isVisible(modal.el), 1000);
             const view = _converse.xmppstatusview;
             modal.el.querySelector('label[for="radio-busy"]').click(); // Change status to "dnd"
@@ -183,7 +182,7 @@ describe("The Controlbox", function () {
             await mock.openControlBox(_converse);
             const cbview = _converse.chatboxviews.get('controlbox');
             cbview.el.querySelector('.change-status').click()
-            const modal = _converse.xmppstatusview.status_modal;
+            const modal = _converse.api.modal.get('modal-status-change');
 
             await u.waitUntil(() => u.isVisible(modal.el), 1000);
             const view = _converse.xmppstatusview;
@@ -219,7 +218,7 @@ describe("The 'Add Contact' widget", function () {
 
         const cbview = _converse.chatboxviews.get('controlbox');
         cbview.el.querySelector('.add-contact').click()
-        const modal = _converse.rosterview.add_contact_modal;
+        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);
 
@@ -252,7 +251,7 @@ describe("The 'Add Contact' widget", function () {
         mock.openControlBox(_converse);
         const cbview = _converse.chatboxviews.get('controlbox');
         cbview.el.querySelector('.add-contact').click()
-        const modal = _converse.rosterview.add_contact_modal;
+        const modal = _converse.api.modal.get('add-contact-modal');
         expect(modal.jid_auto_complete).toBe(undefined);
         expect(modal.name_auto_complete).toBe(undefined);
 
@@ -299,7 +298,7 @@ describe("The 'Add Contact' widget", function () {
 
         const cbview = _converse.chatboxviews.get('controlbox');
         cbview.el.querySelector('.add-contact').click()
-        const modal = _converse.rosterview.add_contact_modal;
+        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
@@ -369,7 +368,7 @@ describe("The 'Add Contact' widget", function () {
 
         const cbview = _converse.chatboxviews.get('controlbox');
         cbview.el.querySelector('.add-contact').click()
-        modal = _converse.rosterview.add_contact_modal;
+        modal = _converse.api.modal.get('add-contact-modal');
         await u.waitUntil(() => u.isVisible(modal.el), 1000);
 
         expect(modal.jid_auto_complete).toBe(undefined);

+ 1 - 1
spec/mock.js

@@ -152,7 +152,7 @@ window.addEventListener('converse-loaded', () => {
         const roomspanel = view.roomspanel;
         roomspanel.el.querySelector('.show-add-muc-modal').click();
         mock.closeControlBox(_converse);
-        const modal = roomspanel.add_room_modal;
+        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;
         if (nick) {

+ 10 - 10
spec/modtools.js

@@ -1,4 +1,4 @@
-/*global mock */
+/*global mock, converse */
 
 const _ = converse.env._;
 const $iq = converse.env.$iq;
@@ -8,13 +8,13 @@ const Strophe = converse.env.Strophe;
 const u = converse.env.utils;
 
 
-async function openModtools (view) {
+async function openModtools (_converse, view) {
     const textarea = view.el.querySelector('.chat-textarea');
     textarea.value = '/modtools';
     const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
     view.onKeyDown(enter);
     await u.waitUntil(() => view.showModeratorToolsModal.calls.count());
-    const modal = view.modtools_modal;
+    const modal = _converse.api.modal.get('converse-modtools-modal');
     await u.waitUntil(() => u.isVisible(modal.el), 1000);
     return modal;
 }
@@ -40,7 +40,7 @@ describe("The groupchat moderator tool", function () {
         const view = _converse.chatboxviews.get(muc_jid);
         await u.waitUntil(() => (view.model.occupants.length === 5), 1000);
 
-        const modal = await openModtools(view);
+        const modal = await openModtools(_converse, view);
         let tab = modal.el.querySelector('#affiliations-tab');
         // Clear so that we don't match older stanzas
         _converse.connection.IQ_stanzas = [];
@@ -163,7 +163,7 @@ describe("The groupchat moderator tool", function () {
 
         // Clear so that we don't match older stanzas
         _converse.connection.IQ_stanzas = [];
-        const modal = await openModtools(view);
+        const modal = await openModtools(_converse, view);
         const select = modal.el.querySelector('.select-affiliation');
         expect(select.value).toBe('owner');
         select.value = 'member';
@@ -270,7 +270,7 @@ describe("The groupchat moderator tool", function () {
         view.onKeyDown(enter);
         await u.waitUntil(() => view.showModeratorToolsModal.calls.count());
 
-        const modal = view.modtools_modal;
+        const modal = _converse.api.modal.get('converse-modtools-modal');
         await u.waitUntil(() => u.isVisible(modal.el), 1000);
 
         const tab = modal.el.querySelector('#roles-tab');
@@ -326,7 +326,7 @@ describe("The groupchat moderator tool", function () {
         await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
         const view = _converse.chatboxviews.get(muc_jid);
         await u.waitUntil(() => (view.model.occupants.length === 5));
-        const modal = await openModtools(view);
+        const modal = await openModtools(_converse, view);
         const tab = modal.el.querySelector('#affiliations-tab');
         // Clear so that we don't match older stanzas
         _converse.connection.IQ_stanzas = [];
@@ -375,7 +375,7 @@ describe("The groupchat moderator tool", function () {
         await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
         const view = _converse.chatboxviews.get(muc_jid);
         await u.waitUntil(() => (view.model.occupants.length === 2));
-        const modal = await openModtools(view);
+        const modal = await openModtools(_converse, view);
         // Clear so that we don't match older stanzas
         _converse.connection.IQ_stanzas = [];
 
@@ -443,7 +443,7 @@ describe("The groupchat moderator tool", function () {
         await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members);
         const view = _converse.chatboxviews.get(muc_jid);
         await u.waitUntil(() => (view.model.occupants.length === 3));
-        const modal = await openModtools(view);
+        const modal = await openModtools(_converse, view);
         const tab = modal.el.querySelector('#affiliations-tab');
         // Clear so that we don't match older stanzas
         _converse.connection.IQ_stanzas = [];
@@ -481,7 +481,7 @@ describe("The groupchat moderator tool", function () {
         view.onKeyDown(enter);
         await u.waitUntil(() => view.showModeratorToolsModal.calls.count());
 
-        const modal = view.modtools_modal;
+        const modal = _converse.api.modal.get('converse-modtools-modal');
         const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});
 
         expect(modal.getAssignableAffiliations(occupant)).toEqual(['owner', 'admin', 'member', 'outcast', 'none']);

+ 11 - 11
spec/muc.js

@@ -1973,7 +1973,7 @@ describe("Groupchats", function () {
             await u.waitUntil(() => view.el.querySelector('.open-invite-modal'));
 
             view.el.querySelector('.open-invite-modal').click();
-            const modal = view.muc_invite_modal;
+            const modal = _converse.api.modal.get('muc-invite-modal');
             await u.waitUntil(() => u.isVisible(modal.el), 1000)
 
             expect(modal.el.querySelectorAll('#invitee_jids').length).toBe(1);
@@ -2456,7 +2456,7 @@ describe("Groupchats", function () {
 
             const info_el = view.el.querySelector(".show-room-details-modal");
             info_el.click();
-            const  modal = view.model.room_details_modal;
+            const modal = _converse.api.modal.get('room-details-modal');
             await u.waitUntil(() => u.isVisible(modal.el), 1000);
 
             let features_list = modal.el.querySelector('.features-list');
@@ -4629,7 +4629,7 @@ describe("Groupchats", function () {
             const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
             roomspanel.el.querySelector('.show-add-muc-modal').click();
             mock.closeControlBox(_converse);
-            const modal = roomspanel.add_room_modal;
+            const modal = _converse.api.modal.get('add-chatroom-modal');
             await u.waitUntil(() => u.isVisible(modal.el), 1000)
 
             let label_name = modal.el.querySelector('label[for="chatroom"]');
@@ -4670,7 +4670,7 @@ describe("Groupchats", function () {
             const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
             roomspanel.el.querySelector('.show-add-muc-modal').click();
             mock.closeControlBox(_converse);
-            const modal = roomspanel.add_room_modal;
+            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"]');
             name_input.value = 'lounge@montague.lit';
@@ -4693,7 +4693,7 @@ describe("Groupchats", function () {
             const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
             roomspanel.el.querySelector('.show-add-muc-modal').click();
             mock.closeControlBox(_converse);
-            const modal = roomspanel.add_room_modal;
+            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"]');
             expect(label_nick.textContent.trim()).toBe('Nickname:');
@@ -4712,7 +4712,7 @@ describe("Groupchats", function () {
             const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
             roomspanel.el.querySelector('.show-add-muc-modal').click();
             mock.closeControlBox(_converse);
-            const modal = roomspanel.add_room_modal;
+            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"]');
             expect(label_nick.textContent.trim()).toBe('Nickname:');
@@ -4729,7 +4729,7 @@ describe("Groupchats", function () {
             await mock.openControlBox(_converse);
             const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
             roomspanel.el.querySelector('.show-add-muc-modal').click();
-            const modal = roomspanel.add_room_modal;
+            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');
             spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
@@ -4769,7 +4769,7 @@ describe("Groupchats", function () {
             await mock.openControlBox(_converse);
             const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
             roomspanel.el.querySelector('.show-add-muc-modal').click();
-            const modal = roomspanel.add_room_modal;
+            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');
             spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
@@ -4812,7 +4812,7 @@ describe("Groupchats", function () {
             const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
             roomspanel.el.querySelector('.show-list-muc-modal').click();
             mock.closeControlBox(_converse);
-            const modal = roomspanel.muc_list_modal;
+            const modal = _converse.api.modal.get('list-chatrooms-modal');
             await u.waitUntil(() => u.isVisible(modal.el), 1000);
             spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
             roomspanel.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
@@ -4889,7 +4889,7 @@ describe("Groupchats", function () {
             const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
             roomspanel.el.querySelector('.show-list-muc-modal').click();
             mock.closeControlBox(_converse);
-            const modal = roomspanel.muc_list_modal;
+            const modal = _converse.api.modal.get('list-chatrooms-modal');
             await u.waitUntil(() => u.isVisible(modal.el), 1000);
             const server_input = modal.el.querySelector('input[name="server"]');
             expect(server_input.value).toBe('muc.example.org');
@@ -4906,7 +4906,7 @@ describe("Groupchats", function () {
             const roomspanel = _converse.chatboxviews.get('controlbox').roomspanel;
             roomspanel.el.querySelector('.show-list-muc-modal').click();
             mock.closeControlBox(_converse);
-            const modal = roomspanel.muc_list_modal;
+            const modal = _converse.api.modal.get('list-chatrooms-modal');
             await u.waitUntil(() => u.isVisible(modal.el), 1000);
             spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
             roomspanel.delegateEvents(); // We need to rebind all events otherwise our spy won't be called

+ 1 - 1
spec/muclist.js

@@ -204,7 +204,7 @@ describe("A groupchat shown in the groupchats list", function () {
         const info_el = _converse.rooms_list_view.el.querySelector(".room-info");
         info_el.click();
 
-        const  modal = view.model.room_details_modal;
+        const modal = _converse.api.modal.get('room-details-modal');
         await u.waitUntil(() => u.isVisible(modal.el), 1000);
         let els = modal.el.querySelectorAll('p.room-info');
         expect(els[0].textContent).toBe("Name: A Dark Cave")

+ 1 - 1
spec/omemo.js

@@ -1463,7 +1463,7 @@ describe("The OMEMO module", function() {
         const view = _converse.chatboxviews.get(contact_jid);
         const show_modal_button = view.el.querySelector('.show-user-details-modal');
         show_modal_button.click();
-        const modal = view.user_details_modal;
+        const modal = _converse.api.modal.get('user-details-modal');
         await u.waitUntil(() => u.isVisible(modal.el), 1000);
         let iq_stanza = await u.waitUntil(() => deviceListFetched(_converse, contact_jid));
         expect(Strophe.serialize(iq_stanza)).toBe(

+ 5 - 3
spec/presence.js

@@ -1,4 +1,5 @@
-/*global mock */
+/*global mock, converse */
+
 // See: https://xmpp.org/rfcs/rfc3921.html
 
 const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
@@ -76,7 +77,7 @@ describe("A sent presence stanza", function () {
         const cbview = _converse.chatboxviews.get('controlbox');
         const change_status_el = await u.waitUntil(() => cbview.el.querySelector('.change-status'));
         change_status_el.click()
-        const modal = _converse.xmppstatusview.status_modal;
+        let modal = _converse.api.modal.get('modal-status-change');
         await u.waitUntil(() => u.isVisible(modal.el), 1000);
         const msg = 'My custom status';
         modal.el.querySelector('input[name="status_message"]').value = msg;
@@ -90,10 +91,11 @@ describe("A sent presence stanza", function () {
                     `<priority>0</priority>`+
                     `<c hash="sha-1" node="https://conversejs.org" ver="PxXfr6uz8ClMWIga0OB/MhKNH/M=" xmlns="http://jabber.org/protocol/caps"/>`+
                     `</presence>`)
-
         await u.waitUntil(() => modal.el.getAttribute('aria-hidden') === "true");
         await u.waitUntil(() => !u.isVisible(modal.el));
+
         cbview.el.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();

+ 1 - 1
spec/protocol.js

@@ -66,7 +66,7 @@ describe("The Protocol", function () {
             });
 
             cbview.el.querySelector('.add-contact').click()
-            const modal = _converse.rosterview.add_contact_modal;
+            const modal = _converse.api.modal.get('add-contact-modal');
             await u.waitUntil(() => u.isVisible(modal.el), 1000);
             spyOn(modal, "addContactFromForm").and.callThrough();
             modal.delegateEvents();

+ 4 - 3
spec/user-details-modal.js

@@ -18,7 +18,7 @@ describe("The User Details Modal", function () {
         const view = _converse.chatboxviews.get(contact_jid);
         let show_modal_button = view.el.querySelector('.show-user-details-modal');
         show_modal_button.click();
-        const modal = view.user_details_modal;
+        const modal = _converse.api.modal.get('user-details-modal');
         await u.waitUntil(() => u.isVisible(modal.el), 1000);
         spyOn(window, 'confirm').and.returnValue(true);
         spyOn(view.model.contact, 'removeFromRoster').and.callFake(callback => callback());
@@ -45,7 +45,7 @@ describe("The User Details Modal", function () {
         const view = _converse.chatboxviews.get(contact_jid);
         let show_modal_button = view.el.querySelector('.show-user-details-modal');
         show_modal_button.click();
-        const modal = view.user_details_modal;
+        let modal = _converse.api.modal.get('user-details-modal');
         await u.waitUntil(() => u.isVisible(modal.el), 2000);
         spyOn(window, 'confirm').and.returnValue(true);
 
@@ -53,7 +53,7 @@ describe("The User Details Modal", function () {
         let remove_contact_button = modal.el.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(document.querySelector('.alert-danger')), 2000);
 
         const header = document.querySelector('.alert-danger .modal-title');
@@ -63,6 +63,7 @@ describe("The User Details Modal", function () {
         document.querySelector('.alert-danger  button.close').click();
         show_modal_button = view.el.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)
 
         show_modal_button = view.el.querySelector('.show-user-details-modal');

+ 2 - 7
src/components/message-body.js

@@ -15,14 +15,9 @@ export default class MessageBody extends CustomElement {
         }
     }
 
-    showImageModal (ev) {
+    showImageModal (ev) { // eslint-disable-line class-methods-use-this
         ev.preventDefault();
-        if (this.image_modal === undefined) {
-            this.image_modal = new ImageModal();
-        }
-        this.image_modal.src = ev.target.src;
-        this.image_modal.render();
-        this.image_modal.show(ev);
+        api.modal.create(ImageModal, {'src': ev.target.src}, ev).show(ev);
     }
 
     render () {

+ 1 - 4
src/converse-chatview.js

@@ -238,10 +238,7 @@ export const ChatBoxView = View.extend({
 
     showUserDetailsModal (ev) {
         ev.preventDefault();
-        if (this.user_details_modal === undefined) {
-            this.user_details_modal = new UserDetailsModal({model: this.model});
-        }
-        this.user_details_modal.show(ev);
+        api.modal.show(UserDetailsModal, {model: this.model}, ev);
     },
 
     onDragOver (evt) {

+ 75 - 19
src/converse-modal.js

@@ -7,15 +7,77 @@ import Alert from './modals/alert.js';
 import BootstrapModal from './modals/base.js';
 import Confirm from './modals/confirm.js';
 import { Model } from '@converse/skeletor/src/model.js';
-import { _converse, converse } from "@converse/headless/converse-core";
+import { _converse, api, converse } from "@converse/headless/converse-core";
 
 
 converse.env.BootstrapModal = BootstrapModal; // expose to plugins
 
 
-let alert;
+let modals = [];
+
 
 const modal_api = {
+
+    /**
+     * API namespace for methods relating to modals
+     * @namespace _converse.api.modal
+     * @memberOf _converse.api
+     */
+    modal: {
+        /**
+         * Shows a modal of type `ModalClass` to the user.
+         * 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 { [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);
+            modal.show(ev);
+            return modal;
+        },
+
+        /**
+         * Return a modal with the passed-in identifier, if it exists.
+         * @param { String } id
+         */
+        get (id) {
+            return modals.filter(m => m.id == id).pop();
+        },
+
+        /**
+         * Create a modal of the passed-in type.
+         * @param { Class } ModalClass
+         * @param { [Object] } properties - Optional properties that will be
+         *  set on the modal instance.
+         */
+        create (ModalClass, properties) {
+            const modal = new ModalClass(properties);
+            modals.push(modal);
+            return modal;
+        },
+
+        /**
+         * Remove a particular modal
+         * @param { View } modal
+         */
+        remove (modal) {
+            modals = modals.filter(m => m !== modal);
+            modal.remove();
+        },
+
+        /**
+         * Remove all modals
+         */
+        removeAll () {
+            modals.forEach(m => m.remove());
+            modals = [];
+        }
+    },
+
     /**
      * Show a confirm modal to the user.
      * @method _converse.api.confirm
@@ -101,22 +163,13 @@ const modal_api = {
             level = 'alert-warning';
         }
 
-        if (alert === undefined) {
-            const model = new Model({
-                'title': title,
-                'messages': messages,
-                'level': level,
-                'type': 'alert'
-            })
-            alert = new Alert({model});
-        } else {
-            alert.model.set({
-                'title': title,
-                'messages': messages,
-                'level': level
-            });
-        }
-        alert.show();
+        const model = new Model({
+            'title': title,
+            'messages': messages,
+            'level': level,
+            'type': 'alert'
+        })
+        api.modal.show(Alert, {model});
     }
 }
 
@@ -124,12 +177,15 @@ const modal_api = {
 converse.plugins.add('converse-modal', {
 
     initialize () {
-        _converse.api.listen.on('disconnect', () => {
+        api.listen.on('disconnect', () => {
             const container = document.querySelector("#converse-modals");
             if (container) {
                 container.innerHTML = '';
             }
         });
+
+        api.listen.on('clearSession', () => api.modal.removeAll());
+
         Object.assign(_converse.api, modal_api);
     }
 });

+ 11 - 27
src/converse-muc-views.js

@@ -501,29 +501,24 @@ export const ChatRoomView = ChatBoxView.extend({
         if (!this.verifyRoles(['moderator'])) {
             return;
         }
-        if (typeof this.model.modtools_modal === 'undefined') {
-            const model = new Model({'affiliation': affiliation});
-            this.modtools_modal = new ModeratorToolsModal({model, _converse, 'chatroomview': this});
+        let modal = api.modal.get(ModeratorToolsModal.id);
+        if (modal) {
+            modal.model.set('affiliation', affiliation);
         } else {
-            this.modtools_modal.set('affiliation', affiliation);
+            const model = new Model({'affiliation': affiliation});
+            modal = api.modal.create(ModeratorToolsModal, {model, _converse, 'chatroomview': this});
         }
-        this.modtools_modal.show();
+        modal.show();
     },
 
     showRoomDetailsModal (ev) {
         ev.preventDefault();
-        if (this.model.room_details_modal === undefined) {
-            this.model.room_details_modal = new RoomDetailsModal({'model': this.model});
-        }
-        this.model.room_details_modal.show(ev);
+        api.modal.show(RoomDetailsModal, {'model': this.model}, ev);
     },
 
     showOccupantDetailsModal (ev, message) {
         ev.preventDefault();
-        if (this.model.occupant_modal === undefined) {
-            this.model.occupant_modal = new OccupantModal({'model': message.occupant});
-        }
-        this.model.occupant_modal.show(ev);
+        api.modal.show(OccupantModal, {'model': message.occupant}, ev);
     },
 
     showChatStateNotification (message) {
@@ -679,12 +674,7 @@ export const ChatRoomView = ChatBoxView.extend({
 
     showInviteModal (ev) {
         ev.preventDefault();
-        if (this.muc_invite_modal === undefined) {
-            this.muc_invite_modal = new MUCInviteModal({'model': new Model()});
-            // TODO: remove once we have API for sending direct invite
-            this.muc_invite_modal.chatroomview = this;
-        }
-        this.muc_invite_modal.show(ev);
+        api.modal.show(MUCInviteModal, {'model': new Model(), 'chatroomview': this}, ev);
     },
 
 
@@ -1365,17 +1355,11 @@ export const RoomsPanel = View.extend({
     },
 
     showAddRoomModal (ev) {
-        if (this.add_room_modal === undefined) {
-            this.add_room_modal = new AddMUCModal({'model': this.model});
-        }
-        this.add_room_modal.show(ev);
+        api.modal.show(AddMUCModal, {'model': this.model}, ev);
     },
 
     showMUCListModal(ev) {
-        if (this.muc_list_modal === undefined) {
-            this.muc_list_modal = new MUCListModal({'model': this.model});
-        }
-        this.muc_list_modal.show(ev);
+        api.modal.show(MUCListModal, {'model': this.model}, ev);
     }
 });
 

+ 3 - 12
src/converse-profile.js

@@ -61,26 +61,17 @@ converse.plugins.add('converse-profile', {
 
             showProfileModal (ev) {
                 ev.preventDefault();
-                if (this.profile_modal === undefined) {
-                    this.profile_modal = new _converse.ProfileModal({model: this.model});
-                }
-                this.profile_modal.show(ev);
+                api.modal.show(_converse.ProfileModal, {model: this.model}, ev);
             },
 
             showStatusChangeModal (ev) {
                 ev.preventDefault();
-                if (this.status_modal === undefined) {
-                    this.status_modal = new _converse.ChatStatusModal({model: this.model});
-                }
-                this.status_modal.show(ev);
+                api.modal.show(_converse.ChatStatusModal, {model: this.model}, ev);
             },
 
             showUserSettingsModal(ev) {
                 ev.preventDefault();
-                if (this.user_settings_modal === undefined) {
-                    this.user_settings_modal = new UserSettingsModal({model: this.model, _converse});
-                }
-                this.user_settings_modal.show(ev);
+                api.modal.show(UserSettingsModal, {model: this.model, _converse}, ev);
             },
 
             logOut (ev) {

+ 1 - 5
src/converse-roomslist.js

@@ -109,10 +109,7 @@ converse.plugins.add('converse-roomslist', {
                 const jid = ev.target.getAttribute('data-room-jid');
                 const room = _converse.chatboxes.get(jid);
                 ev.preventDefault();
-                if (room.room_details_modal === undefined) {
-                    room.room_details_modal = new RoomDetailsModal({'model': room});
-                }
-                room.room_details_modal.show(ev);
+                api.modal.show(RoomDetailsModal, {'model': room}, ev);
             },
 
             async openRoom (ev) {
@@ -183,4 +180,3 @@ converse.plugins.add('converse-roomslist', {
         api.listen.on('reconnected', initRoomsListView);
     }
 });
-

+ 1 - 4
src/converse-rosterview.js

@@ -629,10 +629,7 @@ converse.plugins.add('converse-rosterview', {
             },
 
             showAddContactModal (ev) {
-                if (this.add_contact_modal === undefined) {
-                    this.add_contact_modal = new _converse.AddContactModal({'model': new Model()});
-                }
-                this.add_contact_modal.show(ev);
+                api.modal.show(_converse.AddContactModal, {'model': new Model()}, ev);
             },
 
             createRosterFilter () {

+ 1 - 0
src/modals/alert.js

@@ -4,6 +4,7 @@ import { __ } from '../i18n';
 
 
 const Alert = BootstrapModal.extend({
+    id: 'alert-modal',
 
     initialize () {
         BootstrapModal.prototype.initialize.apply(this, arguments);

+ 9 - 3
src/modals/base.js

@@ -2,7 +2,7 @@ import bootstrap from "bootstrap.native";
 import log from "@converse/headless/log";
 import tpl_alert_component from "templates/alert.js";
 import { View } from '@converse/skeletor/src/view.js';
-import { _converse, converse } from "@converse/headless/converse-core";
+import { _converse, api, converse } from "@converse/headless/converse-core";
 import { render } from 'lit-html';
 
 const { sizzle } = converse.env;
@@ -16,9 +16,15 @@ const BaseModal = View.extend({
         'click  .nav-item .nav-link': 'switchTab'
     },
 
-    initialize () {
+    initialize (options) {
+        if (!this.id) {
+            throw new Error("Each modal class must have a unique id attribute");
+        }
         this.render()
 
+        // Allow properties to be set via passed in options
+        Object.assign(this, options);
+
         this.el.setAttribute('tabindex', '-1');
         this.el.setAttribute('role', 'dialog');
         this.el.setAttribute('aria-hidden', 'true');
@@ -36,7 +42,7 @@ const BaseModal = View.extend({
 
     onHide () {
         u.removeClass('selected', this.trigger_el);
-        !this.persistent && this.remove();
+        !this.persistent && api.modal.remove(this);
     },
 
     insertIntoDOM () {

+ 1 - 0
src/modals/confirm.js

@@ -6,6 +6,7 @@ const u = converse.env.utils;
 
 
 const Confirm = BootstrapModal.extend({
+    id: 'confirm-modal',
     events: {
         'submit .confirm': 'onConfimation'
     },

+ 2 - 0
src/modals/image.js

@@ -3,6 +3,8 @@ import tpl_image_modal from "./templates/image.js";
 
 
 export default BootstrapModal.extend({
+    id: 'image-modal',
+
     toHTML () {
         return tpl_image_modal({
             'src': this.src,

+ 1 - 1
src/modals/message-versions.js

@@ -3,7 +3,7 @@ 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.toJSON());
     }

+ 1 - 0
src/modals/moderator-tools.js

@@ -12,6 +12,7 @@ let _converse;
 
 
 export default BootstrapModal.extend({
+    id: "converse-modtools-modal",
     persistent: true,
 
     initialize (attrs) {

+ 18 - 15
src/modals/user-details.js

@@ -7,7 +7,22 @@ import { _converse, api, converse } from "@converse/headless/converse-core";
 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: {
@@ -74,23 +89,11 @@ const UserDetailsModal = BootstrapModal.extend({
         if (!api.settings.get('allow_contact_removal')) { return; }
         const result = confirm(__("Are you sure you want to remove this contact?"));
         if (result === true) {
-            this.modal.hide();
-            // XXX: This is annoying but necessary to get tests to pass.
-            // The `dismissHandler` in bootstrap.native tries to
+            // XXX: The `dismissHandler` in bootstrap.native tries to
             // reference the remove button after it's been cleared from
             // the DOM, so we delay removing the contact to give it time.
-            setTimeout(() => {
-                this.model.contact.removeFromRoster(
-                    () => this.model.contact.destroy(),
-                    (err) => {
-                        log.error(err);
-                        api.alert('error', __('Error'), [
-                            __('Sorry, there was an error while trying to remove %1$s as a contact.',
-                            this.model.contact.getDisplayName())
-                        ]);
-                    }
-                );
-            }, 1);
+            setTimeout(() => removeContact(this.model.contact), 1);
+            this.modal.hide();
         }
     },
 });