2
0
Эх сурвалжийг харах

Move modals and their templates into `./modals/`

JC Brand 4 жил өмнө
parent
commit
b18cc6bcc5
38 өөрчлөгдсөн 651 нэмэгдсэн , 603 устгасан
  1. 12 5
      package-lock.json
  2. 2 1
      spec/user-details-modal.js
  3. 101 245
      src/converse-modal.js
  4. 2 148
      src/converse-profile.js
  5. 3 135
      src/converse-rosterview.js
  6. 142 0
      src/modals/add-contact.js
  7. 4 3
      src/modals/add-muc.js
  8. 18 0
      src/modals/alert.js
  9. 84 0
      src/modals/base.js
  10. 64 0
      src/modals/chat-status.js
  11. 59 0
      src/modals/confirm.js
  12. 2 2
      src/modals/image.js
  13. 3 4
      src/modals/message-versions.js
  14. 3 3
      src/modals/moderator-tools.js
  15. 1 1
      src/modals/muc-commands.js
  16. 3 3
      src/modals/muc-details.js
  17. 2 4
      src/modals/muc-invite.js
  18. 3 2
      src/modals/muc-list.js
  19. 6 7
      src/modals/occupant.js
  20. 99 0
      src/modals/profile.js
  21. 2 2
      src/modals/templates/add-contact.js
  22. 1 1
      src/modals/templates/add-muc.js
  23. 1 1
      src/modals/templates/alert.js
  24. 1 2
      src/modals/templates/buttons.js
  25. 1 1
      src/modals/templates/chat-status.js
  26. 2 2
      src/modals/templates/image.js
  27. 3 3
      src/modals/templates/message-versions.js
  28. 3 3
      src/modals/templates/moderator-tools.js
  29. 2 2
      src/modals/templates/muc-details.js
  30. 2 2
      src/modals/templates/muc-invite.js
  31. 3 3
      src/modals/templates/muc-list.js
  32. 1 1
      src/modals/templates/occupant.js
  33. 4 4
      src/modals/templates/profile.js
  34. 1 1
      src/modals/templates/prompt.js
  35. 3 3
      src/modals/templates/user-details.js
  36. 3 3
      src/modals/templates/user-settings.js
  37. 3 3
      src/modals/user-details.js
  38. 2 3
      src/modals/user-settings.js

+ 12 - 5
package-lock.json

@@ -3144,7 +3144,8 @@
 			"dependencies": {
 				"filesize": {
 					"version": "6.1.0",
-					"resolved": false
+					"resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz",
+					"integrity": "sha512-LpCHtPQ3sFx67z+uh2HnSyWSLLu5Jxo21795uRDuar/EOuYWXib5EmPaGIBuSnRqH2IODiKA2k5re/K9OnN/Yg=="
 				},
 				"fs-extra": {
 					"version": "8.1.0",
@@ -3200,20 +3201,22 @@
 				},
 				"localforage": {
 					"version": "1.7.3",
-					"resolved": false,
+					"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.7.3.tgz",
+					"integrity": "sha512-1TulyYfc4udS7ECSBT2vwJksWbkwwTX8BzeUIiq8Y07Riy7bDAAnxDaPU/tWyOVmQAcWJIEIFP9lPfBGqVoPgQ==",
 					"requires": {
 						"lie": "3.1.1"
 					}
 				},
 				"pluggable.js": {
 					"version": "2.0.1",
-					"resolved": false,
+					"resolved": "https://registry.npmjs.org/pluggable.js/-/pluggable.js-2.0.1.tgz",
+					"integrity": "sha512-SBt6v6Tbp20Jf8hU0cpcc/+HBHGMY8/Q+yA6Ih0tBQE8tfdZ6U4PRG0iNvUUjLx/hVyOP53n0UfGBymlfaaXCg==",
 					"requires": {
 						"lodash": "^4.17.11"
 					}
 				},
 				"skeletor.js": {
-					"version": "0.0.1",
+					"version": "github:skeletorjs/skeletor#bf6d9c86f9fcf224fa9d9af5a25380b77aa4b561",
 					"from": "github:skeletorjs/skeletor#bf6d9c86f9fcf224fa9d9af5a25380b77aa4b561",
 					"requires": {
 						"lodash": "^4.17.14"
@@ -3221,7 +3224,11 @@
 				},
 				"strophe.js": {
 					"version": "github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f",
-					"from": "strophe.js@github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f"
+					"from": "strophe.js@github:strophe/strophejs#c4a94e59877c06dc2395f4ccbd26f3fee67a4c9f",
+					"requires": {
+						"abab": "^2.0.3",
+						"xmldom": "^0.1.27"
+					}
 				},
 				"twemoji": {
 					"version": "12.1.5",

+ 2 - 1
spec/user-details-modal.js

@@ -1,4 +1,4 @@
-/*global mock */
+/*global mock, converse */
 
 const u = converse.env.utils;
 
@@ -53,6 +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(document.querySelector('.alert-danger')), 2000);
 
         const header = document.querySelector('.alert-danger .modal-title');

+ 101 - 245
src/converse-modal.js

@@ -3,277 +3,133 @@
  * @copyright The Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  */
-import { View } from '@converse/skeletor/src/view.js';
+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 { render } from 'lit-html';
-import { __ } from './i18n';
-import bootstrap from "bootstrap.native";
-import { converse } from "@converse/headless/converse-core";
-import log from "@converse/headless/log";
-import tpl_alert_component from "templates/alert.js";
-import tpl_alert_modal from "templates/alert_modal.js";
-import tpl_prompt from "templates/prompt.js";
+import { _converse, converse } from "@converse/headless/converse-core";
 
-const { sizzle } = converse.env;
-const u = converse.env.utils;
 
-let _converse;
-
-
-export const BootstrapModal = View.extend({
-    className: "modal",
-    events: {
-        'click  .nav-item .nav-link': 'switchTab'
-    },
-
-    initialize () {
-        this.render()
-
-        this.el.setAttribute('tabindex', '-1');
-        this.el.setAttribute('role', 'dialog');
-        this.el.setAttribute('aria-hidden', 'true');
-        const label_id = this.el.querySelector('.modal-title').getAttribute('id');
-        label_id && this.el.setAttribute('aria-labelledby', label_id);
-
-        this.insertIntoDOM();
-        const Modal = bootstrap.Modal;
-        this.modal = new Modal(this.el, {
-            backdrop: true,
-            keyboard: true
-        });
-        this.el.addEventListener('hide.bs.modal', () => u.removeClass('selected', this.trigger_el), false);
-    },
-
-    insertIntoDOM () {
-        const container_el = _converse.chatboxviews.el.querySelector("#converse-modals");
-        container_el.insertAdjacentElement('beforeEnd', this.el);
-    },
+converse.env.BootstrapModal = BootstrapModal; // expose to plugins
 
-    switchTab (ev) {
-        ev.stopPropagation();
-        ev.preventDefault();
-        sizzle('.nav-link.active', this.el).forEach(el => {
-            u.removeClass('active', this.el.querySelector(el.getAttribute('href')));
-            u.removeClass('active', el);
-        });
-        u.addClass('active', ev.target);
-        u.addClass('active', this.el.querySelector(ev.target.getAttribute('href')))
-    },
 
-    alert (message, type='primary') {
-        const body = this.el.querySelector('.modal-alert');
-        if (body === null) {
-            log.error("Could not find a .modal-alert element in the modal to show an alert message in!");
-            return;
+let alert;
+
+const modal_api = {
+    /**
+     * Show a confirm modal to the user.
+     * @method _converse.api.confirm
+     * @param { String } title - The header text for the confirmation dialog
+     * @param { (String[]|String) } messages - The text to show to the user
+     * @param { Array<Field> } fields - An object representing a fields presented to the user.
+     * @property { String } Field.label - The form label for the input field.
+     * @property { String } Field.name - The name for the input field.
+     * @property { String } [Field.challenge] - A challenge value that must be provided by the user.
+     * @property { String } [Field.placeholder] - The placeholder for the input field.
+     * @property { Boolean} [Field.required] - Whether the field is required or not
+     * @returns { Promise<Array|false> } A promise which resolves with an array of
+     *  filled in fields or `false` if the confirm dialog was closed or canceled.
+     */
+    async confirm (title, messages=[], fields=[]) {
+        if (typeof messages === 'string') {
+            messages = [messages];
         }
-        // FIXME: Instead of adding the alert imperatively, we should
-        // find a way to let the modal rerender with an alert message
-        render(tpl_alert_component({'type': `alert-${type}`, 'message': message}), body);
-        const el = body.firstElementChild;
-        setTimeout(() => {
-            u.addClass('fade-out', el);
-            setTimeout(() => u.removeElement(el), 600);
-        }, 5000);
-    },
-
-    show (ev) {
-        if (ev) {
-            ev.preventDefault();
-            this.trigger_el = ev.target;
-            this.trigger_el.classList.add('selected');
+        const model = new Model({title, messages, fields, 'type': 'confirm'})
+        const confirm = new Confirm({model});
+        confirm.show();
+        let result;
+        try {
+            result = await confirm.confirmation;
+        } catch (e) {
+            result = false;
         }
-        this.modal.show();
-    }
-});
-
-converse.env.BootstrapModal = BootstrapModal; // expose to plugins
-
-export const Confirm = BootstrapModal.extend({
-    events: {
-        'submit .confirm': 'onConfimation'
-    },
-
-    initialize () {
-        this.confirmation = u.getResolveablePromise();
-        BootstrapModal.prototype.initialize.apply(this, arguments);
-        this.listenTo(this.model, 'change', this.render)
-        this.el.addEventListener('closed.bs.modal', () => this.confirmation.reject(), false);
+        confirm.remove();
+        return result;
     },
 
-    toHTML () {
-        return tpl_prompt(this.model.toJSON());
+    /**
+     * Show a prompt modal to the user.
+     * @method _converse.api.prompt
+     * @param { String } title - The header text for the prompt
+     * @param { (String[]|String) } messages - The prompt text to show to the user
+     * @param { String } placeholder - The placeholder text for the prompt input
+     * @returns { Promise<String|false> } A promise which resolves with the text provided by the
+     *  user or `false` if the user canceled the prompt.
+     */
+    async prompt (title, messages=[], placeholder='') {
+        if (typeof messages === 'string') {
+            messages = [messages];
+        }
+        const model = new Model({
+            title,
+            messages,
+            'fields': [{
+                'name': 'reason',
+                'placeholder': placeholder,
+            }],
+            'type': 'prompt'
+        })
+        const prompt = new Confirm({model});
+        prompt.show();
+        let result;
+        try {
+            result = (await prompt.confirmation).pop()?.value;
+        } catch (e) {
+            result = false;
+        }
+        prompt.remove();
+        return result;
     },
 
-    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;
+    /**
+     * Show an alert modal to the user.
+     * @method _converse.api.alert
+     * @param { ('info'|'warn'|'error') } type - The type of alert.
+     * @param { String } title - The header text for the alert.
+     * @param { (String[]|String) } messages - The alert text to show to the user.
+     */
+    alert (type, title, messages) {
+        if (typeof messages === 'string') {
+            messages = [messages];
+        }
+        let level;
+        if (type === 'error') {
+            level = 'alert-danger';
+        } else if (type === 'info') {
+            level = 'alert-info';
+        } else if (type === 'warn') {
+            level = 'alert-warning';
         }
-    },
 
-    onConfimation (ev) {
-        ev.preventDefault();
-        const form_data = new FormData(ev.target);
-        const fields = (this.model.get('fields') || [])
-            .map(field => {
-                const value = form_data.get(field.name).trim();
-                field.value = value;
-                if (field.challenge) {
-                    field.challenge_failed = (value !== field.challenge);
-                }
-                return field;
+        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
             });
-
-        if (fields.filter(c => c.challenge_failed).length) {
-            this.model.set('fields', fields);
-            // Setting an array doesn't trigger a change event
-            this.model.trigger('change');
-            return;
         }
-        this.confirmation.resolve(fields);
-        this.modal.hide();
+        alert.show();
     }
-});
-
-
-export const Alert = BootstrapModal.extend({
-    initialize () {
-        BootstrapModal.prototype.initialize.apply(this, arguments);
-        this.listenTo(this.model, 'change', this.render)
-    },
-
-    toHTML () {
-        return tpl_alert_modal(Object.assign({__}, this.model.toJSON()));
-    }
-});
+}
 
 
 converse.plugins.add('converse-modal', {
 
     initialize () {
-        _converse = this._converse
-
-        /************************ BEGIN Event Listeners ************************/
         _converse.api.listen.on('disconnect', () => {
             const container = document.querySelector("#converse-modals");
             if (container) {
                 container.innerHTML = '';
             }
         });
-
-
-        /************************ BEGIN API ************************/
-        // We extend the default converse.js API to add methods specific to MUC chat rooms.
-        let alert;
-
-        Object.assign(_converse.api, {
-            /**
-             * Show a confirm modal to the user.
-             * @method _converse.api.confirm
-             * @param { String } title - The header text for the confirmation dialog
-             * @param { (String[]|String) } messages - The text to show to the user
-             * @param { Array<Field> } fields - An object representing a fields presented to the user.
-             * @property { String } Field.label - The form label for the input field.
-             * @property { String } Field.name - The name for the input field.
-             * @property { String } [Field.challenge] - A challenge value that must be provided by the user.
-             * @property { String } [Field.placeholder] - The placeholder for the input field.
-             * @property { Boolean} [Field.required] - Whether the field is required or not
-             * @returns { Promise<Array|false> } A promise which resolves with an array of
-             *  filled in fields or `false` if the confirm dialog was closed or canceled.
-             */
-            async confirm (title, messages=[], fields=[]) {
-                if (typeof messages === 'string') {
-                    messages = [messages];
-                }
-                const model = new Model({title, messages, fields, 'type': 'confirm'})
-                const confirm = new Confirm({model});
-                confirm.show();
-                let result;
-                try {
-                    result = await confirm.confirmation;
-                } catch (e) {
-                    result = false;
-                }
-                confirm.remove();
-                return result;
-            },
-
-            /**
-             * Show a prompt modal to the user.
-             * @method _converse.api.prompt
-             * @param { String } title - The header text for the prompt
-             * @param { (String[]|String) } messages - The prompt text to show to the user
-             * @param { String } placeholder - The placeholder text for the prompt input
-             * @returns { Promise<String|false> } A promise which resolves with the text provided by the
-             *  user or `false` if the user canceled the prompt.
-             */
-            async prompt (title, messages=[], placeholder='') {
-                if (typeof messages === 'string') {
-                    messages = [messages];
-                }
-                const model = new Model({
-                    title,
-                    messages,
-                    'fields': [{
-                        'name': 'reason',
-                        'placeholder': placeholder,
-                    }],
-                    'type': 'prompt'
-                })
-                const prompt = new Confirm({model});
-                prompt.show();
-                let result;
-                try {
-                    result = (await prompt.confirmation).pop()?.value;
-                } catch (e) {
-                    result = false;
-                }
-                prompt.remove();
-                return result;
-            },
-
-            /**
-             * Show an alert modal to the user.
-             * @method _converse.api.alert
-             * @param { ('info'|'warn'|'error') } type - The type of alert.
-             * @param { String } title - The header text for the alert.
-             * @param { (String[]|String) } messages - The alert text to show to the user.
-             */
-            alert (type, title, messages) {
-                if (typeof messages === 'string') {
-                    messages = [messages];
-                }
-                let level;
-                if (type === 'error') {
-                    level = 'alert-danger';
-                } else if (type === 'info') {
-                    level = 'alert-info';
-                } else if (type === 'warn') {
-                    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();
-            }
-        });
+        Object.assign(_converse.api, modal_api);
     }
 });
-

+ 2 - 148
src/converse-profile.js

@@ -3,22 +3,16 @@
  * @copyright The Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  */
+import "modals/profile.js";
+import "modals/chat-status.js";
 import "@converse/headless/converse-status";
 import "@converse/headless/converse-vcard";
 import "converse-modal";
 import UserSettingsModal from "modals/user-settings";
-import bootstrap from "bootstrap.native";
-import log from "@converse/headless/log";
-import sizzle from 'sizzle';
-import tpl_chat_status_modal from "templates/chat_status_modal";
 import tpl_profile from "templates/profile.js";
-import tpl_profile_modal from "templates/profile_modal";
-import { BootstrapModal } from "./converse-modal.js";
 import { __ } from './i18n';
 import { _converse, api, converse } from "@converse/headless/converse-core";
 
-const u = converse.env.utils;
-
 
 converse.plugins.add('converse-profile', {
 
@@ -35,146 +29,6 @@ converse.plugins.add('converse-profile', {
         });
 
 
-        _converse.ProfileModal = BootstrapModal.extend({
-            id: "user-profile-modal",
-            events: {
-                'submit .profile-form': 'onFormSubmitted'
-            },
-
-            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 => { ... });
-                 */
-                api.trigger('profileModalInitialized', this.model);
-            },
-
-            toHTML () {
-                return tpl_profile_modal(Object.assign(
-                    this.model.toJSON(),
-                    this.model.vcard.toJSON(),
-                    this.getAvatarData(),
-                    { 'view': this }
-                ));
-            },
-
-            getAvatarData () {
-                const image_type = this.model.vcard.get('image_type');
-                const image_data = this.model.vcard.get('image');
-                const image = "data:" + image_type + ";base64," + image_data;
-                return {
-                    'height': 128,
-                    'width': 128,
-                    image,
-                };
-            },
-
-            afterRender () {
-                this.tabs = sizzle('.nav-item .nav-link', this.el).map(e => new bootstrap.Tab(e));
-            },
-
-            async setVCard (data) {
-                try {
-                    await api.vcard.set(_converse.bare_jid, data);
-                } catch (err) {
-                    log.fatal(err);
-                    this.alert([
-                        __("Sorry, an error happened while trying to save your profile data."),
-                        __("You can check your browser's developer console for any error output.")
-                    ].join(" "));
-                    return;
-                }
-                this.modal.hide();
-            },
-
-            onFormSubmitted (ev) {
-                ev.preventDefault();
-                const reader = new FileReader();
-                const form_data = new FormData(ev.target);
-                const image_file = form_data.get('image');
-                const data = {
-                    'fn': form_data.get('fn'),
-                    'nickname': form_data.get('nickname'),
-                    'role': form_data.get('role'),
-                    'email': form_data.get('email'),
-                    'url': form_data.get('url'),
-                };
-                if (!image_file.size) {
-                    Object.assign(data, {
-                        'image': this.model.vcard.get('image'),
-                        'image_type': this.model.vcard.get('image_type')
-                    });
-                    this.setVCard(data);
-                } else {
-                    reader.onloadend = () => {
-                        Object.assign(data, {
-                            'image': btoa(reader.result),
-                            'image_type': image_file.type
-                        });
-                        this.setVCard(data);
-                    };
-                    reader.readAsBinaryString(image_file);
-                }
-            }
-        });
-
-
-        _converse.ChatStatusModal = BootstrapModal.extend({
-            id: "modal-status-change",
-            events: {
-                "submit form#set-xmpp-status": "onFormSubmitted",
-                "click .clear-input": "clearStatusMessage"
-            },
-
-            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();
-                }, false);
-            },
-
-            clearStatusMessage (ev) {
-                if (ev && ev.preventDefault) {
-                    ev.preventDefault();
-                    u.hideElement(this.el.querySelector('.clear-input'));
-                }
-                const roster_filter = this.el.querySelector('input[name="status_message"]');
-                roster_filter.value = '';
-            },
-
-            onFormSubmitted (ev) {
-                ev.preventDefault();
-                const data = new FormData(ev.target);
-                this.model.save({
-                    'status_message': data.get('status_message'),
-                    'status': data.get('chat_status')
-                });
-                this.modal.hide();
-            }
-        });
-
         _converse.XMPPStatusView = _converse.ViewWithAvatar.extend({
             tagName: "div",
             events: {

+ 3 - 135
src/converse-rosterview.js

@@ -6,23 +6,21 @@
 import "@converse/headless/converse-chatboxes";
 import "@converse/headless/converse-roster";
 import "converse-modal";
+import "modals/add-contact.js";
 import log from "@converse/headless/log";
-import tpl_add_contact_modal from "templates/add_contact_modal.js";
 import tpl_group_header from "templates/group_header.html";
 import tpl_pending_contact from "templates/pending_contact.html";
 import tpl_requesting_contact from "templates/requesting_contact.html";
 import tpl_roster from "templates/roster.html";
 import tpl_roster_filter from "templates/roster_filter.js";
 import tpl_roster_item from "templates/roster_item.html";
-import { BootstrapModal } from "./converse-modal.js";
 import { Model } from '@converse/skeletor/src/model.js';
 import { OrderedListView } from "@converse/skeletor/src/overview";
 import { View } from '@converse/skeletor/src/view.js';
 import { __ } from './i18n';
 import { _converse, api, converse } from "@converse/headless/converse-core";
-import { compact, debounce, has, without } from "lodash-es";
+import { debounce, has, without } from "lodash-es";
 
-const { Strophe } = converse.env;
 const u = converse.env.utils;
 
 
@@ -55,136 +53,6 @@ converse.plugins.add('converse-rosterview', {
         };
 
 
-        _converse.AddContactModal = BootstrapModal.extend({
-            id: "add-contact-modal",
-            events: {
-                'submit form': 'addContactFromForm'
-            },
-
-            initialize () {
-                BootstrapModal.prototype.initialize.apply(this, arguments);
-                this.listenTo(this.model, 'change', this.render);
-            },
-
-            toHTML () {
-                const label_nickname = api.settings.get('xhr_user_search_url') ? __('Contact name') : __('Optional nickname');
-                return tpl_add_contact_modal(Object.assign(this.model.toJSON(), { _converse, label_nickname }));
-            },
-
-            afterRender () {
-                if (typeof api.settings.get('xhr_user_search_url') === 'string') {
-                    this.initXHRAutoComplete();
-                } 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;
-                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;
-                this.name_auto_complete = new _converse.AutoComplete(el, {
-                    'auto_evaluate': false,
-                    'filter': _converse.FILTER_STARTSWITH,
-                    'list': []
-                });
-                const xhr = new window.XMLHttpRequest();
-                // `open` must be called after `onload` for mock/testing purposes.
-                xhr.onload = () => {
-                    if (xhr.responseText) {
-                        const r = xhr.responseText;
-                        this.name_auto_complete.list = JSON.parse(r).map(i => ({'label': i.fullname || i.jid, 'value': i.jid}));
-                        this.name_auto_complete.auto_completing = true;
-                        this.name_auto_complete.evaluate();
-                    }
-                };
-                const input_el = this.el.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;
-                });
-            },
-
-            initXHRFetch () {
-                this.xhr = new window.XMLHttpRequest();
-                this.xhr.onload = () => {
-                    if (this.xhr.responseText) {
-                        const r = this.xhr.responseText;
-                        const list = JSON.parse(r).map(i => ({'label': i.fullname || i.jid, 'value': i.jid}));
-                        if (list.length !== 1) {
-                            const el = this.el.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 name = list[0].label;
-                            this.afterSubmission(form, jid, name);
-                        }
-                    }
-                };
-            },
-
-            validateSubmission (jid) {
-                const el = this.el.querySelector('.invalid-feedback');
-                if (!jid || compact(jid.split('@')).length < 2) {
-                    u.addClass('is-invalid', this.el.querySelector('input[name="jid"]'));
-                    u.addClass('d-block', el);
-                    return false;
-                } else if (_converse.roster.get(Strophe.getBareJidFromJid(jid))) {
-                    el.textContent = __('This contact has already been added')
-                    u.addClass('d-block', el);
-                    return false;
-                }
-                u.removeClass('d-block', el);
-                return true;
-            },
-
-            afterSubmission (form, jid, name) {
-                _converse.roster.addAndSubscribe(jid, name);
-                this.model.clear();
-                this.modal.hide();
-            },
-
-            addContactFromForm (ev) {
-                ev.preventDefault();
-                const data = new FormData(ev.target),
-                      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"]');
-                    this.xhr.open("GET", `${api.settings.get('xhr_user_search_url')}q=${encodeURIComponent(input_el.value)}`, true);
-                    this.xhr.send()
-                    return;
-                }
-                if (this.validateSubmission(jid)) {
-                    this.afterSubmission(ev.target, jid, data.get('name'));
-                }
-            }
-        });
-
-
         _converse.RosterFilter = Model.extend({
             initialize () {
                 this.set({
@@ -195,6 +63,7 @@ converse.plugins.add('converse-rosterview', {
             },
         });
 
+
         _converse.RosterFilterView = View.extend({
             tagName: 'span',
 
@@ -980,4 +849,3 @@ converse.plugins.add('converse-rosterview', {
         });
     }
 });
-

+ 142 - 0
src/modals/add-contact.js

@@ -0,0 +1,142 @@
+import BootstrapModal from "./base.js";
+import tpl_add_contact_modal from "./templates/add-contact.js";
+import { __ } from '../i18n';
+import { _converse, api, converse } from "@converse/headless/converse-core";
+import { compact, debounce } from "lodash-es";
+
+const { Strophe } = converse.env;
+const u = converse.env.utils;
+
+
+const AddContactModal = BootstrapModal.extend({
+    id: "add-contact-modal",
+    events: {
+        'submit form': 'addContactFromForm'
+    },
+
+    initialize () {
+        BootstrapModal.prototype.initialize.apply(this, arguments);
+        this.listenTo(this.model, 'change', this.render);
+    },
+
+    toHTML () {
+        const label_nickname = api.settings.get('xhr_user_search_url') ? __('Contact name') : __('Optional nickname');
+        return tpl_add_contact_modal(Object.assign(this.model.toJSON(), { _converse, label_nickname }));
+    },
+
+    afterRender () {
+        if (typeof api.settings.get('xhr_user_search_url') === 'string') {
+            this.initXHRAutoComplete();
+        } 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;
+        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;
+        this.name_auto_complete = new _converse.AutoComplete(el, {
+            'auto_evaluate': false,
+            'filter': _converse.FILTER_STARTSWITH,
+            'list': []
+        });
+        const xhr = new window.XMLHttpRequest();
+        // `open` must be called after `onload` for mock/testing purposes.
+        xhr.onload = () => {
+            if (xhr.responseText) {
+                const r = xhr.responseText;
+                this.name_auto_complete.list = JSON.parse(r).map(i => ({'label': i.fullname || i.jid, 'value': i.jid}));
+                this.name_auto_complete.auto_completing = true;
+                this.name_auto_complete.evaluate();
+            }
+        };
+        const input_el = this.el.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;
+        });
+    },
+
+    initXHRFetch () {
+        this.xhr = new window.XMLHttpRequest();
+        this.xhr.onload = () => {
+            if (this.xhr.responseText) {
+                const r = this.xhr.responseText;
+                const list = JSON.parse(r).map(i => ({'label': i.fullname || i.jid, 'value': i.jid}));
+                if (list.length !== 1) {
+                    const el = this.el.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 name = list[0].label;
+                    this.afterSubmission(form, jid, name);
+                }
+            }
+        };
+    },
+
+    validateSubmission (jid) {
+        const el = this.el.querySelector('.invalid-feedback');
+        if (!jid || compact(jid.split('@')).length < 2) {
+            u.addClass('is-invalid', this.el.querySelector('input[name="jid"]'));
+            u.addClass('d-block', el);
+            return false;
+        } else if (_converse.roster.get(Strophe.getBareJidFromJid(jid))) {
+            el.textContent = __('This contact has already been added')
+            u.addClass('d-block', el);
+            return false;
+        }
+        u.removeClass('d-block', el);
+        return true;
+    },
+
+    afterSubmission (form, jid, name) {
+        _converse.roster.addAndSubscribe(jid, name);
+        this.model.clear();
+        this.modal.hide();
+    },
+
+    addContactFromForm (ev) {
+        ev.preventDefault();
+        const data = new FormData(ev.target),
+                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"]');
+            this.xhr.open("GET", `${api.settings.get('xhr_user_search_url')}q=${encodeURIComponent(input_el.value)}`, true);
+            this.xhr.send()
+            return;
+        }
+        if (this.validateSubmission(jid)) {
+            this.afterSubmission(ev.target, jid, data.get('name'));
+        }
+    }
+});
+
+_converse.AddContactModal = AddContactModal;
+
+export default AddContactModal;

+ 4 - 3
src/modals/add-muc.js

@@ -1,5 +1,5 @@
-import tpl_add_chatroom_modal from "templates/add_chatroom_modal.js";
-import { BootstrapModal } from "../converse-modal.js";
+import tpl_add_muc from "./templates/add-muc.js";
+import BootstrapModal from "./base.js";
 import { Strophe } from 'strophe.js/src/strophe';
 import { __ } from '../i18n';
 import { _converse, api, converse } from "@converse/headless/converse-core";
@@ -8,6 +8,7 @@ const u = converse.env.utils;
 
 
 export default BootstrapModal.extend({
+    persistent: true,
     id: 'add-chatroom-modal',
 
     events: {
@@ -28,7 +29,7 @@ export default BootstrapModal.extend({
             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_chatroom_modal(Object.assign(this.model.toJSON(), {
+        return tpl_add_muc(Object.assign(this.model.toJSON(), {
             '_converse': _converse,
             'label_room_address': api.settings.get('muc_domain') ? __('Groupchat name') :  __('Groupchat address'),
             'chatroom_placeholder': placeholder,

+ 18 - 0
src/modals/alert.js

@@ -0,0 +1,18 @@
+import BootstrapModal from "./base.js";
+import tpl_alert_modal from "./templates/alert.js";
+import { __ } from '../i18n';
+
+
+const Alert = BootstrapModal.extend({
+
+    initialize () {
+        BootstrapModal.prototype.initialize.apply(this, arguments);
+        this.listenTo(this.model, 'change', this.render)
+    },
+
+    toHTML () {
+        return tpl_alert_modal(Object.assign({__}, this.model.toJSON()));
+    }
+});
+
+export default Alert;

+ 84 - 0
src/modals/base.js

@@ -0,0 +1,84 @@
+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 { render } from 'lit-html';
+
+const { sizzle } = converse.env;
+const u = converse.env.utils;
+
+
+const BaseModal = View.extend({
+    className: "modal",
+    persistent: false, // Whether this modal should persist in the DOM once it's been closed
+    events: {
+        'click  .nav-item .nav-link': 'switchTab'
+    },
+
+    initialize () {
+        this.render()
+
+        this.el.setAttribute('tabindex', '-1');
+        this.el.setAttribute('role', 'dialog');
+        this.el.setAttribute('aria-hidden', 'true');
+        const label_id = this.el.querySelector('.modal-title').getAttribute('id');
+        label_id && this.el.setAttribute('aria-labelledby', label_id);
+
+        this.insertIntoDOM();
+        const Modal = bootstrap.Modal;
+        this.modal = new Modal(this.el, {
+            backdrop: true,
+            keyboard: true
+        });
+        this.el.addEventListener('hide.bs.modal', () => this.onHide(), false);
+    },
+
+    onHide () {
+        u.removeClass('selected', this.trigger_el);
+        !this.persistent && this.remove();
+    },
+
+    insertIntoDOM () {
+        const container_el = _converse.chatboxviews.el.querySelector("#converse-modals");
+        container_el.insertAdjacentElement('beforeEnd', this.el);
+    },
+
+    switchTab (ev) {
+        ev.stopPropagation();
+        ev.preventDefault();
+        sizzle('.nav-link.active', this.el).forEach(el => {
+            u.removeClass('active', this.el.querySelector(el.getAttribute('href')));
+            u.removeClass('active', el);
+        });
+        u.addClass('active', ev.target);
+        u.addClass('active', this.el.querySelector(ev.target.getAttribute('href')))
+    },
+
+    alert (message, type='primary') {
+        const body = this.el.querySelector('.modal-alert');
+        if (body === null) {
+            log.error("Could not find a .modal-alert element in the modal to show an alert message in!");
+            return;
+        }
+        // FIXME: Instead of adding the alert imperatively, we should
+        // find a way to let the modal rerender with an alert message
+        render(tpl_alert_component({'type': `alert-${type}`, 'message': message}), body);
+        const el = body.firstElementChild;
+        setTimeout(() => {
+            u.addClass('fade-out', el);
+            setTimeout(() => u.removeElement(el), 600);
+        }, 5000);
+    },
+
+    show (ev) {
+        if (ev) {
+            ev.preventDefault();
+            this.trigger_el = ev.target;
+            this.trigger_el.classList.add('selected');
+        }
+        this.modal.show();
+    }
+});
+
+export default BaseModal;

+ 64 - 0
src/modals/chat-status.js

@@ -0,0 +1,64 @@
+import BootstrapModal from "./base.js";
+import tpl_chat_status_modal from "./templates/chat-status.js";
+import { __ } from '../i18n';
+import { _converse, converse } from "@converse/headless/converse-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"
+    },
+
+    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();
+        }, false);
+    },
+
+    clearStatusMessage (ev) {
+        if (ev && ev.preventDefault) {
+            ev.preventDefault();
+            u.hideElement(this.el.querySelector('.clear-input'));
+        }
+        const roster_filter = this.el.querySelector('input[name="status_message"]');
+        roster_filter.value = '';
+    },
+
+    onFormSubmitted (ev) {
+        ev.preventDefault();
+        const data = new FormData(ev.target);
+        this.model.save({
+            'status_message': data.get('status_message'),
+            'status': data.get('chat_status')
+        });
+        this.modal.hide();
+    }
+});
+
+
+_converse.ChatStatusModal = ChatStatusModal;
+
+export default ChatStatusModal;

+ 59 - 0
src/modals/confirm.js

@@ -0,0 +1,59 @@
+import BootstrapModal from './base.js';
+import tpl_prompt from "./templates/prompt.js";
+import { converse } from "@converse/headless/converse-core";
+
+const u = converse.env.utils;
+
+
+const Confirm = BootstrapModal.extend({
+    events: {
+        'submit .confirm': 'onConfimation'
+    },
+
+    initialize () {
+        this.confirmation = u.getResolveablePromise();
+        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;
+        }
+    },
+
+    onConfimation (ev) {
+        ev.preventDefault();
+        const form_data = new FormData(ev.target);
+        const fields = (this.model.get('fields') || [])
+            .map(field => {
+                const value = form_data.get(field.name).trim();
+                field.value = value;
+                if (field.challenge) {
+                    field.challenge_failed = (value !== field.challenge);
+                }
+                return field;
+            });
+
+        if (fields.filter(c => c.challenge_failed).length) {
+            this.model.set('fields', fields);
+            // Setting an array doesn't trigger a change event
+            this.model.trigger('change');
+            return;
+        }
+        this.confirmation.resolve(fields);
+        this.modal.hide();
+    }
+});
+
+export default Confirm;

+ 2 - 2
src/modals/image.js

@@ -1,5 +1,5 @@
-import { BootstrapModal } from "../converse-modal.js";
-import tpl_image_modal from "../templates/image_modal.js";
+import BootstrapModal from "./base.js";
+import tpl_image_modal from "./templates/image.js";
 
 
 export default BootstrapModal.extend({

+ 3 - 4
src/modals/message-versions.js

@@ -1,10 +1,9 @@
-import { BootstrapModal } from "../converse-modal.js";
-import tpl_message_versions_modal from "../templates/message_versions_modal.js";
+import BootstrapModal from "./base.js";
+import tpl_message_versions_modal from "./templates/message-versions.js";
 
 
 export default BootstrapModal.extend({
-    // FIXME: this isn't globally unique
-    id: "message-versions-modal",
+
     toHTML () {
         return tpl_message_versions_modal(this.model.toJSON());
     }

+ 3 - 3
src/modals/moderator-tools.js

@@ -1,8 +1,8 @@
+import BootstrapModal from "./base.js";
 import log from "@converse/headless/log";
 import sizzle from "sizzle";
-import tpl_moderator_tools_modal from "../templates/moderator_tools_modal.js";
+import tpl_moderator_tools_modal from "./templates/moderator-tools.js";
 import { AFFILIATIONS, ROLES } from "@converse/headless/converse-muc.js";
-import { BootstrapModal } from "../converse-modal.js";
 import { __ } from '../i18n';
 import { api, converse } from "@converse/headless/converse-core";
 
@@ -12,7 +12,7 @@ let _converse;
 
 
 export default BootstrapModal.extend({
-    id: "converse-modtools-modal",
+    persistent: true,
 
     initialize (attrs) {
         _converse  = attrs._converse;

+ 1 - 1
src/modals/muc-commands.js

@@ -1,4 +1,4 @@
-import { BootstrapModal } from "../converse-modal.js";
+import BootstrapModal from "./base.js";
 import { __ } from '../i18n';
 import { api, converse } from "@converse/headless/converse-core";
 import log from "@converse/headless/log";

+ 3 - 3
src/modals/muc-details.js

@@ -1,6 +1,6 @@
-import { BootstrapModal } from "../converse-modal.js";
+import BootstrapModal from "./base.js";
+import tpl_muc_details from "./templates/muc-details.js";
 import { __ } from '../i18n';
-import tpl_chatroom_details_modal from "../templates/chatroom_details_modal.js";
 
 
 export default BootstrapModal.extend({
@@ -15,7 +15,7 @@ export default BootstrapModal.extend({
     },
 
     toHTML () {
-        return tpl_chatroom_details_modal(Object.assign(
+        return tpl_muc_details(Object.assign(
             this.model.toJSON(), {
                 'config': this.model.config.toJSON(),
                 'display_name': __('Groupchat info for %1$s', this.model.getDisplayName()),

+ 2 - 4
src/modals/muc-invite.js

@@ -1,5 +1,5 @@
-import tpl_muc_invite_modal from "templates/muc_invite_modal.js";
-import { BootstrapModal } from "../converse-modal.js";
+import BootstrapModal from "./base.js";
+import tpl_muc_invite_modal from "./templates/muc-invite.js";
 import { _converse, converse } from "@converse/headless/converse-core";
 
 const u = converse.env.utils;
@@ -49,5 +49,3 @@ export default BootstrapModal.extend({
         }
     }
 });
-
-

+ 3 - 2
src/modals/muc-list.js

@@ -1,10 +1,10 @@
+import BootstrapModal from "./base.js";
 import log from "@converse/headless/log";
 import sizzle from 'sizzle';
 import st from "@converse/headless/utils/stanza";
-import tpl_list_chatrooms_modal from "templates/list_chatrooms_modal.js";
+import tpl_list_chatrooms_modal from "./templates/muc-list.js";
 import tpl_room_description from "templates/room_description.html";
 import tpl_spinner from "templates/spinner.js";
-import { BootstrapModal } from "../converse-modal.js";
 import { Strophe, $iq } from 'strophe.js/src/strophe';
 import { __ } from '../i18n';
 import { _converse, api, converse } from "@converse/headless/converse-core";
@@ -83,6 +83,7 @@ function toggleRoomInfo (ev) {
 
 export default BootstrapModal.extend({
     id: "list-chatrooms-modal",
+    persistent: true,
 
     initialize () {
         this.items = [];

+ 6 - 7
src/modals/occupant.js

@@ -1,20 +1,19 @@
+import BootstrapModal from "./base.js";
 import tpl_occupant_modal from "./templates/occupant.js";
-import { BootstrapModal } from "../converse-modal.js";
 import { _converse, api } from "@converse/headless/converse-core";
 
 
 const OccupantModal = BootstrapModal.extend({
-    id: "muc-occupant-modal",
 
     initialize () {
         BootstrapModal.prototype.initialize.apply(this, arguments);
         this.listenTo(this.model, 'change', this.render);
         /**
-            * Triggered once the OccupantModal has been initialized
-            * @event _converse#userDetailsModalInitialized
-            * @type { _converse.ChatBox }
-            * @example _converse.api.listen.on('userDetailsModalInitialized', chatbox => { ... });
-            */
+         * Triggered once the OccupantModal has been initialized
+         * @event _converse#userDetailsModalInitialized
+         * @type { _converse.ChatBox }
+         * @example _converse.api.listen.on('userDetailsModalInitialized', chatbox => { ... });
+         */
         api.trigger('occupantModalInitialized', this.model);
     },
 

+ 99 - 0
src/modals/profile.js

@@ -0,0 +1,99 @@
+import BootstrapModal from "./base.js";
+import bootstrap from "bootstrap.native";
+import log from "@converse/headless/log";
+import sizzle from 'sizzle';
+import tpl_profile_modal from "./templates/profile.js";
+import { __ } from '../i18n';
+import { _converse, api } from "@converse/headless/converse-core";
+
+
+const ProfileModal = BootstrapModal.extend({
+    id: "user-profile-modal",
+    events: {
+        'submit .profile-form': 'onFormSubmitted'
+    },
+
+    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 => { ... });
+            */
+        api.trigger('profileModalInitialized', this.model);
+    },
+
+    toHTML () {
+        return tpl_profile_modal(Object.assign(
+            this.model.toJSON(),
+            this.model.vcard.toJSON(),
+            this.getAvatarData(),
+            { 'view': this }
+        ));
+    },
+
+    getAvatarData () {
+        const image_type = this.model.vcard.get('image_type');
+        const image_data = this.model.vcard.get('image');
+        const image = "data:" + image_type + ";base64," + image_data;
+        return {
+            'height': 128,
+            'width': 128,
+            image,
+        };
+    },
+
+    afterRender () {
+        this.tabs = sizzle('.nav-item .nav-link', this.el).map(e => new bootstrap.Tab(e));
+    },
+
+    async setVCard (data) {
+        try {
+            await api.vcard.set(_converse.bare_jid, data);
+        } catch (err) {
+            log.fatal(err);
+            this.alert([
+                __("Sorry, an error happened while trying to save your profile data."),
+                __("You can check your browser's developer console for any error output.")
+            ].join(" "));
+            return;
+        }
+        this.modal.hide();
+    },
+
+    onFormSubmitted (ev) {
+        ev.preventDefault();
+        const reader = new FileReader();
+        const form_data = new FormData(ev.target);
+        const image_file = form_data.get('image');
+        const data = {
+            'fn': form_data.get('fn'),
+            'nickname': form_data.get('nickname'),
+            'role': form_data.get('role'),
+            'email': form_data.get('email'),
+            'url': form_data.get('url'),
+        };
+        if (!image_file.size) {
+            Object.assign(data, {
+                'image': this.model.vcard.get('image'),
+                'image_type': this.model.vcard.get('image_type')
+            });
+            this.setVCard(data);
+        } else {
+            reader.onloadend = () => {
+                Object.assign(data, {
+                    'image': btoa(reader.result),
+                    'image_type': image_file.type
+                });
+                this.setVCard(data);
+            };
+            reader.readAsBinaryString(image_file);
+        }
+    }
+});
+
+_converse.ProfileModal = ProfileModal;
+
+export default ProfileModal;

+ 2 - 2
src/templates/add_contact_modal.js → src/modals/templates/add-contact.js

@@ -1,6 +1,6 @@
 import { html } from "lit-html";
-import { __ } from '../i18n';
-import { modal_header_close_button } from "./buttons"
+import { __ } from '../../i18n';
+import { modal_header_close_button } from "./buttons.js"
 
 
 export default (o) => {

+ 1 - 1
src/templates/add_chatroom_modal.js → src/modals/templates/add-muc.js

@@ -1,5 +1,5 @@
 import xss from "xss/dist/xss";
-import { __ } from '../i18n';
+import { __ } from '../../i18n';
 import { html } from "lit-html";
 import { modal_header_close_button } from "./buttons"
 import { unsafeHTML } from "lit-html/directives/unsafe-html.js";

+ 1 - 1
src/templates/alert_modal.js → src/modals/templates/alert.js

@@ -1,5 +1,5 @@
 import { html } from "lit-html";
-import { modal_header_close_button } from "./buttons"
+import { modal_header_close_button } from "./buttons.js"
 
 
 export default (o) => html`

+ 1 - 2
src/templates/buttons.js → src/modals/templates/buttons.js

@@ -1,8 +1,7 @@
-import { __ } from '../i18n';
+import { __ } from '../../i18n';
 import { html } from "lit-html";
 
 
 export const modal_close_button = html`<button type="button" class="btn btn-secondary" data-dismiss="modal">${__('Close')}</button>`;
 
 export const modal_header_close_button = html`<button type="button" class="close" data-dismiss="modal" aria-label="${__('Close')}"><span aria-hidden="true">×</span></button>`;
-

+ 1 - 1
src/templates/chat_status_modal.js → src/modals/templates/chat-status.js

@@ -1,5 +1,5 @@
 import { html } from "lit-html";
-import { modal_header_close_button } from "./buttons"
+import { modal_header_close_button } from "./buttons.js"
 
 
 export default (o) => html`

+ 2 - 2
src/templates/image_modal.js → src/modals/templates/image.js

@@ -1,6 +1,6 @@
 import { html } from "lit-html";
-import { __ } from '../i18n';
-import { modal_close_button, modal_header_close_button } from "./buttons"
+import { __ } from '../../i18n';
+import { modal_close_button, modal_header_close_button } from "./buttons.js"
 
 
 export default (o) => {

+ 3 - 3
src/templates/message_versions_modal.js → src/modals/templates/message-versions.js

@@ -1,7 +1,7 @@
-import { html } from "lit-html";
-import { __ } from '../i18n';
 import dayjs from 'dayjs';
-import { modal_close_button, modal_header_close_button } from "./buttons"
+import { __ } from '../../i18n';
+import { html } from "lit-html";
+import { modal_close_button, modal_header_close_button } from "./buttons.js"
 
 
 export default (o) => html`

+ 3 - 3
src/templates/moderator_tools_modal.js → src/modals/templates/moderator-tools.js

@@ -1,7 +1,7 @@
 import { html } from "lit-html";
-import { __ } from '../i18n';
-import spinner from "./spinner.js";
-import { modal_header_close_button } from "./buttons"
+import { __ } from '../../i18n';
+import spinner from "../../templates/spinner.js";
+import { modal_header_close_button } from "./buttons.js"
 
 
 function getRoleHelpText (role) {

+ 2 - 2
src/templates/chatroom_details_modal.js → src/modals/templates/muc-details.js

@@ -1,6 +1,6 @@
-import { __ } from '../i18n';
+import { __ } from '../../i18n';
 import { html } from "lit-html";
-import { modal_close_button, modal_header_close_button } from "./buttons"
+import { modal_close_button, modal_header_close_button } from "./buttons.js"
 import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
 import xss from "xss/dist/xss";
 

+ 2 - 2
src/templates/muc_invite_modal.js → src/modals/templates/muc-invite.js

@@ -1,6 +1,6 @@
 import { html } from "lit-html";
-import { __ } from '../i18n';
-import { modal_header_close_button } from "./buttons"
+import { __ } from '../../i18n';
+import { modal_header_close_button } from "./buttons.js"
 
 
 export default (o) => {

+ 3 - 3
src/templates/list_chatrooms_modal.js → src/modals/templates/muc-list.js

@@ -1,8 +1,8 @@
-import { __ } from '../i18n';
+import { __ } from '../../i18n';
 import { html } from "lit-html";
 import { repeat } from 'lit-html/directives/repeat.js';
-import { modal_close_button, modal_header_close_button } from "./buttons"
-import spinner from "./spinner.js";
+import { modal_close_button, modal_header_close_button } from "./buttons.js"
+import spinner from "../../templates/spinner.js";
 
 
 const form = (o) => {

+ 1 - 1
src/modals/templates/occupant.js

@@ -1,5 +1,5 @@
 import { html } from "lit-html";
-import { modal_close_button, modal_header_close_button } from "../../templates/buttons"
+import { modal_close_button, modal_header_close_button } from "./buttons.js"
 import { renderAvatar } from '../../templates/directives/avatar';
 
 

+ 4 - 4
src/templates/profile_modal.js → src/modals/templates/profile.js

@@ -1,9 +1,9 @@
-import "../components/image_picker.js";
-import spinner from "./spinner.js";
-import { __ } from '../i18n';
+import "../../components/image_picker.js";
+import spinner from "../../templates/spinner.js";
+import { __ } from '../../i18n';
 import { _converse, converse } from  "@converse/headless/converse-core";
 import { html } from "lit-html";
-import { modal_header_close_button } from "./buttons";
+import { modal_header_close_button } from "./buttons.js";
 
 const u = converse.env.utils;
 

+ 1 - 1
src/templates/prompt.js → src/modals/templates/prompt.js

@@ -1,5 +1,5 @@
 import { html } from "lit-html";
-import { __ } from '../i18n';
+import { __ } from '../../i18n';
 
 
 const tpl_field = (f) => html`

+ 3 - 3
src/templates/user_details_modal.js → src/modals/templates/user-details.js

@@ -1,7 +1,7 @@
-import { __ } from '../i18n';
+import { __ } from '../../i18n';
 import { html } from "lit-html";
-import avatar from "./avatar.js";
-import { modal_close_button, modal_header_close_button } from "./buttons"
+import avatar from "../../templates/avatar.js";
+import { modal_close_button, modal_header_close_button } from "./buttons.js"
 
 
 const device_fingerprint = (o) => {

+ 3 - 3
src/templates/user_settings_modal.js → src/modals/templates/user-settings.js

@@ -1,9 +1,9 @@
-import '../components/adhoc-commands.js';
+import '../../components/adhoc-commands.js';
 import xss from "xss/dist/xss";
-import { __ } from '../i18n';
+import { __ } from '../../i18n';
 import { api } from "@converse/headless/converse-core";
 import { html } from "lit-html";
-import { modal_header_close_button } from "./buttons"
+import { modal_header_close_button } from "./buttons.js"
 import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
 
 

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

@@ -1,6 +1,6 @@
+import BootstrapModal from "./base.js";
 import log from "@converse/headless/log";
-import tpl_user_details_modal from "../templates/user_details_modal.js";
-import { BootstrapModal } from "../converse-modal.js";
+import tpl_user_details_modal from "./templates/user-details.js";
 import { __ } from '../i18n';
 import { _converse, api, converse } from "@converse/headless/converse-core";
 
@@ -8,7 +8,7 @@ const u = converse.env.utils;
 
 
 const UserDetailsModal = BootstrapModal.extend({
-    id: "user-details-modal",
+    persistent: true,
 
     events: {
         'click button.refresh-contact': 'refreshContact',

+ 2 - 3
src/modals/user-settings.js

@@ -1,5 +1,5 @@
-import { BootstrapModal } from "../converse-modal.js";
-import tpl_user_settings_modal from "templates/user_settings_modal";
+import BootstrapModal from "./base.js";
+import tpl_user_settings_modal from "./templates/user-settings.js";
 
 let _converse;
 
@@ -21,4 +21,3 @@ export default BootstrapModal.extend({
         );
     }
 });
-