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

Move some View declarations out of the plugin `initialize` method

Precursor to some refactoring planned
JC Brand 4 жил өмнө
parent
commit
b7654f1fc7

+ 71 - 68
src/converse-chatboxviews.js

@@ -40,6 +40,75 @@ const AvatarMixin = {
 };
 
 
+const ViewWithAvatar = View.extend(AvatarMixin);
+
+
+const ChatBoxViews = Overview.extend({
+
+    _ensureElement () {
+        /* Override method from backbone.js
+            * If the #conversejs element doesn't exist, create it.
+            */
+        if (this.el) {
+            this.setElement(result(this, 'el'), false);
+        } else {
+            let el = _converse.root.querySelector('#conversejs');
+            if (el === null) {
+                el = document.createElement('div');
+                el.setAttribute('id', 'conversejs');
+                u.addClass(`theme-${api.settings.get('theme')}`, el);
+                const body = _converse.root.querySelector('body');
+                if (body) {
+                    body.appendChild(el);
+                } else {
+                    // Perhaps inside a web component?
+                    _converse.root.appendChild(el);
+                }
+            }
+            this.setElement(el, false);
+        }
+    },
+
+    initialize () {
+        this.listenTo(this.model, "destroy", this.removeChat)
+        const bg = document.getElementById('conversejs-bg');
+        if (bg && !bg.innerHTML.trim()) {
+            bg.innerHTML = tpl_background_logo();
+        }
+        const body = document.querySelector('body');
+        body.classList.add(`converse-${api.settings.get("view_mode")}`);
+        this.el.classList.add(`converse-${api.settings.get("view_mode")}`);
+        if (api.settings.get("singleton")) {
+            this.el.classList.add(`converse-singleton`);
+        }
+        this.render();
+    },
+
+    render () {
+        this._ensureElement();
+        render(tpl_converse(), this.el);
+        this.row_el = this.el.querySelector('.row');
+    },
+
+    /*(
+     * Add a new DOM element (likely a chat box) into the
+     * the row managed by this overview.
+     * @param { HTMLElement } el
+     */
+    insertRowColumn (el) {
+        this.row_el.insertAdjacentElement('afterBegin', el);
+    },
+
+    removeChat (item) {
+        this.remove(item.get('id'));
+    },
+
+    closeAllChatBoxes () {
+        return Promise.all(this.map(v => v.close({'name': 'closeAllChatBoxes'})));
+    }
+});
+
+
 converse.plugins.add('converse-chatboxviews', {
 
     dependencies: ["converse-chatboxes", "converse-vcard"],
@@ -62,74 +131,8 @@ converse.plugins.add('converse-chatboxviews', {
             'theme': 'default'
         });
 
-        _converse.ViewWithAvatar = View.extend(AvatarMixin);
-
-
-        _converse.ChatBoxViews = Overview.extend({
-
-            _ensureElement () {
-                /* Override method from backbone.js
-                 * If the #conversejs element doesn't exist, create it.
-                 */
-                if (this.el) {
-                    this.setElement(result(this, 'el'), false);
-                } else {
-                    let el = _converse.root.querySelector('#conversejs');
-                    if (el === null) {
-                        el = document.createElement('div');
-                        el.setAttribute('id', 'conversejs');
-                        u.addClass(`theme-${api.settings.get('theme')}`, el);
-                        const body = _converse.root.querySelector('body');
-                        if (body) {
-                            body.appendChild(el);
-                        } else {
-                            // Perhaps inside a web component?
-                            _converse.root.appendChild(el);
-                        }
-                    }
-                    this.setElement(el, false);
-                }
-            },
-
-            initialize () {
-                this.listenTo(this.model, "destroy", this.removeChat)
-                const bg = document.getElementById('conversejs-bg');
-                if (bg && !bg.innerHTML.trim()) {
-                    bg.innerHTML = tpl_background_logo();
-                }
-                const body = document.querySelector('body');
-                body.classList.add(`converse-${api.settings.get("view_mode")}`);
-                this.el.classList.add(`converse-${api.settings.get("view_mode")}`);
-                if (api.settings.get("singleton")) {
-                    this.el.classList.add(`converse-singleton`);
-                }
-                this.render();
-            },
-
-            render () {
-                this._ensureElement();
-                render(tpl_converse(), this.el);
-                this.row_el = this.el.querySelector('.row');
-            },
-
-            /*(
-             * Add a new DOM element (likely a chat box) into the
-             * the row managed by this overview.
-             * @param { HTMLElement } el
-             */
-            insertRowColumn (el) {
-                this.row_el.insertAdjacentElement('afterBegin', el);
-            },
-
-            removeChat (item) {
-                this.remove(item.get('id'));
-            },
-
-            closeAllChatBoxes () {
-                return Promise.all(this.map(v => v.close({'name': 'closeAllChatBoxes'})));
-            }
-        });
-
+        _converse.ViewWithAvatar = ViewWithAvatar;
+        _converse.ChatBoxViews = ChatBoxViews;
 
         /************************ BEGIN Event Handlers ************************/
         api.listen.on('cleanup', () => (delete _converse.chatboxviews));

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 998 - 0
src/converse-chatview.js


+ 136 - 132
src/converse-headlines-view.js

@@ -6,6 +6,7 @@
 import "converse-chatview";
 import tpl_chatbox from "templates/chatbox.js";
 import tpl_headline_panel from "templates/headline_panel.js";
+import { ChatBoxView } from "./converse-chatview";
 import { View } from '@converse/skeletor/src/view.js';
 import { __ } from '@converse/headless/i18n';
 import { _converse, api, converse } from "@converse/headless/converse-core";
@@ -14,6 +15,139 @@ import { render } from "lit-html";
 const u = converse.env.utils;
 
 
+const HeadlinesBoxView = ChatBoxView.extend({
+    className: 'chatbox headlines',
+
+    events: {
+        'click .close-chatbox-button': 'close',
+        'click .toggle-chatbox-button': 'minimize',
+        'keypress textarea.chat-textarea': 'onKeyDown'
+    },
+
+    initialize () {
+        this.initDebounced();
+
+        this.model.disable_mam = true; // Don't do MAM queries for this box
+        this.listenTo(this.model.messages, 'add', this.renderChatHistory);
+        this.listenTo(this.model, 'show', this.show);
+        this.listenTo(this.model, 'destroy', this.hide);
+        this.listenTo(this.model, 'change:minimized', this.onMinimizedChanged);
+
+        this.render();
+        this.renderHeading();
+        this.updateAfterMessagesFetched();
+        this.insertIntoDOM().hide();
+        this.model.maybeShow();
+        /**
+         * Triggered once the {@link _converse.HeadlinesBoxView} has been initialized
+         * @event _converse#headlinesBoxViewInitialized
+         * @type { _converse.HeadlinesBoxView }
+         * @example _converse.api.listen.on('headlinesBoxViewInitialized', view => { ... });
+         */
+        api.trigger('headlinesBoxViewInitialized', this);
+    },
+
+    render () {
+        this.el.setAttribute('id', this.model.get('box_id'))
+        const result = tpl_chatbox(
+            Object.assign(this.model.toJSON(), {
+                    info_close: '',
+                    label_personal_message: '',
+                    show_send_button: false,
+                    show_toolbar: false,
+                    unread_msgs: ''
+                }
+            ));
+        render(result, this.el);
+        this.content = this.el.querySelector('.chat-content');
+        this.msgs_container = this.el.querySelector('.chat-content__messages');
+        return this;
+    },
+
+    getNotifications () {
+        // Override method in ChatBox. We don't show notifications for
+        // headlines boxes.
+        return [];
+    },
+
+    /**
+     * Returns a list of objects which represent buttons for the headlines header.
+     * @async
+     * @emits _converse#getHeadingButtons
+     * @private
+     * @method _converse.HeadlinesBoxView#getHeadingButtons
+     */
+    getHeadingButtons () {
+        const buttons = [];
+        if (!api.settings.get("singleton")) {
+            buttons.push({
+                'a_class': 'close-chatbox-button',
+                'handler': ev => this.close(ev),
+                'i18n_text': __('Close'),
+                'i18n_title': __('Close these announcements'),
+                'icon_class': 'fa-times',
+                'name': 'close',
+                'standalone': api.settings.get("view_mode") === 'overlayed',
+            });
+        }
+        return _converse.api.hook('getHeadingButtons', this, buttons);
+    },
+
+    // Override to avoid the methods in converse-chatview.js
+    'renderMessageForm': function renderMessageForm () {},
+    'afterShown': function afterShown () {}
+});
+
+
+/**
+ * View which renders headlines section of the control box.
+ * @class
+ * @namespace _converse.HeadlinesPanel
+ * @memberOf _converse
+ */
+export const HeadlinesPanel = View.extend({
+    tagName: 'div',
+    className: 'controlbox-section',
+    id: 'headline',
+
+    events: {
+        'click .open-headline': 'openHeadline'
+    },
+
+    initialize () {
+        this.listenTo(this.model, 'add', this.renderIfHeadline)
+        this.listenTo(this.model, 'remove', this.renderIfHeadline)
+        this.listenTo(this.model, 'destroy', this.renderIfHeadline)
+        this.render();
+        this.insertIntoDOM();
+    },
+
+    toHTML () {
+        return tpl_headline_panel({
+            'heading_headline': __('Announcements'),
+            'headlineboxes': this.model.filter(m => m.get('type') === _converse.HEADLINES_TYPE),
+            'open_title': __('Click to open this server message'),
+        });
+    },
+
+    renderIfHeadline (model) {
+        return (model && model.get('type') === _converse.HEADLINES_TYPE) && this.render();
+    },
+
+    openHeadline (ev) {
+        ev.preventDefault();
+        const jid = ev.target.getAttribute('data-headline-jid');
+        const chat = _converse.chatboxes.get(jid);
+        chat.maybeShow(true);
+    },
+
+    insertIntoDOM () {
+        const view = _converse.chatboxviews.get('controlbox');
+        view && view.el.querySelector('.controlbox-pane').insertAdjacentElement('beforeEnd', this.el);
+    }
+});
+
+
 converse.plugins.add('converse-headlines-view', {
     /* Plugin dependencies are other plugins which might be
      * overridden or relied upon, and therefore need to be loaded before
@@ -69,138 +203,8 @@ converse.plugins.add('converse-headlines-view', {
             Object.assign(_converse.ControlBoxView.prototype, viewWithHeadlinesPanel);
         }
 
-
-        /**
-         * View which renders headlines section of the control box.
-         * @class
-         * @namespace _converse.HeadlinesPanel
-         * @memberOf _converse
-         */
-        _converse.HeadlinesPanel = View.extend({
-            tagName: 'div',
-            className: 'controlbox-section',
-            id: 'headline',
-
-            events: {
-                'click .open-headline': 'openHeadline'
-            },
-
-            initialize () {
-                this.listenTo(this.model, 'add', this.renderIfHeadline)
-                this.listenTo(this.model, 'remove', this.renderIfHeadline)
-                this.listenTo(this.model, 'destroy', this.renderIfHeadline)
-                this.render();
-                this.insertIntoDOM();
-            },
-
-            toHTML () {
-                return tpl_headline_panel({
-                    'heading_headline': __('Announcements'),
-                    'headlineboxes': this.model.filter(m => m.get('type') === _converse.HEADLINES_TYPE),
-                    'open_title': __('Click to open this server message'),
-                });
-            },
-
-            renderIfHeadline (model) {
-                return (model && model.get('type') === _converse.HEADLINES_TYPE) && this.render();
-            },
-
-            openHeadline (ev) {
-                ev.preventDefault();
-                const jid = ev.target.getAttribute('data-headline-jid');
-                const chat = _converse.chatboxes.get(jid);
-                chat.maybeShow(true);
-            },
-
-            insertIntoDOM () {
-                const view = _converse.chatboxviews.get('controlbox');
-                view && view.el.querySelector('.controlbox-pane').insertAdjacentElement('beforeEnd', this.el);
-            }
-        });
-
-
-        _converse.HeadlinesBoxView = _converse.ChatBoxView.extend({
-            className: 'chatbox headlines',
-
-            events: {
-                'click .close-chatbox-button': 'close',
-                'click .toggle-chatbox-button': 'minimize',
-                'keypress textarea.chat-textarea': 'onKeyDown'
-            },
-
-            initialize () {
-                this.initDebounced();
-
-                this.model.disable_mam = true; // Don't do MAM queries for this box
-                this.listenTo(this.model.messages, 'add', this.renderChatHistory);
-                this.listenTo(this.model, 'show', this.show);
-                this.listenTo(this.model, 'destroy', this.hide);
-                this.listenTo(this.model, 'change:minimized', this.onMinimizedChanged);
-
-                this.render();
-                this.renderHeading();
-                this.updateAfterMessagesFetched();
-                this.insertIntoDOM().hide();
-                this.model.maybeShow();
-                /**
-                 * Triggered once the {@link _converse.HeadlinesBoxView} has been initialized
-                 * @event _converse#headlinesBoxViewInitialized
-                 * @type { _converse.HeadlinesBoxView }
-                 * @example _converse.api.listen.on('headlinesBoxViewInitialized', view => { ... });
-                 */
-                api.trigger('headlinesBoxViewInitialized', this);
-            },
-
-            render () {
-                this.el.setAttribute('id', this.model.get('box_id'))
-                const result = tpl_chatbox(
-                    Object.assign(this.model.toJSON(), {
-                            info_close: '',
-                            label_personal_message: '',
-                            show_send_button: false,
-                            show_toolbar: false,
-                            unread_msgs: ''
-                        }
-                    ));
-                render(result, this.el);
-                this.content = this.el.querySelector('.chat-content');
-                this.msgs_container = this.el.querySelector('.chat-content__messages');
-                return this;
-            },
-
-            getNotifications () {
-                // Override method in ChatBox. We don't show notifications for
-                // headlines boxes.
-                return [];
-            },
-
-            /**
-             * Returns a list of objects which represent buttons for the headlines header.
-             * @async
-             * @emits _converse#getHeadingButtons
-             * @private
-             * @method _converse.HeadlinesBoxView#getHeadingButtons
-             */
-            getHeadingButtons () {
-                const buttons = [];
-                if (!api.settings.get("singleton")) {
-                    buttons.push({
-                        'a_class': 'close-chatbox-button',
-                        'handler': ev => this.close(ev),
-                        'i18n_text': __('Close'),
-                        'i18n_title': __('Close these announcements'),
-                        'icon_class': 'fa-times',
-                        'name': 'close',
-                        'standalone': api.settings.get("view_mode") === 'overlayed',
-                    });
-                }
-                return _converse.api.hook('getHeadingButtons', this, buttons);
-            },
-
-            // Override to avoid the methods in converse-chatview.js
-            'renderMessageForm': function renderMessageForm () {},
-            'afterShown': function afterShown () {}
-        });
+        _converse.HeadlinesBoxView = HeadlinesBoxView;
+        _converse.HeadlinesPanel = HeadlinesPanel;
 
 
         /************************ BEGIN Event Handlers ************************/

+ 1311 - 1305
src/converse-muc-views.js

@@ -23,10 +23,11 @@ import tpl_muc_password_form from "templates/muc_password_form.js";
 import tpl_muc_sidebar from "templates/muc_sidebar.js";
 import tpl_room_panel from "templates/room_panel.html";
 import tpl_spinner from "templates/spinner.html";
+import { ChatBoxView } from "./converse-chatview";
 import { Model } from '@converse/skeletor/src/model.js';
 import { View } from '@converse/skeletor/src/view.js';
 import { __ } from '@converse/headless/i18n';
-import { api, converse } from "@converse/headless/converse-core";
+import { _converse, api, converse } from "@converse/headless/converse-core";
 import { debounce, isString, isUndefined } from "lodash-es";
 import { render } from "lit-html";
 
@@ -53,1421 +54,1426 @@ const COMMAND_TO_AFFILIATION = {
     'revoke': 'none'
 }
 
-converse.plugins.add('converse-muc-views', {
-    /* Dependencies are other plugins which might be
-     * overridden or relied upon, and therefore need to be loaded before
-     * this plugin. They are "optional" because they might not be
-     * available, in which case any overrides applicable to them will be
-     * ignored.
-     *
-     * NB: These plugins need to have already been loaded via require.js.
-     *
-     * It's possible to make these dependencies "non-optional".
-     * If the setting "strict_plugin_dependencies" is set to true,
-     * an error will be raised if the plugin is not found.
-     */
-    dependencies: ["converse-autocomplete", "converse-modal", "converse-controlbox", "converse-chatview"],
 
-    overrides: {
-        ControlBoxView: {
-            renderControlBoxPane () {
-                this.__super__.renderControlBoxPane.apply(this, arguments);
-                if (api.settings.get('allow_muc')) {
-                    this.renderRoomsPanel();
-                }
-            }
-        }
+/**
+ * NativeView which renders a groupchat, based upon
+ * { @link _converse.ChatBoxView } for normal one-on-one chat boxes.
+ * @class
+ * @namespace _converse.ChatRoomView
+ * @memberOf _converse
+ */
+export const ChatRoomView = ChatBoxView.extend({
+    length: 300,
+    tagName: 'div',
+    className: 'chatbox chatroom hidden',
+    is_chatroom: true,
+    events: {
+        'click .chatbox-navback': 'showControlBox',
+        'click .hide-occupants': 'hideOccupants',
+        'click .new-msgs-indicator': 'viewUnreadMessages',
+        // Arrow functions don't work here because you can't bind a different `this` param to them.
+        'click .occupant-nick': function (ev) {this.insertIntoTextArea(ev.target.textContent) },
+        'click .send-button': 'onFormSubmitted',
+        'dragover .chat-textarea': 'onDragOver',
+        'drop .chat-textarea': 'onDrop',
+        'input .chat-textarea': 'inputChanged',
+        'keydown .chat-textarea': 'onKeyDown',
+        'keyup .chat-textarea': 'onKeyUp',
+        'mousedown .dragresize-occupants-left': 'onStartResizeOccupants',
+        'paste .chat-textarea': 'onPaste',
+        'submit .muc-nickname-form': 'submitNickname',
     },
 
-    initialize () {
-        const { _converse } = this;
-
-        api.promises.add(['roomsPanelRendered']);
-
-        // Configuration values for this plugin
-        // ====================================
-        // Refer to docs/source/configuration.rst for explanations of these
-        // configuration settings.
-        api.settings.extend({
-            'auto_list_rooms': false,
-            'cache_muc_messages': true,
-            'locked_muc_nickname': false,
-            'modtools_disable_query': [],
-            'modtools_disable_assign': false,
-            'muc_disable_slash_commands': false,
-            'muc_mention_autocomplete_filter': 'contains',
-            'muc_mention_autocomplete_min_chars': 0,
-            'muc_mention_autocomplete_show_avatar': true,
-            'muc_roomid_policy': null,
-            'muc_roomid_policy_hint': null,
-            'roomconfig_whitelist': [],
-            'show_retraction_warning': true,
-            'visible_toolbar_buttons': {
-                'toggle_occupants': true
-            }
-        });
-
+    async initialize () {
+        this.initDebounced();
+
+        this.listenTo(this.model, 'change', debounce(() => this.renderHeading(), 250));
+        this.listenTo(this.model, 'change:composing_spoiler', this.renderMessageForm);
+        this.listenTo(this.model, 'change:hidden_occupants', this.renderToolbar);
+        this.listenTo(this.model, 'configurationNeeded', this.getAndRenderConfigurationForm);
+        this.listenTo(this.model, 'destroy', this.hide);
+        this.listenTo(this.model, 'show', this.show);
+        this.listenTo(this.model.features, 'change:moderated', this.renderBottomPanel);
+        this.listenTo(this.model.features, 'change:open', this.renderHeading);
+        this.listenTo(this.model.messages, 'rendered', this.maybeScrollDown);
+        this.listenTo(this.model.session, 'change:connection_status', this.onConnectionStatusChanged);
+
+        // Bind so that we can pass it to addEventListener and removeEventListener
+        this.onMouseMove =  this.onMouseMove.bind(this);
+        this.onMouseUp =  this.onMouseUp.bind(this);
+
+        await this.render();
+
+        // Need to be registered after render has been called.
+        this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages);
+        this.listenTo(this.model.messages, 'add', this.onMessageAdded);
+        this.listenTo(this.model.messages, 'change', this.renderChatHistory);
+        this.listenTo(this.model.messages, 'remove', this.renderChatHistory);
+        this.listenTo(this.model.messages, 'reset', this.renderChatHistory);
+        this.listenTo(this.model.notifications, 'change', this.renderNotifications);
+
+        this.model.occupants.forEach(o => this.onOccupantAdded(o));
+        this.listenTo(this.model.occupants, 'add', this.onOccupantAdded);
+        this.listenTo(this.model.occupants, 'change', this.renderChatHistory);
+        this.listenTo(this.model.occupants, 'change:affiliation', this.onOccupantAffiliationChanged);
+        this.listenTo(this.model.occupants, 'change:role', this.onOccupantRoleChanged);
+        this.listenTo(this.model.occupants, 'change:show', this.showJoinOrLeaveNotification);
+        this.listenTo(this.model.occupants, 'remove', this.onOccupantRemoved);
+
+        this.createSidebarView();
+        await this.updateAfterMessagesFetched();
+
+        // Register later due to await
+        const user_settings = await _converse.api.user.settings.getModel();
+        this.listenTo(user_settings, 'change:mucs_with_hidden_subject', this.renderHeading);
+
+        this.onConnectionStatusChanged();
+        this.model.maybeShow();
 
-        const viewWithRoomsPanel = {
-            renderRoomsPanel () {
-                if (this.roomspanel && u.isInDOM(this.roomspanel.el)) {
-                    return this.roomspanel;
-                }
-                const id = `converse.roomspanel${_converse.bare_jid}`;
+        /**
+         * Triggered once a { @link _converse.ChatRoomView } has been opened
+         * @event _converse#chatRoomViewInitialized
+         * @type { _converse.ChatRoomView }
+         * @example _converse.api.listen.on('chatRoomViewInitialized', view => { ... });
+         */
+        api.trigger('chatRoomViewInitialized', this);
+    },
 
-                this.roomspanel = new _converse.RoomsPanel({
-                    'model': new (_converse.RoomsPanelModel.extend({
-                        id,
-                        'browserStorage': _converse.createStore(id)
-                    }))()
-                });
-                this.roomspanel.model.fetch();
-                this.el.querySelector('.controlbox-pane').insertAdjacentElement(
-                    'beforeEnd', this.roomspanel.render().el);
+    async render () {
+        this.el.setAttribute('id', this.model.get('box_id'));
+        render(tpl_chatroom({
+            'markScrolled': ev => this.markScrolled(ev),
+            'muc_show_logs_before_join': api.settings.get('muc_show_logs_before_join'),
+            'show_send_button': _converse.show_send_button,
+        }), this.el);
+
+        this.notifications = this.el.querySelector('.chat-content__notifications');
+        this.content = this.el.querySelector('.chat-content');
+        this.msgs_container = this.el.querySelector('.chat-content__messages');
+        this.help_container = this.el.querySelector('.chat-content__help');
+
+        this.renderBottomPanel();
+        if (!api.settings.get('muc_show_logs_before_join') &&
+                this.model.session.get('connection_status') !== converse.ROOMSTATUS.ENTERED) {
+            this.showSpinner();
+        }
+        // Render header as late as possible since it's async and we
+        // want the rest of the DOM elements to be available ASAP.
+        // Otherwise e.g. this.notifications is not yet defined when accessed elsewhere.
+        await this.renderHeading();
+        !this.model.get('hidden') && this.show();
+    },
 
-                /**
-                 * Triggered once the section of the { @link _converse.ControlBoxView }
-                 * which shows gropuchats has been rendered.
-                 * @event _converse#roomsPanelRendered
-                 * @example _converse.api.listen.on('roomsPanelRendered', () => { ... });
-                 */
-                api.trigger('roomsPanelRendered');
-                return this.roomspanel;
-            },
+    getNotifications () {
+        const actors_per_state = this.model.notifications.toJSON();
+        const states = api.settings.get('muc_show_join_leave') ?
+            [...converse.CHAT_STATES, ...converse.MUC_TRAFFIC_STATES, ...converse.MUC_ROLE_CHANGES] :
+            converse.CHAT_STATES;
 
-            getRoomsPanel () {
-                if (this.roomspanel && u.isInDOM(this.roomspanel.el)) {
-                    return this.roomspanel;
+        return states.reduce((result, state) => {
+            const existing_actors = actors_per_state[state];
+            if (!(existing_actors?.length)) {
+                return result;
+            }
+            const actors = existing_actors.map(a => this.model.getOccupant(a)?.getDisplayName() || a);
+            if (actors.length === 1) {
+                if (state === 'composing') {
+                    return `${result}${__('%1$s is typing', actors[0])}\n`;
+                } else if (state === 'paused') {
+                    return `${result}${__('%1$s has stopped typing', actors[0])}\n`;
+                } else if (state === _converse.GONE) {
+                    return `${result}${__('%1$s has gone away', actors[0])}\n`;
+                } else if (state === 'entered') {
+                    return `${result}${__('%1$s has entered the groupchat', actors[0])}\n`;
+                } else if (state === 'exited') {
+                    return `${result}${__('%1$s has left the groupchat', actors[0])}\n`;
+                } else if (state === 'op') {
+                    return `${result}${__("%1$s is now a moderator", actors[0])}\n`;
+                } else if (state === 'deop') {
+                    return `${result}${__("%1$s is no longer a moderator", actors[0])}\n`;
+                } else if (state === 'voice') {
+                    return `${result}${__("%1$s has been given a voice", actors[0])}\n`;
+                } else if (state === 'mute') {
+                    return `${result}${__("%1$s has been muted", actors[0])}\n`;
+                }
+            } else if (actors.length > 1) {
+                let actors_str;
+                if (actors.length > 3) {
+                    actors_str = `${Array.from(actors).slice(0, 2).join(', ')} and others`;
                 } else {
-                    return this.renderRoomsPanel();
+                    const last_actor = actors.pop();
+                    actors_str = __('%1$s and %2$s', actors.join(', '), last_actor);
+                }
+
+                if (state === 'composing') {
+                    return `${result}${__('%1$s are typing', actors_str)}\n`;
+                } else if (state === 'paused') {
+                    return `${result}${__('%1$s have stopped typing', actors_str)}\n`;
+                } else if (state === _converse.GONE) {
+                    return `${result}${__('%1$s have gone away', actors_str)}\n`;
+                } else if (state === 'entered') {
+                    return `${result}${__('%1$s have entered the groupchat', actors_str)}\n`;
+                } else if (state === 'exited') {
+                    return `${result}${__('%1$s have left the groupchat', actors_str)}\n`;
+                } else if (state === 'op') {
+                    return `${result}${__("%1$s are now moderators", actors[0])}\n`;
+                } else if (state === 'deop') {
+                    return `${result}${__("%1$s are no longer moderators", actors[0])}\n`;
+                } else if (state === 'voice') {
+                    return `${result}${__("%1$s have been given voices", actors[0])}\n`;
+                } else if (state === 'mute') {
+                    return `${result}${__("%1$s have been muted", actors[0])}\n`;
                 }
             }
-        }
+            return result;
+        }, '');
+    },
 
-        if (_converse.ControlBoxView) {
-            Object.assign(_converse.ControlBoxView.prototype, viewWithRoomsPanel);
-        }
+    getHelpMessages () {
+        const setting = api.settings.get("muc_disable_slash_commands");
+        const disabled_commands = Array.isArray(setting) ? setting : [];
+        return [
+            `<strong>/admin</strong>: ${__("Change user's affiliation to admin")}`,
+            `<strong>/ban</strong>: ${__('Ban user by changing their affiliation to outcast')}`,
+            `<strong>/clear</strong>: ${__('Clear the chat area')}`,
+            `<strong>/close</strong>: ${__('Close this groupchat')}`,
+            `<strong>/deop</strong>: ${__('Change user role to participant')}`,
+            `<strong>/destroy</strong>: ${__('Remove this groupchat')}`,
+            `<strong>/help</strong>: ${__('Show this menu')}`,
+            `<strong>/kick</strong>: ${__('Kick user from groupchat')}`,
+            `<strong>/me</strong>: ${__('Write in 3rd person')}`,
+            `<strong>/member</strong>: ${__('Grant membership to a user')}`,
+            `<strong>/modtools</strong>: ${__('Opens up the moderator tools GUI')}`,
+            `<strong>/mute</strong>: ${__("Remove user's ability to post messages")}`,
+            `<strong>/nick</strong>: ${__('Change your nickname')}`,
+            `<strong>/op</strong>: ${__('Grant moderator role to user')}`,
+            `<strong>/owner</strong>: ${__('Grant ownership of this groupchat')}`,
+            `<strong>/register</strong>: ${__("Register your nickname")}`,
+            `<strong>/revoke</strong>: ${__("Revoke the user's current affiliation")}`,
+            `<strong>/subject</strong>: ${__('Set groupchat subject')}`,
+            `<strong>/topic</strong>: ${__('Set groupchat subject (alias for /subject)')}`,
+            `<strong>/voice</strong>: ${__('Allow muted user to post messages')}`
+            ].filter(line => disabled_commands.every(c => (!line.startsWith(c+'<', 9))))
+                .filter(line => this.getAllowedCommands().some(c => line.startsWith(c+'<', 9)));
+    },
 
+    /**
+     * Renders the MUC heading if any relevant attributes have changed.
+     * @private
+     * @method _converse.ChatRoomView#renderHeading
+     * @param { _converse.ChatRoom } [item]
+     */
+    async renderHeading () {
+        const tpl = await this.generateHeadingTemplate();
+        render(tpl, this.el.querySelector('.chat-head-chatroom'));
+    },
 
-        /**
-         * NativeView which renders a groupchat, based upon
-         * { @link _converse.ChatBoxView } for normal one-on-one chat boxes.
-         * @class
-         * @namespace _converse.ChatRoomView
-         * @memberOf _converse
-         */
-        _converse.ChatRoomView = _converse.ChatBoxView.extend({
-            length: 300,
-            tagName: 'div',
-            className: 'chatbox chatroom hidden',
-            is_chatroom: true,
-            events: {
-                'click .chatbox-navback': 'showControlBox',
-                'click .hide-occupants': 'hideOccupants',
-                'click .new-msgs-indicator': 'viewUnreadMessages',
-                // Arrow functions don't work here because you can't bind a different `this` param to them.
-                'click .occupant-nick': function (ev) {this.insertIntoTextArea(ev.target.textContent) },
-                'click .send-button': 'onFormSubmitted',
-                'dragover .chat-textarea': 'onDragOver',
-                'drop .chat-textarea': 'onDrop',
-                'input .chat-textarea': 'inputChanged',
-                'keydown .chat-textarea': 'onKeyDown',
-                'keyup .chat-textarea': 'onKeyUp',
-                'mousedown .dragresize-occupants-left': 'onStartResizeOccupants',
-                'paste .chat-textarea': 'onPaste',
-                'submit .muc-nickname-form': 'submitNickname',
-            },
 
-            async initialize () {
-                this.initDebounced();
-
-                this.listenTo(this.model, 'change', debounce(() => this.renderHeading(), 250));
-                this.listenTo(this.model, 'change:composing_spoiler', this.renderMessageForm);
-                this.listenTo(this.model, 'change:hidden_occupants', this.renderToolbar);
-                this.listenTo(this.model, 'configurationNeeded', this.getAndRenderConfigurationForm);
-                this.listenTo(this.model, 'destroy', this.hide);
-                this.listenTo(this.model, 'show', this.show);
-                this.listenTo(this.model.features, 'change:moderated', this.renderBottomPanel);
-                this.listenTo(this.model.features, 'change:open', this.renderHeading);
-                this.listenTo(this.model.messages, 'rendered', this.maybeScrollDown);
-                this.listenTo(this.model.session, 'change:connection_status', this.onConnectionStatusChanged);
-
-                // Bind so that we can pass it to addEventListener and removeEventListener
-                this.onMouseMove =  this.onMouseMove.bind(this);
-                this.onMouseUp =  this.onMouseUp.bind(this);
-
-                await this.render();
-
-                // Need to be registered after render has been called.
-                this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages);
-                this.listenTo(this.model.messages, 'add', this.onMessageAdded);
-                this.listenTo(this.model.messages, 'change', this.renderChatHistory);
-                this.listenTo(this.model.messages, 'remove', this.renderChatHistory);
-                this.listenTo(this.model.messages, 'reset', this.renderChatHistory);
-                this.listenTo(this.model.notifications, 'change', this.renderNotifications);
-
-                this.model.occupants.forEach(o => this.onOccupantAdded(o));
-                this.listenTo(this.model.occupants, 'add', this.onOccupantAdded);
-                this.listenTo(this.model.occupants, 'change', this.renderChatHistory);
-                this.listenTo(this.model.occupants, 'change:affiliation', this.onOccupantAffiliationChanged);
-                this.listenTo(this.model.occupants, 'change:role', this.onOccupantRoleChanged);
-                this.listenTo(this.model.occupants, 'change:show', this.showJoinOrLeaveNotification);
-                this.listenTo(this.model.occupants, 'remove', this.onOccupantRemoved);
-
-                this.createSidebarView();
-                await this.updateAfterMessagesFetched();
-
-                // Register later due to await
-                const user_settings = await _converse.api.user.settings.getModel();
-                this.listenTo(user_settings, 'change:mucs_with_hidden_subject', this.renderHeading);
-
-                this.onConnectionStatusChanged();
-                this.model.maybeShow();
+    renderBottomPanel () {
+        const container = this.el.querySelector('.bottom-panel');
+        const entered = this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED;
+        const can_edit = entered && !(this.model.features.get('moderated') && this.model.getOwnRole() === 'visitor');
+        container.innerHTML = tpl_chatroom_bottom_panel({__, can_edit, entered});
+        if (entered && can_edit) {
+            this.renderMessageForm();
+            this.initMentionAutoComplete();
+        }
+    },
 
-                /**
-                 * Triggered once a { @link _converse.ChatRoomView } has been opened
-                 * @event _converse#chatRoomViewInitialized
-                 * @type { _converse.ChatRoomView }
-                 * @example _converse.api.listen.on('chatRoomViewInitialized', view => { ... });
-                 */
-                api.trigger('chatRoomViewInitialized', this);
-            },
+    createSidebarView () {
+        this.model.occupants.chatroomview = this;
+        this.sidebar_view = new _converse.MUCSidebar({'model': this.model.occupants});
+        const container_el = this.el.querySelector('.chatroom-body');
+        const occupants_width = this.model.get('occupants_width');
+        if (this.sidebar_view && occupants_width !== undefined) {
+            this.sidebar_view.el.style.flex = "0 0 " + occupants_width + "px";
+        }
+        container_el.insertAdjacentElement('beforeend', this.sidebar_view.el);
+    },
 
-            async render () {
-                this.el.setAttribute('id', this.model.get('box_id'));
-                render(tpl_chatroom({
-                    'markScrolled': ev => this.markScrolled(ev),
-                    'muc_show_logs_before_join': api.settings.get('muc_show_logs_before_join'),
-                    'show_send_button': _converse.show_send_button,
-                }), this.el);
-
-                this.notifications = this.el.querySelector('.chat-content__notifications');
-                this.content = this.el.querySelector('.chat-content');
-                this.msgs_container = this.el.querySelector('.chat-content__messages');
-                this.help_container = this.el.querySelector('.chat-content__help');
-
-                this.renderBottomPanel();
-                if (!api.settings.get('muc_show_logs_before_join') &&
-                        this.model.session.get('connection_status') !== converse.ROOMSTATUS.ENTERED) {
-                    this.showSpinner();
-                }
-                // Render header as late as possible since it's async and we
-                // want the rest of the DOM elements to be available ASAP.
-                // Otherwise e.g. this.notifications is not yet defined when accessed elsewhere.
-                await this.renderHeading();
-                !this.model.get('hidden') && this.show();
-            },
+    onStartResizeOccupants (ev) {
+        this.resizing = true;
+        this.el.addEventListener('mousemove', this.onMouseMove);
+        this.el.addEventListener('mouseup', this.onMouseUp);
 
-            getNotifications () {
-                const actors_per_state = this.model.notifications.toJSON();
-                const states = api.settings.get('muc_show_join_leave') ?
-                    [...converse.CHAT_STATES, ...converse.MUC_TRAFFIC_STATES, ...converse.MUC_ROLE_CHANGES] :
-                    converse.CHAT_STATES;
+        const style = window.getComputedStyle(this.sidebar_view.el);
+        this.width = parseInt(style.width.replace(/px$/, ''), 10);
+        this.prev_pageX = ev.pageX;
+    },
 
-                return states.reduce((result, state) => {
-                    const existing_actors = actors_per_state[state];
-                    if (!(existing_actors?.length)) {
-                        return result;
-                    }
-                    const actors = existing_actors.map(a => this.model.getOccupant(a)?.getDisplayName() || a);
-                    if (actors.length === 1) {
-                        if (state === 'composing') {
-                            return `${result}${__('%1$s is typing', actors[0])}\n`;
-                        } else if (state === 'paused') {
-                            return `${result}${__('%1$s has stopped typing', actors[0])}\n`;
-                        } else if (state === _converse.GONE) {
-                            return `${result}${__('%1$s has gone away', actors[0])}\n`;
-                        } else if (state === 'entered') {
-                            return `${result}${__('%1$s has entered the groupchat', actors[0])}\n`;
-                        } else if (state === 'exited') {
-                            return `${result}${__('%1$s has left the groupchat', actors[0])}\n`;
-                        } else if (state === 'op') {
-                            return `${result}${__("%1$s is now a moderator", actors[0])}\n`;
-                        } else if (state === 'deop') {
-                            return `${result}${__("%1$s is no longer a moderator", actors[0])}\n`;
-                        } else if (state === 'voice') {
-                            return `${result}${__("%1$s has been given a voice", actors[0])}\n`;
-                        } else if (state === 'mute') {
-                            return `${result}${__("%1$s has been muted", actors[0])}\n`;
-                        }
-                    } else if (actors.length > 1) {
-                        let actors_str;
-                        if (actors.length > 3) {
-                            actors_str = `${Array.from(actors).slice(0, 2).join(', ')} and others`;
-                        } else {
-                            const last_actor = actors.pop();
-                            actors_str = __('%1$s and %2$s', actors.join(', '), last_actor);
-                        }
+    onMouseMove (ev) {
+        if (this.resizing) {
+            ev.preventDefault();
+            const delta = this.prev_pageX - ev.pageX;
+            this.resizeSidebarView(delta, ev.pageX);
+            this.prev_pageX = ev.pageX;
+        }
+    },
 
-                        if (state === 'composing') {
-                            return `${result}${__('%1$s are typing', actors_str)}\n`;
-                        } else if (state === 'paused') {
-                            return `${result}${__('%1$s have stopped typing', actors_str)}\n`;
-                        } else if (state === _converse.GONE) {
-                            return `${result}${__('%1$s have gone away', actors_str)}\n`;
-                        } else if (state === 'entered') {
-                            return `${result}${__('%1$s have entered the groupchat', actors_str)}\n`;
-                        } else if (state === 'exited') {
-                            return `${result}${__('%1$s have left the groupchat', actors_str)}\n`;
-                        } else if (state === 'op') {
-                            return `${result}${__("%1$s are now moderators", actors[0])}\n`;
-                        } else if (state === 'deop') {
-                            return `${result}${__("%1$s are no longer moderators", actors[0])}\n`;
-                        } else if (state === 'voice') {
-                            return `${result}${__("%1$s have been given voices", actors[0])}\n`;
-                        } else if (state === 'mute') {
-                            return `${result}${__("%1$s have been muted", actors[0])}\n`;
-                        }
-                    }
-                    return result;
-                }, '');
-            },
+    onMouseUp (ev) {
+        if (this.resizing) {
+            ev.preventDefault();
+            this.resizing = false;
+            this.el.removeEventListener('mousemove', this.onMouseMove);
+            this.el.removeEventListener('mouseup', this.onMouseUp);
+            const element_position = this.sidebar_view.el.getBoundingClientRect();
+            const occupants_width = this.calculateSidebarWidth(element_position, 0);
+            const attrs = {occupants_width};
+            _converse.connection.connected ? this.model.save(attrs) : this.model.set(attrs);
+        }
+    },
 
-            getHelpMessages () {
-                const setting = api.settings.get("muc_disable_slash_commands");
-                const disabled_commands = Array.isArray(setting) ? setting : [];
-                return [
-                    `<strong>/admin</strong>: ${__("Change user's affiliation to admin")}`,
-                    `<strong>/ban</strong>: ${__('Ban user by changing their affiliation to outcast')}`,
-                    `<strong>/clear</strong>: ${__('Clear the chat area')}`,
-                    `<strong>/close</strong>: ${__('Close this groupchat')}`,
-                    `<strong>/deop</strong>: ${__('Change user role to participant')}`,
-                    `<strong>/destroy</strong>: ${__('Remove this groupchat')}`,
-                    `<strong>/help</strong>: ${__('Show this menu')}`,
-                    `<strong>/kick</strong>: ${__('Kick user from groupchat')}`,
-                    `<strong>/me</strong>: ${__('Write in 3rd person')}`,
-                    `<strong>/member</strong>: ${__('Grant membership to a user')}`,
-                    `<strong>/modtools</strong>: ${__('Opens up the moderator tools GUI')}`,
-                    `<strong>/mute</strong>: ${__("Remove user's ability to post messages")}`,
-                    `<strong>/nick</strong>: ${__('Change your nickname')}`,
-                    `<strong>/op</strong>: ${__('Grant moderator role to user')}`,
-                    `<strong>/owner</strong>: ${__('Grant ownership of this groupchat')}`,
-                    `<strong>/register</strong>: ${__("Register your nickname")}`,
-                    `<strong>/revoke</strong>: ${__("Revoke the user's current affiliation")}`,
-                    `<strong>/subject</strong>: ${__('Set groupchat subject')}`,
-                    `<strong>/topic</strong>: ${__('Set groupchat subject (alias for /subject)')}`,
-                    `<strong>/voice</strong>: ${__('Allow muted user to post messages')}`
-                    ].filter(line => disabled_commands.every(c => (!line.startsWith(c+'<', 9))))
-                        .filter(line => this.getAllowedCommands().some(c => line.startsWith(c+'<', 9)));
-            },
+    resizeSidebarView (delta, current_mouse_position) {
+        const element_position = this.sidebar_view.el.getBoundingClientRect();
+        if (this.is_minimum) {
+            this.is_minimum = element_position.left < current_mouse_position;
+        } else if (this.is_maximum) {
+            this.is_maximum = element_position.left > current_mouse_position;
+        } else {
+            const occupants_width = this.calculateSidebarWidth(element_position, delta);
+            this.sidebar_view.el.style.flex = "0 0 " + occupants_width + "px";
+        }
+    },
 
-            /**
-             * Renders the MUC heading if any relevant attributes have changed.
-             * @private
-             * @method _converse.ChatRoomView#renderHeading
-             * @param { _converse.ChatRoom } [item]
-             */
-            async renderHeading () {
-                const tpl = await this.generateHeadingTemplate();
-                render(tpl, this.el.querySelector('.chat-head-chatroom'));
-            },
+    calculateSidebarWidth(element_position, delta) {
+        let occupants_width = element_position.width + delta;
+        const room_width = this.el.clientWidth;
+        // keeping display in boundaries
+        if (occupants_width < (room_width * 0.20)) {
+            // set pixel to 20% width
+            occupants_width = (room_width * 0.20);
+            this.is_minimum = true;
+        } else if (occupants_width > (room_width * 0.75)) {
+            // set pixel to 75% width
+            occupants_width = (room_width * 0.75);
+            this.is_maximum = true;
+        } else if ((room_width - occupants_width) < 250) {
+            // resize occupants if chat-area becomes smaller than 250px (min-width property set in css)
+            occupants_width = room_width - 250;
+            this.is_maximum = true;
+        } else {
+            this.is_maximum = false;
+            this.is_minimum = false;
+        }
+        return occupants_width;
+    },
 
+    getAutoCompleteList () {
+        return this.model.getAllKnownNicknames().map(nick => ({'label': nick, 'value': `@${nick}`}));
+    },
 
-            renderBottomPanel () {
-                const container = this.el.querySelector('.bottom-panel');
-                const entered = this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED;
-                const can_edit = entered && !(this.model.features.get('moderated') && this.model.getOwnRole() === 'visitor');
-                container.innerHTML = tpl_chatroom_bottom_panel({__, can_edit, entered});
-                if (entered && can_edit) {
-                    this.renderMessageForm();
-                    this.initMentionAutoComplete();
-                }
-            },
+    getAutoCompleteListItem(text, input) {
+        input = input.trim();
+        const element = document.createElement("li");
+        element.setAttribute("aria-selected", "false");
 
-            createSidebarView () {
-                this.model.occupants.chatroomview = this;
-                this.sidebar_view = new _converse.MUCSidebar({'model': this.model.occupants});
-                const container_el = this.el.querySelector('.chatroom-body');
-                const occupants_width = this.model.get('occupants_width');
-                if (this.sidebar_view && occupants_width !== undefined) {
-                    this.sidebar_view.el.style.flex = "0 0 " + occupants_width + "px";
-                }
-                container_el.insertAdjacentElement('beforeend', this.sidebar_view.el);
-            },
+        if (api.settings.get('muc_mention_autocomplete_show_avatar')) {
+            const img = document.createElement("img");
+            let dataUri = "data:" + _converse.DEFAULT_IMAGE_TYPE + ";base64," + _converse.DEFAULT_IMAGE;
 
-            onStartResizeOccupants (ev) {
-                this.resizing = true;
-                this.el.addEventListener('mousemove', this.onMouseMove);
-                this.el.addEventListener('mouseup', this.onMouseUp);
+            if (_converse.vcards) {
+                const vcard = _converse.vcards.findWhere({'nickname': text});
+                if (vcard) dataUri = "data:" + vcard.get('image_type') + ";base64," + vcard.get('image');
+            }
 
-                const style = window.getComputedStyle(this.sidebar_view.el);
-                this.width = parseInt(style.width.replace(/px$/, ''), 10);
-                this.prev_pageX = ev.pageX;
-            },
+            img.setAttribute("src", dataUri);
+            img.setAttribute("width", "22");
+            img.setAttribute("class", "avatar avatar-autocomplete");
+            element.appendChild(img);
+        }
 
-            onMouseMove (ev) {
-                if (this.resizing) {
-                    ev.preventDefault();
-                    const delta = this.prev_pageX - ev.pageX;
-                    this.resizeSidebarView(delta, ev.pageX);
-                    this.prev_pageX = ev.pageX;
-                }
-            },
+        const regex = new RegExp("(" + input + ")", "ig");
+        const parts = input ? text.split(regex) : [text];
 
-            onMouseUp (ev) {
-                if (this.resizing) {
-                    ev.preventDefault();
-                    this.resizing = false;
-                    this.el.removeEventListener('mousemove', this.onMouseMove);
-                    this.el.removeEventListener('mouseup', this.onMouseUp);
-                    const element_position = this.sidebar_view.el.getBoundingClientRect();
-                    const occupants_width = this.calculateSidebarWidth(element_position, 0);
-                    const attrs = {occupants_width};
-                    _converse.connection.connected ? this.model.save(attrs) : this.model.set(attrs);
-                }
-            },
+        parts.forEach(txt => {
+            if (input && txt.match(regex)) {
+              const match = document.createElement("mark");
+              match.textContent = txt;
+              element.appendChild(match);
+            } else {
+              element.appendChild(document.createTextNode(txt));
+            }
+        });
 
-            resizeSidebarView (delta, current_mouse_position) {
-                const element_position = this.sidebar_view.el.getBoundingClientRect();
-                if (this.is_minimum) {
-                    this.is_minimum = element_position.left < current_mouse_position;
-                } else if (this.is_maximum) {
-                    this.is_maximum = element_position.left > current_mouse_position;
-                } else {
-                    const occupants_width = this.calculateSidebarWidth(element_position, delta);
-                    this.sidebar_view.el.style.flex = "0 0 " + occupants_width + "px";
-                }
-            },
+        return element;
+    },
 
-            calculateSidebarWidth(element_position, delta) {
-                let occupants_width = element_position.width + delta;
-                const room_width = this.el.clientWidth;
-                // keeping display in boundaries
-                if (occupants_width < (room_width * 0.20)) {
-                    // set pixel to 20% width
-                    occupants_width = (room_width * 0.20);
-                    this.is_minimum = true;
-                } else if (occupants_width > (room_width * 0.75)) {
-                    // set pixel to 75% width
-                    occupants_width = (room_width * 0.75);
-                    this.is_maximum = true;
-                } else if ((room_width - occupants_width) < 250) {
-                    // resize occupants if chat-area becomes smaller than 250px (min-width property set in css)
-                    occupants_width = room_width - 250;
-                    this.is_maximum = true;
-                } else {
-                    this.is_maximum = false;
-                    this.is_minimum = false;
-                }
-                return occupants_width;
-            },
+    initMentionAutoComplete () {
+        this.mention_auto_complete = new _converse.AutoComplete(this.el, {
+            'auto_first': true,
+            'auto_evaluate': false,
+            'min_chars': api.settings.get('muc_mention_autocomplete_min_chars'),
+            'match_current_word': true,
+            'list': () => this.getAutoCompleteList(),
+            'filter': api.settings.get('muc_mention_autocomplete_filter') == 'contains' ?
+                _converse.FILTER_CONTAINS :
+                _converse.FILTER_STARTSWITH,
+            'ac_triggers': ["Tab", "@"],
+            'include_triggers': [],
+            'item': this.getAutoCompleteListItem
+        });
+        this.mention_auto_complete.on('suggestion-box-selectcomplete', () => (this.auto_completing = false));
+    },
 
-            getAutoCompleteList () {
-                return this.model.getAllKnownNicknames().map(nick => ({'label': nick, 'value': `@${nick}`}));
-            },
+    /**
+     * Get the nickname value from the form and then join the groupchat with it.
+     * @private
+     * @method _converse.ChatRoomView#submitNickname
+     * @param { Event }
+     */
+    submitNickname (ev) {
+        ev.preventDefault();
+        const nick = ev.target.nick.value.trim();
+        nick && this.model.join(nick);
+    },
 
-            getAutoCompleteListItem(text, input) {
-                input = input.trim();
-                const element = document.createElement("li");
-                element.setAttribute("aria-selected", "false");
+    onKeyDown (ev) {
+        if (this.mention_auto_complete.onKeyDown(ev)) {
+            return;
+        }
+        return _converse.ChatBoxView.prototype.onKeyDown.call(this, ev);
+    },
 
-                if (api.settings.get('muc_mention_autocomplete_show_avatar')) {
-                    const img = document.createElement("img");
-                    let dataUri = "data:" + _converse.DEFAULT_IMAGE_TYPE + ";base64," + _converse.DEFAULT_IMAGE;
+    onKeyUp (ev) {
+        this.mention_auto_complete.evaluate(ev);
+        return _converse.ChatBoxView.prototype.onKeyUp.call(this, ev);
+    },
 
-                    if (_converse.vcards) {
-                        const vcard = _converse.vcards.findWhere({'nickname': text});
-                        if (vcard) dataUri = "data:" + vcard.get('image_type') + ";base64," + vcard.get('image');
-                    }
+    async onMessageRetractButtonClicked (message) {
+        const retraction_warning =
+            __("Be aware that other XMPP/Jabber clients (and servers) may "+
+                "not yet support retractions and that this message may not "+
+                "be removed everywhere.");
 
-                    img.setAttribute("src", dataUri);
-                    img.setAttribute("width", "22");
-                    img.setAttribute("class", "avatar avatar-autocomplete");
-                    element.appendChild(img);
-                }
+        if (message.mayBeRetracted()) {
+            const messages = [__('Are you sure you want to retract this message?')];
+            if (api.settings.get('show_retraction_warning')) {
+                messages[1] = retraction_warning;
+            }
+            !!(await api.confirm(__('Confirm'), messages)) && this.model.retractOwnMessage(message);
+        } else if (await message.mayBeModerated()) {
+            if (message.get('sender') === 'me') {
+                let messages = [__('Are you sure you want to retract this message?')];
+                if (api.settings.get('show_retraction_warning')) {
+                    messages = [messages[0], retraction_warning, messages[1]]
+                }
+                !!(await api.confirm(__('Confirm'), messages)) && this.retractOtherMessage(message);
+            } else {
+                let messages = [
+                    __('You are about to retract this message.'),
+                    __('You may optionally include a message, explaining the reason for the retraction.')
+                ];
+                if (api.settings.get('show_retraction_warning')) {
+                    messages = [messages[0], retraction_warning, messages[1]]
+                }
+                const reason = await api.prompt(
+                    __('Message Retraction'),
+                    messages,
+                    __('Optional reason')
+                );
+                (reason !== false) && this.retractOtherMessage(message, reason);
+            }
+        } else {
+            const err_msg = __(`Sorry, you're not allowed to retract this message`);
+            api.alert('error', __('Error'), err_msg);
+        }
+    },
 
-                const regex = new RegExp("(" + input + ")", "ig");
-                const parts = input ? text.split(regex) : [text];
+    /**
+     * Retract someone else's message in this groupchat.
+     * @private
+     * @method _converse.ChatRoomView#retractOtherMessage
+     * @param { _converse.Message } message - The message which we're retracting.
+     * @param { string } [reason] - The reason for retracting the message.
+     */
+    async retractOtherMessage (message, reason) {
+        const result = await this.model.retractOtherMessage(message, reason);
+        if (result === null) {
+            const err_msg = __(`A timeout occurred while trying to retract the message`);
+            api.alert('error', __('Error'), err_msg);
+            log(err_msg, Strophe.LogLevel.WARN);
+        } else if (u.isErrorStanza(result)) {
+            const err_msg = __(`Sorry, you're not allowed to retract this message.`);
+            api.alert('error', __('Error'), err_msg);
+            log(err_msg, Strophe.LogLevel.WARN);
+            log(result, Strophe.LogLevel.WARN);
+        }
+    },
 
-                parts.forEach(txt => {
-                    if (input && txt.match(regex)) {
-                      const match = document.createElement("mark");
-                      match.textContent = txt;
-                      element.appendChild(match);
-                    } else {
-                      element.appendChild(document.createTextNode(txt));
-                    }
-                });
+    showModeratorToolsModal (affiliation) {
+        if (!this.verifyRoles(['moderator'])) {
+            return;
+        }
+        if (isUndefined(this.model.modtools_modal)) {
+            const model = new Model({'affiliation': affiliation});
+            this.modtools_modal = new ModeratorToolsModal({model, _converse, 'chatroomview': this});
+        } else {
+            this.modtools_modal.set('affiliation', affiliation);
+        }
+        this.modtools_modal.show();
+    },
 
-                return element;
-            },
+    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);
+    },
 
-            initMentionAutoComplete () {
-                this.mention_auto_complete = new _converse.AutoComplete(this.el, {
-                    'auto_first': true,
-                    'auto_evaluate': false,
-                    'min_chars': api.settings.get('muc_mention_autocomplete_min_chars'),
-                    'match_current_word': true,
-                    'list': () => this.getAutoCompleteList(),
-                    'filter': api.settings.get('muc_mention_autocomplete_filter') == 'contains' ?
-                        _converse.FILTER_CONTAINS :
-                        _converse.FILTER_STARTSWITH,
-                    'ac_triggers': ["Tab", "@"],
-                    'include_triggers': [],
-                    'item': this.getAutoCompleteListItem
-                });
-                this.mention_auto_complete.on('suggestion-box-selectcomplete', () => (this.auto_completing = false));
-            },
+    showChatStateNotification (message) {
+        if (message.get('sender') === 'me') {
+            return;
+        }
+        return _converse.ChatBoxView.prototype.showChatStateNotification.apply(this, arguments);
+    },
 
-            /**
-             * Get the nickname value from the form and then join the groupchat with it.
-             * @private
-             * @method _converse.ChatRoomView#submitNickname
-             * @param { Event }
-             */
-            submitNickname (ev) {
-                ev.preventDefault();
-                const nick = ev.target.nick.value.trim();
-                nick && this.model.join(nick);
-            },
+    onOccupantAffiliationChanged (occupant) {
+        if (occupant.get('jid') === _converse.bare_jid) {
+            this.renderHeading();
+        }
+    },
 
-            onKeyDown (ev) {
-                if (this.mention_auto_complete.onKeyDown(ev)) {
-                    return;
-                }
-                return _converse.ChatBoxView.prototype.onKeyDown.call(this, ev);
-            },
+    onOccupantRoleChanged (occupant) {
+        if (occupant.get('jid') === _converse.bare_jid) {
+            this.renderBottomPanel();
+        }
+    },
 
-            onKeyUp (ev) {
-                this.mention_auto_complete.evaluate(ev);
-                return _converse.ChatBoxView.prototype.onKeyUp.call(this, ev);
-            },
+    /**
+     * Returns a list of objects which represent buttons for the groupchat header.
+     * @emits _converse#getHeadingButtons
+     * @private
+     * @method _converse.ChatRoomView#getHeadingButtons
+     */
+    getHeadingButtons (subject_hidden) {
+        const buttons = [];
+        buttons.push({
+            'i18n_text': __('Details'),
+            'i18n_title': __('Show more information about this groupchat'),
+            'handler': ev => this.showRoomDetailsModal(ev),
+            'a_class': 'show-room-details-modal',
+            'icon_class': 'fa-info-circle',
+            'name': 'details'
+        });
 
-            async onMessageRetractButtonClicked (message) {
-                const retraction_warning =
-                    __("Be aware that other XMPP/Jabber clients (and servers) may "+
-                        "not yet support retractions and that this message may not "+
-                        "be removed everywhere.");
+        if (this.model.getOwnAffiliation() === 'owner') {
+            buttons.push({
+                'i18n_text': __('Configure'),
+                'i18n_title': __('Configure this groupchat'),
+                'handler': ev => this.getAndRenderConfigurationForm(ev),
+                'a_class': 'configure-chatroom-button',
+                'icon_class': 'fa-wrench',
+                'name': 'configure'
+            });
+        }
 
-                if (message.mayBeRetracted()) {
-                    const messages = [__('Are you sure you want to retract this message?')];
-                    if (api.settings.get('show_retraction_warning')) {
-                        messages[1] = retraction_warning;
-                    }
-                    !!(await api.confirm(__('Confirm'), messages)) && this.model.retractOwnMessage(message);
-                } else if (await message.mayBeModerated()) {
-                    if (message.get('sender') === 'me') {
-                        let messages = [__('Are you sure you want to retract this message?')];
-                        if (api.settings.get('show_retraction_warning')) {
-                            messages = [messages[0], retraction_warning, messages[1]]
-                        }
-                        !!(await api.confirm(__('Confirm'), messages)) && this.retractOtherMessage(message);
-                    } else {
-                        let messages = [
-                            __('You are about to retract this message.'),
-                            __('You may optionally include a message, explaining the reason for the retraction.')
-                        ];
-                        if (api.settings.get('show_retraction_warning')) {
-                            messages = [messages[0], retraction_warning, messages[1]]
-                        }
-                        const reason = await api.prompt(
-                            __('Message Retraction'),
-                            messages,
-                            __('Optional reason')
-                        );
-                        (reason !== false) && this.retractOtherMessage(message, reason);
-                    }
-                } else {
-                    const err_msg = __(`Sorry, you're not allowed to retract this message`);
-                    api.alert('error', __('Error'), err_msg);
-                }
-            },
+        if (this.model.invitesAllowed()) {
+            buttons.push({
+                'i18n_text': __('Invite'),
+                'i18n_title': __('Invite someone to join this groupchat'),
+                'handler': ev => this.showInviteModal(ev),
+                'a_class': 'open-invite-modal',
+                'icon_class': 'fa-user-plus',
+                'name': 'invite'
+            });
+        }
 
-            /**
-             * Retract someone else's message in this groupchat.
-             * @private
-             * @method _converse.ChatRoomView#retractOtherMessage
-             * @param { _converse.Message } message - The message which we're retracting.
-             * @param { string } [reason] - The reason for retracting the message.
-             */
-            async retractOtherMessage (message, reason) {
-                const result = await this.model.retractOtherMessage(message, reason);
-                if (result === null) {
-                    const err_msg = __(`A timeout occurred while trying to retract the message`);
-                    api.alert('error', __('Error'), err_msg);
-                    log(err_msg, Strophe.LogLevel.WARN);
-                } else if (u.isErrorStanza(result)) {
-                    const err_msg = __(`Sorry, you're not allowed to retract this message.`);
-                    api.alert('error', __('Error'), err_msg);
-                    log(err_msg, Strophe.LogLevel.WARN);
-                    log(result, Strophe.LogLevel.WARN);
-                }
-            },
+        const subject = this.model.get('subject');
+        if (subject && subject.text) {
+            buttons.push({
+                'i18n_text': subject_hidden ? __('Show topic') : __('Hide topic'),
+                'i18n_title': subject_hidden ?
+                    __('Show the topic message in the heading') :
+                    __('Hide the topic in the heading'),
+                'handler': ev => this.toggleTopic(ev),
+                'a_class': 'hide-topic',
+                'icon_class': 'fa-minus-square',
+                'name': 'toggle-topic'
+            });
+        }
 
-            showModeratorToolsModal (affiliation) {
-                if (!this.verifyRoles(['moderator'])) {
-                    return;
-                }
-                if (isUndefined(this.model.modtools_modal)) {
-                    const model = new Model({'affiliation': affiliation});
-                    this.modtools_modal = new ModeratorToolsModal({model, _converse, 'chatroomview': this});
-                } else {
-                    this.modtools_modal.set('affiliation', affiliation);
-                }
-                this.modtools_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);
-            },
+        const conn_status = this.model.session.get('connection_status');
+        if (conn_status === converse.ROOMSTATUS.ENTERED) {
+            const allowed_commands = this.getAllowedCommands();
+            if (allowed_commands.includes('modtools')) {
+                buttons.push({
+                    'i18n_text': __('Moderate'),
+                    'i18n_title': __('Moderate this groupchat'),
+                    'handler': () => this.showModeratorToolsModal(),
+                    'a_class': 'moderate-chatroom-button',
+                    'icon_class': 'fa-user-cog',
+                    'name': 'moderate'
+                });
+            }
+            if (allowed_commands.includes('destroy')) {
+                buttons.push({
+                    'i18n_text': __('Destroy'),
+                    'i18n_title': __('Remove this groupchat'),
+                    'handler': ev => this.destroy(ev),
+                    'a_class': 'destroy-chatroom-button',
+                    'icon_class': 'fa-trash',
+                    'name': 'destroy'
+                });
+            }
+        }
 
-            showChatStateNotification (message) {
-                if (message.get('sender') === 'me') {
-                    return;
-                }
-                return _converse.ChatBoxView.prototype.showChatStateNotification.apply(this, arguments);
-            },
+        if (!api.settings.get("singleton")) {
+            buttons.push({
+                'i18n_text': __('Leave'),
+                'i18n_title': __('Leave and close this groupchat'),
+                'handler': async ev => {
+                    ev.stopPropagation();
+                    const messages = [__('Are you sure you want to leave this groupchat?')];
+                    const result = await api.confirm(__('Confirm'), messages);
+                    result && this.close(ev);
+                },
+                'a_class': 'close-chatbox-button',
+                'standalone': api.settings.get("view_mode") === 'overlayed',
+                'icon_class': 'fa-sign-out-alt',
+                'name': 'signout'
+            });
+        }
+        return _converse.api.hook('getHeadingButtons', this, buttons);
+    },
 
-            onOccupantAffiliationChanged (occupant) {
-                if (occupant.get('jid') === _converse.bare_jid) {
-                    this.renderHeading();
-                }
-            },
+    /**
+     * Returns the groupchat heading TemplateResult to be rendered.
+     * @private
+     * @method _converse.ChatRoomView#generateHeadingTemplate
+     */
+    async generateHeadingTemplate () {
+        const subject_hidden = await this.model.isSubjectHidden();
+        const heading_btns = await this.getHeadingButtons(subject_hidden);
+        const standalone_btns = heading_btns.filter(b => b.standalone);
+        const dropdown_btns = heading_btns.filter(b => !b.standalone);
+        return tpl_chatroom_head(
+            Object.assign(this.model.toJSON(), {
+                _converse,
+                subject_hidden,
+                'dropdown_btns': dropdown_btns.map(b => this.getHeadingDropdownItem(b)),
+                'standalone_btns': standalone_btns.map(b => this.getHeadingStandaloneButton(b)),
+                'title': this.model.getDisplayName(),
+        }));
+    },
 
-            onOccupantRoleChanged (occupant) {
-                if (occupant.get('jid') === _converse.bare_jid) {
-                    this.renderBottomPanel();
-                }
-            },
+    toggleTopic () {
+        this.model.toggleSubjectHiddenState();
+    },
 
-            /**
-             * Returns a list of objects which represent buttons for the groupchat header.
-             * @emits _converse#getHeadingButtons
-             * @private
-             * @method _converse.ChatRoomView#getHeadingButtons
-             */
-            getHeadingButtons (subject_hidden) {
-                const buttons = [];
-                buttons.push({
-                    'i18n_text': __('Details'),
-                    'i18n_title': __('Show more information about this groupchat'),
-                    'handler': ev => this.showRoomDetailsModal(ev),
-                    'a_class': 'show-room-details-modal',
-                    'icon_class': 'fa-info-circle',
-                    'name': 'details'
-                });
+    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);
+    },
 
-                if (this.model.getOwnAffiliation() === 'owner') {
-                    buttons.push({
-                        'i18n_text': __('Configure'),
-                        'i18n_title': __('Configure this groupchat'),
-                        'handler': ev => this.getAndRenderConfigurationForm(ev),
-                        'a_class': 'configure-chatroom-button',
-                        'icon_class': 'fa-wrench',
-                        'name': 'configure'
-                    });
-                }
 
-                if (this.model.invitesAllowed()) {
-                    buttons.push({
-                        'i18n_text': __('Invite'),
-                        'i18n_title': __('Invite someone to join this groupchat'),
-                        'handler': ev => this.showInviteModal(ev),
-                        'a_class': 'open-invite-modal',
-                        'icon_class': 'fa-user-plus',
-                        'name': 'invite'
-                    });
-                }
+    /**
+     * Callback method that gets called after the chat has become visible.
+     * @private
+     * @method _converse.ChatRoomView#afterShown
+     */
+    afterShown () {
+        // Override from converse-chatview, specifically to avoid
+        // the 'active' chat state from being sent out prematurely.
+        // This is instead done in `onConnectionStatusChanged` below.
+        if (u.isPersistableModel(this.model)) {
+            this.model.clearUnreadMsgCounter();
+        }
+        this.scrollDown();
+    },
 
-                const subject = this.model.get('subject');
-                if (subject && subject.text) {
-                    buttons.push({
-                        'i18n_text': subject_hidden ? __('Show topic') : __('Hide topic'),
-                        'i18n_title': subject_hidden ?
-                            __('Show the topic message in the heading') :
-                            __('Hide the topic in the heading'),
-                        'handler': ev => this.toggleTopic(ev),
-                        'a_class': 'hide-topic',
-                        'icon_class': 'fa-minus-square',
-                        'name': 'toggle-topic'
-                    });
-                }
+    onConnectionStatusChanged () {
+        const conn_status = this.model.session.get('connection_status');
+        if (conn_status === converse.ROOMSTATUS.NICKNAME_REQUIRED) {
+            this.renderNicknameForm();
+        } else if (conn_status === converse.ROOMSTATUS.PASSWORD_REQUIRED) {
+            this.renderPasswordForm();
+        } else if (conn_status === converse.ROOMSTATUS.CONNECTING) {
+            this.showSpinner();
+        } else if (conn_status === converse.ROOMSTATUS.ENTERED) {
+            this.renderBottomPanel();
+            this.hideSpinner();
+            this.maybeFocus();
+        } else if (conn_status === converse.ROOMSTATUS.DISCONNECTED) {
+            this.showDisconnectMessage();
+        } else if (conn_status === converse.ROOMSTATUS.DESTROYED) {
+            this.showDestroyedMessage();
+        }
+    },
 
+    getToolbarOptions () {
+        return Object.assign(
+            _converse.ChatBoxView.prototype.getToolbarOptions.apply(this, arguments), {
+                'is_groupchat': true,
+                'label_hide_occupants': __('Hide the list of participants'),
+                'show_occupants_toggle': _converse.visible_toolbar_buttons.toggle_occupants
+            }
+        );
+    },
 
-                const conn_status = this.model.session.get('connection_status');
-                if (conn_status === converse.ROOMSTATUS.ENTERED) {
-                    const allowed_commands = this.getAllowedCommands();
-                    if (allowed_commands.includes('modtools')) {
-                        buttons.push({
-                            'i18n_text': __('Moderate'),
-                            'i18n_title': __('Moderate this groupchat'),
-                            'handler': () => this.showModeratorToolsModal(),
-                            'a_class': 'moderate-chatroom-button',
-                            'icon_class': 'fa-user-cog',
-                            'name': 'moderate'
-                        });
-                    }
-                    if (allowed_commands.includes('destroy')) {
-                        buttons.push({
-                            'i18n_text': __('Destroy'),
-                            'i18n_title': __('Remove this groupchat'),
-                            'handler': ev => this.destroy(ev),
-                            'a_class': 'destroy-chatroom-button',
-                            'icon_class': 'fa-trash',
-                            'name': 'destroy'
-                        });
-                    }
-                }
-
-                if (!api.settings.get("singleton")) {
-                    buttons.push({
-                        'i18n_text': __('Leave'),
-                        'i18n_title': __('Leave and close this groupchat'),
-                        'handler': async ev => {
-                            ev.stopPropagation();
-                            const messages = [__('Are you sure you want to leave this groupchat?')];
-                            const result = await api.confirm(__('Confirm'), messages);
-                            result && this.close(ev);
-                        },
-                        'a_class': 'close-chatbox-button',
-                        'standalone': api.settings.get("view_mode") === 'overlayed',
-                        'icon_class': 'fa-sign-out-alt',
-                        'name': 'signout'
-                    });
-                }
-                return _converse.api.hook('getHeadingButtons', this, buttons);
-            },
+    /**
+     * Closes this chat box, which implies leaving the groupchat as well.
+     * @private
+     * @method _converse.ChatRoomView#close
+     */
+    async close () {
+        this.hide();
+        if (_converse.router.history.getFragment() === "converse/room?jid="+this.model.get('jid')) {
+            _converse.router.navigate('');
+        }
+        await this.model.leave();
+        return _converse.ChatBoxView.prototype.close.apply(this, arguments);
+    },
 
-            /**
-             * Returns the groupchat heading TemplateResult to be rendered.
-             * @private
-             * @method _converse.ChatRoomView#generateHeadingTemplate
-             */
-            async generateHeadingTemplate () {
-                const subject_hidden = await this.model.isSubjectHidden();
-                const heading_btns = await this.getHeadingButtons(subject_hidden);
-                const standalone_btns = heading_btns.filter(b => b.standalone);
-                const dropdown_btns = heading_btns.filter(b => !b.standalone);
-                return tpl_chatroom_head(
-                    Object.assign(this.model.toJSON(), {
-                        _converse,
-                        subject_hidden,
-                        'dropdown_btns': dropdown_btns.map(b => this.getHeadingDropdownItem(b)),
-                        'standalone_btns': standalone_btns.map(b => this.getHeadingStandaloneButton(b)),
-                        'title': this.model.getDisplayName(),
-                }));
-            },
+    /**
+     * Hide the right sidebar containing the chat occupants.
+     * @private
+     * @method _converse.ChatRoomView#hideOccupants
+     */
+    hideOccupants (ev) {
+        if (ev) {
+            ev.preventDefault();
+            ev.stopPropagation();
+        }
+        this.model.save({'hidden_occupants': true});
+        this.scrollDown();
+    },
 
-            toggleTopic () {
-                this.model.toggleSubjectHiddenState();
-            },
+    verifyRoles (roles, occupant, show_error=true) {
+        if (!Array.isArray(roles)) {
+            throw new TypeError('roles must be an Array');
+        }
+        if (!roles.length) {
+            return true;
+        }
+        occupant = occupant || this.model.occupants.findWhere({'jid': _converse.bare_jid});
+        if (occupant) {
+            const role = occupant.get('role');
+            if (roles.includes(role)) {
+                return true;
+            }
+        }
+        if (show_error) {
+            const message = __('Forbidden: you do not have the necessary role in order to do that.');
+            this.model.createMessage({message, 'type': 'error'});
+        }
+        return false;
+    },
 
-            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);
-            },
+    verifyAffiliations (affiliations, occupant, show_error=true) {
+        if (!Array.isArray(affiliations)) {
+            throw new TypeError('affiliations must be an Array');
+        }
+        if (!affiliations.length) {
+            return true;
+        }
+        occupant = occupant || this.model.occupants.findWhere({'jid': _converse.bare_jid});
+        if (occupant) {
+            const a = occupant.get('affiliation');
+            if (affiliations.includes(a)) {
+                return true;
+            }
+        }
+        if (show_error) {
+            const message = __('Forbidden: you do not have the necessary affiliation in order to do that.');
+            this.model.createMessage({message, 'type': 'error'});
+        }
+        return false;
+    },
 
+    validateRoleOrAffiliationChangeArgs (command, args) {
+        if (!args) {
+            const message = __(
+                'Error: the "%1$s" command takes two arguments, the user\'s nickname and optionally a reason.',
+                command
+            );
+            this.model.createMessage({message, 'type': 'error'});
+            return false;
+        }
+        return true;
+    },
 
-            /**
-             * Callback method that gets called after the chat has become visible.
-             * @private
-             * @method _converse.ChatRoomView#afterShown
-             */
-            afterShown () {
-                // Override from converse-chatview, specifically to avoid
-                // the 'active' chat state from being sent out prematurely.
-                // This is instead done in `onConnectionStatusChanged` below.
-                if (u.isPersistableModel(this.model)) {
-                    this.model.clearUnreadMsgCounter();
-                }
-                this.scrollDown();
-            },
+    getNickOrJIDFromCommandArgs (args) {
+        if (u.isValidJID(args.trim())) {
+            return args.trim();
+        }
+        if (!args.startsWith('@')) {
+            args = '@'+ args;
+        }
+        const [text, references] = this.model.parseTextForReferences(args); // eslint-disable-line no-unused-vars
+        if (!references.length) {
+            const message = __("Error: couldn't find a groupchat participant based on your arguments");
+            this.model.createMessage({message, 'type': 'error'});
+            return;
+        }
+        if (references.length > 1) {
+            const message = __("Error: found multiple groupchat participant based on your arguments");
+            this.model.createMessage({message, 'type': 'error'});
+            return;
+        }
+        const nick_or_jid = references.pop().value;
+        const reason = args.split(nick_or_jid, 2)[1];
+        if (reason && !reason.startsWith(' ')) {
+            const message = __("Error: couldn't find a groupchat participant based on your arguments");
+            this.model.createMessage({message, 'type': 'error'});
+            return;
+        }
+        return nick_or_jid;
+    },
 
-            onConnectionStatusChanged () {
-                const conn_status = this.model.session.get('connection_status');
-                if (conn_status === converse.ROOMSTATUS.NICKNAME_REQUIRED) {
-                    this.renderNicknameForm();
-                } else if (conn_status === converse.ROOMSTATUS.PASSWORD_REQUIRED) {
-                    this.renderPasswordForm();
-                } else if (conn_status === converse.ROOMSTATUS.CONNECTING) {
-                    this.showSpinner();
-                } else if (conn_status === converse.ROOMSTATUS.ENTERED) {
-                    this.renderBottomPanel();
-                    this.hideSpinner();
-                    this.maybeFocus();
-                } else if (conn_status === converse.ROOMSTATUS.DISCONNECTED) {
-                    this.showDisconnectMessage();
-                } else if (conn_status === converse.ROOMSTATUS.DESTROYED) {
-                    this.showDestroyedMessage();
-                }
-            },
+    setAffiliation (command, args, required_affiliations) {
+        const affiliation = COMMAND_TO_AFFILIATION[command];
+        if (!affiliation) {
+            throw Error(`ChatRoomView#setAffiliation called with invalid command: ${command}`);
+        }
+        if (!this.verifyAffiliations(required_affiliations)) {
+            return false;
+        }
+        if (!this.validateRoleOrAffiliationChangeArgs(command, args)) {
+            return false;
+        }
+        const nick_or_jid = this.getNickOrJIDFromCommandArgs(args);
+        if (!nick_or_jid) {
+            return false;
+        }
 
-            getToolbarOptions () {
-                return Object.assign(
-                    _converse.ChatBoxView.prototype.getToolbarOptions.apply(this, arguments), {
-                        'is_groupchat': true,
-                        'label_hide_occupants': __('Hide the list of participants'),
-                        'show_occupants_toggle': _converse.visible_toolbar_buttons.toggle_occupants
-                    }
+        let jid;
+        const reason = args.split(nick_or_jid, 2)[1].trim();
+        const occupant = this.model.getOccupant(nick_or_jid);
+        if (occupant) {
+            jid = occupant.get('jid');
+        } else {
+            if (u.isValidJID(nick_or_jid)) {
+                jid = nick_or_jid;
+            } else {
+                const message = __(
+                    "Couldn't find a participant with that nickname. "+
+                    "They might have left the groupchat."
                 );
-            },
+                this.model.createMessage({message, 'type': 'error'});
+                return;
+            }
+        }
+        const attrs = { jid, reason };
+        if (occupant && api.settings.get('auto_register_muc_nickname')) {
+            attrs['nick'] = occupant.get('nick');
+        }
+        this.model.setAffiliation(affiliation, [attrs])
+            .then(() => this.model.occupants.fetchMembers())
+            .catch(err => this.onCommandError(err));
+    },
 
-            /**
-             * Closes this chat box, which implies leaving the groupchat as well.
-             * @private
-             * @method _converse.ChatRoomView#close
-             */
-            async close () {
-                this.hide();
-                if (_converse.router.history.getFragment() === "converse/room?jid="+this.model.get('jid')) {
-                    _converse.router.navigate('');
-                }
-                await this.model.leave();
-                return _converse.ChatBoxView.prototype.close.apply(this, arguments);
-            },
+    getReason (args) {
+        return args.includes(',') ? args.slice(args.indexOf(',')+1).trim() : null;
+    },
 
-            /**
-             * Hide the right sidebar containing the chat occupants.
-             * @private
-             * @method _converse.ChatRoomView#hideOccupants
-             */
-            hideOccupants (ev) {
-                if (ev) {
-                    ev.preventDefault();
-                    ev.stopPropagation();
-                }
-                this.model.save({'hidden_occupants': true});
-                this.scrollDown();
-            },
+    setRole (command, args, required_affiliations=[], required_roles=[]) {
+        /* Check that a command to change a groupchat user's role or
+         * affiliation has anough arguments.
+         */
+        const role = COMMAND_TO_ROLE[command];
+        if (!role) {
+            throw Error(`ChatRoomView#setRole called with invalid command: ${command}`);
+        }
+        if (!this.verifyAffiliations(required_affiliations) || !this.verifyRoles(required_roles)) {
+            return false;
+        }
+        if (!this.validateRoleOrAffiliationChangeArgs(command, args)) {
+            return false;
+        }
+        const nick_or_jid = this.getNickOrJIDFromCommandArgs(args);
+        if (!nick_or_jid) {
+            return false;
+        }
+        const reason = args.split(nick_or_jid, 2)[1].trim();
+        // We're guaranteed to have an occupant due to getNickOrJIDFromCommandArgs
+        const occupant = this.model.getOccupant(nick_or_jid);
+        this.model.setRole(occupant, role, reason, undefined, this.onCommandError.bind(this));
+        return true;
+    },
 
-            verifyRoles (roles, occupant, show_error=true) {
-                if (!Array.isArray(roles)) {
-                    throw new TypeError('roles must be an Array');
-                }
-                if (!roles.length) {
-                    return true;
-                }
-                occupant = occupant || this.model.occupants.findWhere({'jid': _converse.bare_jid});
-                if (occupant) {
-                    const role = occupant.get('role');
-                    if (roles.includes(role)) {
-                        return true;
-                    }
-                }
-                if (show_error) {
-                    const message = __('Forbidden: you do not have the necessary role in order to do that.');
-                    this.model.createMessage({message, 'type': 'error'});
-                }
-                return false;
-            },
+    onCommandError (err) {
+        log.fatal(err);
+        const message =
+            __("Sorry, an error happened while running the command.") + " " +
+            __("Check your browser's developer console for details.");
+        this.model.createMessage({message, 'type': 'error'});
+    },
 
-            verifyAffiliations (affiliations, occupant, show_error=true) {
-                if (!Array.isArray(affiliations)) {
-                    throw new TypeError('affiliations must be an Array');
-                }
-                if (!affiliations.length) {
-                    return true;
-                }
-                occupant = occupant || this.model.occupants.findWhere({'jid': _converse.bare_jid});
-                if (occupant) {
-                    const a = occupant.get('affiliation');
-                    if (affiliations.includes(a)) {
-                        return true;
-                    }
-                }
-                if (show_error) {
-                    const message = __('Forbidden: you do not have the necessary affiliation in order to do that.');
-                    this.model.createMessage({message, 'type': 'error'});
-                }
-                return false;
-            },
+    getAllowedCommands () {
+        let allowed_commands = ['clear', 'help', 'me', 'nick', 'register'];
+        if (this.model.config.get('changesubject') || ['owner', 'admin'].includes(this.model.getOwnAffiliation())) {
+            allowed_commands = [...allowed_commands, ...['subject', 'topic']];
+        }
+        const occupant = this.model.occupants.findWhere({'jid': _converse.bare_jid});
+        if (this.verifyAffiliations(['owner'], occupant, false)) {
+            allowed_commands = allowed_commands.concat(OWNER_COMMANDS).concat(ADMIN_COMMANDS);
+        } else if (this.verifyAffiliations(['admin'], occupant, false)) {
+            allowed_commands = allowed_commands.concat(ADMIN_COMMANDS);
+        }
+        if (this.verifyRoles(['moderator'], occupant, false)) {
+            allowed_commands = allowed_commands.concat(MODERATOR_COMMANDS).concat(VISITOR_COMMANDS);
+        } else if (!this.verifyRoles(['visitor', 'participant', 'moderator'], occupant, false)) {
+            allowed_commands = allowed_commands.concat(VISITOR_COMMANDS);
+        }
+        allowed_commands.sort();
 
-            validateRoleOrAffiliationChangeArgs (command, args) {
-                if (!args) {
-                    const message = __(
-                        'Error: the "%1$s" command takes two arguments, the user\'s nickname and optionally a reason.',
-                        command
-                    );
-                    this.model.createMessage({message, 'type': 'error'});
-                    return false;
-                }
-                return true;
-            },
+        if (Array.isArray(api.settings.get('muc_disable_slash_commands'))) {
+            return allowed_commands.filter(c => !api.settings.get('muc_disable_slash_commands').includes(c));
+        } else {
+            return allowed_commands;
+        }
+    },
 
-            getNickOrJIDFromCommandArgs (args) {
-                if (u.isValidJID(args.trim())) {
-                    return args.trim();
-                }
-                if (!args.startsWith('@')) {
-                    args = '@'+ args;
-                }
-                const [text, references] = this.model.parseTextForReferences(args); // eslint-disable-line no-unused-vars
-                if (!references.length) {
-                    const message = __("Error: couldn't find a groupchat participant based on your arguments");
-                    this.model.createMessage({message, 'type': 'error'});
-                    return;
-                }
-                if (references.length > 1) {
-                    const message = __("Error: found multiple groupchat participant based on your arguments");
-                    this.model.createMessage({message, 'type': 'error'});
-                    return;
-                }
-                const nick_or_jid = references.pop().value;
-                const reason = args.split(nick_or_jid, 2)[1];
-                if (reason && !reason.startsWith(' ')) {
-                    const message = __("Error: couldn't find a groupchat participant based on your arguments");
-                    this.model.createMessage({message, 'type': 'error'});
-                    return;
-                }
-                return nick_or_jid;
-            },
+    async destroy () {
+        const messages = [__('Are you sure you want to destroy this groupchat?')];
+        let fields = [{
+            'name': 'challenge',
+            'label': __('Please enter the XMPP address of this groupchat to confirm'),
+            'challenge': this.model.get('jid'),
+            'placeholder': __('name@example.org'),
+            'required': true
+        }, {
+            'name': 'reason',
+            'label': __('Optional reason for destroying this groupchat'),
+            'placeholder': __('Reason')
+        }, {
+            'name': 'newjid',
+            'label': __('Optional XMPP address for a new groupchat that replaces this one'),
+            'placeholder': __('replacement@example.org')
+        }];
+        try {
+            fields = await api.confirm(__('Confirm'), messages, fields);
+            const reason = fields.filter(f => f.name === 'reason').pop()?.value;
+            const newjid = fields.filter(f => f.name === 'newjid').pop()?.value;
+            return this.model.sendDestroyIQ(reason, newjid).then(() => this.close())
+        } catch (e) {
+            log.error(e);
+        }
+    },
 
-            setAffiliation (command, args, required_affiliations) {
-                const affiliation = COMMAND_TO_AFFILIATION[command];
-                if (!affiliation) {
-                    throw Error(`ChatRoomView#setAffiliation called with invalid command: ${command}`);
-                }
-                if (!this.verifyAffiliations(required_affiliations)) {
-                    return false;
-                }
-                if (!this.validateRoleOrAffiliationChangeArgs(command, args)) {
-                    return false;
-                }
-                const nick_or_jid = this.getNickOrJIDFromCommandArgs(args);
-                if (!nick_or_jid) {
-                    return false;
+    parseMessageForCommands (text) {
+        if (api.settings.get('muc_disable_slash_commands') &&
+                !Array.isArray(api.settings.get('muc_disable_slash_commands'))) {
+            return _converse.ChatBoxView.prototype.parseMessageForCommands.apply(this, arguments);
+        }
+        text = text.replace(/^\s*/, "");
+        const command = (text.match(/^\/([a-zA-Z]*) ?/) || ['']).pop().toLowerCase();
+        if (!command) {
+            return false;
+        }
+        const args = text.slice(('/'+command).length+1).trim();
+        if (!this.getAllowedCommands().includes(command)) {
+            return false;
+        }
+
+        switch (command) {
+            case 'admin': {
+                this.setAffiliation(command, args, ['owner']);
+                break;
+            }
+            case 'ban': {
+                this.setAffiliation(command, args, ['admin', 'owner']);
+                break;
+            }
+            case 'modtools': {
+                this.showModeratorToolsModal(args);
+                break;
+            }
+            case 'deop': {
+                // FIXME: /deop only applies to setting a moderators
+                // role to "participant" (which only admin/owner can
+                // do). Moderators can however set non-moderator's role
+                // to participant (e.g. visitor => participant).
+                // Currently we don't distinguish between these two
+                // cases.
+                this.setRole(command, args, ['admin', 'owner']);
+                break;
+            }
+            case 'destroy': {
+                if (!this.verifyAffiliations(['owner'])) {
+                    break;
                 }
+                this.destroy().catch(e => this.onCommandError(e));
+                break;
+            }
+            case 'help': {
+                this.model.set({'show_help_messages': true});
+                break;
+            } case 'kick': {
+                this.setRole(command, args, [], ['moderator']);
+                break;
+            }
+            case 'mute': {
+                this.setRole(command, args, [], ['moderator']);
+                break;
+            }
+            case 'member': {
+                this.setAffiliation(command, args, ['admin', 'owner']);
+                break;
+            }
+            case 'nick': {
+                if (!this.verifyRoles(['visitor', 'participant', 'moderator'])) {
+                    break;
+                } else if (args.length === 0) {
+                    // e.g. Your nickname is "coolguy69"
+                    const message = __('Your nickname is "%1$s"', this.model.get('nick'));
+                    this.model.createMessage({message, 'type': 'error'});
 
-                let jid;
-                const reason = args.split(nick_or_jid, 2)[1].trim();
-                const occupant = this.model.getOccupant(nick_or_jid);
-                if (occupant) {
-                    jid = occupant.get('jid');
                 } else {
-                    if (u.isValidJID(nick_or_jid)) {
-                        jid = nick_or_jid;
-                    } else {
-                        const message = __(
-                            "Couldn't find a participant with that nickname. "+
-                            "They might have left the groupchat."
-                        );
-                        this.model.createMessage({message, 'type': 'error'});
-                        return;
-                    }
-                }
-                const attrs = { jid, reason };
-                if (occupant && api.settings.get('auto_register_muc_nickname')) {
-                    attrs['nick'] = occupant.get('nick');
+                    const jid = Strophe.getBareJidFromJid(this.model.get('jid'));
+                    api.send($pres({
+                        from: _converse.connection.jid,
+                        to: `${jid}/${args}`,
+                        id: u.getUniqueId()
+                    }).tree());
+                }
+                break;
+            }
+            case 'owner':
+                this.setAffiliation(command, args, ['owner']);
+                break;
+            case 'op': {
+                this.setRole(command, args, ['admin', 'owner']);
+                break;
+            }
+            case 'register': {
+                if (args.length > 1) {
+                    this.model.createMessage({
+                        'message': __('Error: invalid number of arguments'),
+                        'type': 'error'
+                    });
+                } else {
+                    this.model.registerNickname().then(err_msg => {
+                        err_msg && this.model.createMessage({'message': err_msg, 'type': 'error'});
+                    });
                 }
-                this.model.setAffiliation(affiliation, [attrs])
-                    .then(() => this.model.occupants.fetchMembers())
-                    .catch(err => this.onCommandError(err));
-            },
+                break;
+            }
+            case 'revoke': {
+                this.setAffiliation(command, args, ['admin', 'owner']);
+                break;
+            }
+            case 'topic':
+            case 'subject':
+                this.model.setSubject(args);
+                break;
+            case 'voice': {
+                this.setRole(command, args, [], ['moderator']);
+                break;
+            }
+            default:
+                return _converse.ChatBoxView.prototype.parseMessageForCommands.apply(this, arguments);
+        }
+        return true;
+    },
 
-            getReason (args) {
-                return args.includes(',') ? args.slice(args.indexOf(',')+1).trim() : null;
-            },
+    /**
+     * Renders a form given an IQ stanza containing the current
+     * groupchat configuration.
+     * Returns a promise which resolves once the user has
+     * either submitted the form, or canceled it.
+     * @private
+     * @method _converse.ChatRoomView#renderConfigurationForm
+     * @param { XMLElement } stanza: The IQ stanza containing the groupchat config.
+     */
+    renderConfigurationForm (stanza) {
+        this.hideChatRoomContents();
+        this.model.save('config_stanza', stanza.outerHTML);
+        if (!this.config_form) {
+            const { _converse } = this.__super__;
+            this.config_form = new _converse.MUCConfigForm({
+                'model': this.model,
+                'chatroomview': this
+            });
+            const container_el = this.el.querySelector('.chatroom-body');
+            container_el.insertAdjacentElement('beforeend', this.config_form.el);
+        }
+        u.showElement(this.config_form.el);
+    },
 
-            setRole (command, args, required_affiliations=[], required_roles=[]) {
-                /* Check that a command to change a groupchat user's role or
-                 * affiliation has anough arguments.
-                 */
-                const role = COMMAND_TO_ROLE[command];
-                if (!role) {
-                    throw Error(`ChatRoomView#setRole called with invalid command: ${command}`);
-                }
-                if (!this.verifyAffiliations(required_affiliations) || !this.verifyRoles(required_roles)) {
-                    return false;
-                }
-                if (!this.validateRoleOrAffiliationChangeArgs(command, args)) {
-                    return false;
-                }
-                const nick_or_jid = this.getNickOrJIDFromCommandArgs(args);
-                if (!nick_or_jid) {
-                    return false;
-                }
-                const reason = args.split(nick_or_jid, 2)[1].trim();
-                // We're guaranteed to have an occupant due to getNickOrJIDFromCommandArgs
-                const occupant = this.model.getOccupant(nick_or_jid);
-                this.model.setRole(occupant, role, reason, undefined, this.onCommandError.bind(this));
-                return true;
-            },
+    /**
+     * Renders a form which allows the user to choose theirnickname.
+     * @private
+     * @method _converse.ChatRoomView#renderNicknameForm
+     */
+    renderNicknameForm () {
+        const heading = api.settings.get('muc_show_logs_before_join') ?
+            __('Choose a nickname to enter') :
+            __('Please choose your nickname');
+
+        const html = tpl_chatroom_nickname_form(Object.assign({
+            heading,
+            'label_nickname': __('Nickname'),
+            'label_join': __('Enter groupchat'),
+        }, this.model.toJSON()));
+
+        if (api.settings.get('muc_show_logs_before_join')) {
+            const container = this.el.querySelector('.muc-bottom-panel');
+            container.innerHTML = html;
+            u.addClass('muc-bottom-panel--nickname', container);
+        } else {
+            const form = this.el.querySelector('.muc-nickname-form');
+            if (form) {
+                sizzle('.spinner', this.el).forEach(u.removeElement);
+                form.outerHTML = html;
+            } else {
+                this.hideChatRoomContents();
+                const container = this.el.querySelector('.chatroom-body');
+                container.insertAdjacentHTML('beforeend', html);
+            }
+        }
+        u.safeSave(this.model.session, {'connection_status': converse.ROOMSTATUS.NICKNAME_REQUIRED});
+    },
 
-            onCommandError (err) {
-                log.fatal(err);
-                const message =
-                    __("Sorry, an error happened while running the command.") + " " +
-                    __("Check your browser's developer console for details.");
-                this.model.createMessage({message, 'type': 'error'});
-            },
+    /**
+     * Remove the configuration form without submitting and return to the chat view.
+     * @private
+     * @method _converse.ChatRoomView#closeForm
+     */
+    closeForm () {
+        sizzle('.chatroom-form-container', this.el).forEach(e => u.addClass('hidden', e));
+        this.renderAfterTransition();
+    },
 
-            getAllowedCommands () {
-                let allowed_commands = ['clear', 'help', 'me', 'nick', 'register'];
-                if (this.model.config.get('changesubject') || ['owner', 'admin'].includes(this.model.getOwnAffiliation())) {
-                    allowed_commands = [...allowed_commands, ...['subject', 'topic']];
-                }
-                const occupant = this.model.occupants.findWhere({'jid': _converse.bare_jid});
-                if (this.verifyAffiliations(['owner'], occupant, false)) {
-                    allowed_commands = allowed_commands.concat(OWNER_COMMANDS).concat(ADMIN_COMMANDS);
-                } else if (this.verifyAffiliations(['admin'], occupant, false)) {
-                    allowed_commands = allowed_commands.concat(ADMIN_COMMANDS);
-                }
-                if (this.verifyRoles(['moderator'], occupant, false)) {
-                    allowed_commands = allowed_commands.concat(MODERATOR_COMMANDS).concat(VISITOR_COMMANDS);
-                } else if (!this.verifyRoles(['visitor', 'participant', 'moderator'], occupant, false)) {
-                    allowed_commands = allowed_commands.concat(VISITOR_COMMANDS);
-                }
-                allowed_commands.sort();
+    /**
+     * Start the process of configuring a groupchat, either by
+     * rendering a configuration form, or by auto-configuring
+     * based on the "roomconfig" data stored on the
+     * {@link _converse.ChatRoom}.
+     * Stores the new configuration on the {@link _converse.ChatRoom}
+     * once completed.
+     * @private
+     * @method _converse.ChatRoomView#getAndRenderConfigurationForm
+     * @param { Event } ev - DOM event that might be passed in if this
+     *   method is called due to a user action. In this
+     *   case, auto-configure won't happen, regardless of
+     *   the settings.
+     */
+    getAndRenderConfigurationForm () {
+        if (!this.config_form || !u.isVisible(this.config_form.el)) {
+            this.showSpinner();
+            this.model.fetchRoomConfiguration()
+                .then(iq => this.renderConfigurationForm(iq))
+                .catch(e => log.error(e));
+        } else {
+            this.closeForm();
+        }
+    },
 
-                if (Array.isArray(api.settings.get('muc_disable_slash_commands'))) {
-                    return allowed_commands.filter(c => !api.settings.get('muc_disable_slash_commands').includes(c));
-                } else {
-                    return allowed_commands;
-                }
-            },
+    hideChatRoomContents () {
+        const container_el = this.el.querySelector('.chatroom-body');
+        if (container_el !== null) {
+            [].forEach.call(container_el.children, child => child.classList.add('hidden'));
+        }
+    },
 
-            async destroy () {
-                const messages = [__('Are you sure you want to destroy this groupchat?')];
-                let fields = [{
-                    'name': 'challenge',
-                    'label': __('Please enter the XMPP address of this groupchat to confirm'),
-                    'challenge': this.model.get('jid'),
-                    'placeholder': __('name@example.org'),
-                    'required': true
-                }, {
-                    'name': 'reason',
-                    'label': __('Optional reason for destroying this groupchat'),
-                    'placeholder': __('Reason')
-                }, {
-                    'name': 'newjid',
-                    'label': __('Optional XMPP address for a new groupchat that replaces this one'),
-                    'placeholder': __('replacement@example.org')
-                }];
-                try {
-                    fields = await api.confirm(__('Confirm'), messages, fields);
-                    const reason = fields.filter(f => f.name === 'reason').pop()?.value;
-                    const newjid = fields.filter(f => f.name === 'newjid').pop()?.value;
-                    return this.model.sendDestroyIQ(reason, newjid).then(() => this.close())
-                } catch (e) {
-                    log.error(e);
-                }
-            },
+    renderPasswordForm () {
+        this.hideChatRoomContents();
+        const message = this.model.get('password_validation_message');
+        this.model.save('password_validation_message', undefined);
+
+        if (!this.password_form) {
+            this.password_form = new _converse.MUCPasswordForm({
+                'model': new Model({
+                    'validation_message': message
+                }),
+                'chatroomview': this,
+            });
+            const container_el = this.el.querySelector('.chatroom-body');
+            container_el.insertAdjacentElement('beforeend', this.password_form.el);
+        } else {
+            this.password_form.model.set('validation_message', message);
+        }
+        u.showElement(this.password_form.el);
+        this.model.session.save('connection_status', converse.ROOMSTATUS.PASSWORD_REQUIRED);
+    },
 
-            parseMessageForCommands (text) {
-                if (api.settings.get('muc_disable_slash_commands') &&
-                        !Array.isArray(api.settings.get('muc_disable_slash_commands'))) {
-                    return _converse.ChatBoxView.prototype.parseMessageForCommands.apply(this, arguments);
-                }
-                text = text.replace(/^\s*/, "");
-                const command = (text.match(/^\/([a-zA-Z]*) ?/) || ['']).pop().toLowerCase();
-                if (!command) {
-                    return false;
-                }
-                const args = text.slice(('/'+command).length+1).trim();
-                if (!this.getAllowedCommands().includes(command)) {
-                    return false;
-                }
+    showDestroyedMessage () {
+        u.hideElement(this.el.querySelector('.chat-area'));
+        u.hideElement(this.el.querySelector('.occupants'));
+        sizzle('.spinner', this.el).forEach(u.removeElement);
 
-                switch (command) {
-                    case 'admin': {
-                        this.setAffiliation(command, args, ['owner']);
-                        break;
-                    }
-                    case 'ban': {
-                        this.setAffiliation(command, args, ['admin', 'owner']);
-                        break;
-                    }
-                    case 'modtools': {
-                        this.showModeratorToolsModal(args);
-                        break;
-                    }
-                    case 'deop': {
-                        // FIXME: /deop only applies to setting a moderators
-                        // role to "participant" (which only admin/owner can
-                        // do). Moderators can however set non-moderator's role
-                        // to participant (e.g. visitor => participant).
-                        // Currently we don't distinguish between these two
-                        // cases.
-                        this.setRole(command, args, ['admin', 'owner']);
-                        break;
-                    }
-                    case 'destroy': {
-                        if (!this.verifyAffiliations(['owner'])) {
-                            break;
-                        }
-                        this.destroy().catch(e => this.onCommandError(e));
-                        break;
-                    }
-                    case 'help': {
-                        this.model.set({'show_help_messages': true});
-                        break;
-                    } case 'kick': {
-                        this.setRole(command, args, [], ['moderator']);
-                        break;
-                    }
-                    case 'mute': {
-                        this.setRole(command, args, [], ['moderator']);
-                        break;
-                    }
-                    case 'member': {
-                        this.setAffiliation(command, args, ['admin', 'owner']);
-                        break;
-                    }
-                    case 'nick': {
-                        if (!this.verifyRoles(['visitor', 'participant', 'moderator'])) {
-                            break;
-                        } else if (args.length === 0) {
-                            // e.g. Your nickname is "coolguy69"
-                            const message = __('Your nickname is "%1$s"', this.model.get('nick'));
-                            this.model.createMessage({message, 'type': 'error'});
+        const reason = this.model.get('destroyed_reason');
+        const moved_jid = this.model.get('moved_jid');
+        this.model.save({
+            'destroyed_reason': undefined,
+            'moved_jid': undefined
+        });
+        const container = this.el.querySelector('.disconnect-container');
+        container.innerHTML = tpl_chatroom_destroyed({
+            '__':__,
+            'jid': moved_jid,
+            'reason': reason ? `"${reason}"` : null
+        });
+        const switch_el = container.querySelector('a.switch-chat');
+        if (switch_el) {
+            switch_el.addEventListener('click', async ev => {
+                ev.preventDefault();
+                const room = await api.rooms.get(moved_jid, null, true);
+                room.maybeShow(true);
+                this.model.destroy();
+            });
+        }
+        u.showElement(container);
+    },
 
-                        } else {
-                            const jid = Strophe.getBareJidFromJid(this.model.get('jid'));
-                            api.send($pres({
-                                from: _converse.connection.jid,
-                                to: `${jid}/${args}`,
-                                id: u.getUniqueId()
-                            }).tree());
-                        }
-                        break;
-                    }
-                    case 'owner':
-                        this.setAffiliation(command, args, ['owner']);
-                        break;
-                    case 'op': {
-                        this.setRole(command, args, ['admin', 'owner']);
-                        break;
-                    }
-                    case 'register': {
-                        if (args.length > 1) {
-                            this.model.createMessage({
-                                'message': __('Error: invalid number of arguments'),
-                                'type': 'error'
-                            });
-                        } else {
-                            this.model.registerNickname().then(err_msg => {
-                                err_msg && this.model.createMessage({'message': err_msg, 'type': 'error'});
-                            });
-                        }
-                        break;
-                    }
-                    case 'revoke': {
-                        this.setAffiliation(command, args, ['admin', 'owner']);
-                        break;
-                    }
-                    case 'topic':
-                    case 'subject':
-                        this.model.setSubject(args);
-                        break;
-                    case 'voice': {
-                        this.setRole(command, args, [], ['moderator']);
-                        break;
-                    }
-                    default:
-                        return _converse.ChatBoxView.prototype.parseMessageForCommands.apply(this, arguments);
-                }
-                return true;
-            },
+    showDisconnectMessage () {
+        const message = this.model.get('disconnection_message');
+        if (!message) {
+            return;
+        }
+        u.hideElement(this.el.querySelector('.chat-area'));
+        u.hideElement(this.el.querySelector('.occupants'));
+        sizzle('.spinner', this.el).forEach(u.removeElement);
+
+        const messages = [message];
+        const actor = this.model.get('disconnection_actor');
+        if (actor) {
+            messages.push(__('This action was done by %1$s.', actor));
+        }
+        const reason = this.model.get('disconnection_reason');
+        if (reason) {
+            messages.push(__('The reason given is: "%1$s".', reason));
+        }
+        this.model.save({
+            'disconnection_message': undefined,
+            'disconnection_reason': undefined,
+            'disconnection_actor': undefined
+        });
+        const container = this.el.querySelector('.disconnect-container');
+        container.innerHTML = tpl_chatroom_disconnect({messages})
+        u.showElement(container);
+    },
 
-            /**
-             * Renders a form given an IQ stanza containing the current
-             * groupchat configuration.
-             * Returns a promise which resolves once the user has
-             * either submitted the form, or canceled it.
-             * @private
-             * @method _converse.ChatRoomView#renderConfigurationForm
-             * @param { XMLElement } stanza: The IQ stanza containing the groupchat config.
-             */
-            renderConfigurationForm (stanza) {
-                this.hideChatRoomContents();
-                this.model.save('config_stanza', stanza.outerHTML);
-                if (!this.config_form) {
-                    const { _converse } = this.__super__;
-                    this.config_form = new _converse.MUCConfigForm({
-                        'model': this.model,
-                        'chatroomview': this
-                    });
-                    const container_el = this.el.querySelector('.chatroom-body');
-                    container_el.insertAdjacentElement('beforeend', this.config_form.el);
-                }
-                u.showElement(this.config_form.el);
-            },
+    onOccupantAdded (occupant) {
+        if (occupant.get('jid') === _converse.bare_jid) {
+            this.renderHeading();
+            this.renderBottomPanel();
+        }
+    },
 
-            /**
-             * Renders a form which allows the user to choose theirnickname.
-             * @private
-             * @method _converse.ChatRoomView#renderNicknameForm
-             */
-            renderNicknameForm () {
-                const heading = api.settings.get('muc_show_logs_before_join') ?
-                    __('Choose a nickname to enter') :
-                    __('Please choose your nickname');
-
-                const html = tpl_chatroom_nickname_form(Object.assign({
-                    heading,
-                    'label_nickname': __('Nickname'),
-                    'label_join': __('Enter groupchat'),
-                }, this.model.toJSON()));
-
-                if (api.settings.get('muc_show_logs_before_join')) {
-                    const container = this.el.querySelector('.muc-bottom-panel');
-                    container.innerHTML = html;
-                    u.addClass('muc-bottom-panel--nickname', container);
-                } else {
-                    const form = this.el.querySelector('.muc-nickname-form');
-                    if (form) {
-                        sizzle('.spinner', this.el).forEach(u.removeElement);
-                        form.outerHTML = html;
-                    } else {
-                        this.hideChatRoomContents();
-                        const container = this.el.querySelector('.chatroom-body');
-                        container.insertAdjacentHTML('beforeend', html);
-                    }
-                }
-                u.safeSave(this.model.session, {'connection_status': converse.ROOMSTATUS.NICKNAME_REQUIRED});
-            },
+    /**
+     * Working backwards, get today's most recent join/leave notification
+     * from the same user (if any exists) after the most recent chat message.
+     * @private
+     * @method _converse.ChatRoomView#getPreviousJoinOrLeaveNotification
+     * @param {HTMLElement} el
+     * @param {string} nick
+     */
+    getPreviousJoinOrLeaveNotification (el, nick) {
+        const today = (new Date()).toISOString().split('T')[0];
+        while (el !== null) {
+            if (!el.classList.contains('chat-info')) {
+                return;
+            }
+            // Check whether el is still from today.
+            // We don't use `Dayjs.same` here, since it's about 4 times slower.
+            const date = el.getAttribute('data-isodate');
+            if (date && date.split('T')[0] !== today) {
+                return;
+            }
+            const data = el?.dataset || {};
+            if (data.join === nick ||
+                    data.leave === nick ||
+                    data.leavejoin === nick ||
+                    data.joinleave === nick) {
+                return el;
+            }
+            el = el.previousElementSibling;
+        }
+    },
 
-            /**
-             * Remove the configuration form without submitting and return to the chat view.
-             * @private
-             * @method _converse.ChatRoomView#closeForm
-             */
-            closeForm () {
-                sizzle('.chatroom-form-container', this.el).forEach(e => u.addClass('hidden', e));
-                this.renderAfterTransition();
-            },
+    /**
+     * Rerender the groupchat after some kind of transition. For
+     * example after the spinner has been removed or after a
+     * form has been submitted and removed.
+     * @private
+     * @method _converse.ChatRoomView#renderAfterTransition
+     */
+    renderAfterTransition () {
+        const conn_status = this.model.session.get('connection_status')
+        if (conn_status == converse.ROOMSTATUS.NICKNAME_REQUIRED) {
+            this.renderNicknameForm();
+        } else if (conn_status == converse.ROOMSTATUS.PASSWORD_REQUIRED) {
+            this.renderPasswordForm();
+        } else if (conn_status == converse.ROOMSTATUS.ENTERED) {
+            this.hideChatRoomContents();
+            u.showElement(this.el.querySelector('.chat-area'));
+            u.showElement(this.el.querySelector('.occupants'));
+            this.scrollDown();
+        }
+    },
 
-            /**
-             * Start the process of configuring a groupchat, either by
-             * rendering a configuration form, or by auto-configuring
-             * based on the "roomconfig" data stored on the
-             * {@link _converse.ChatRoom}.
-             * Stores the new configuration on the {@link _converse.ChatRoom}
-             * once completed.
-             * @private
-             * @method _converse.ChatRoomView#getAndRenderConfigurationForm
-             * @param { Event } ev - DOM event that might be passed in if this
-             *   method is called due to a user action. In this
-             *   case, auto-configure won't happen, regardless of
-             *   the settings.
-             */
-            getAndRenderConfigurationForm () {
-                if (!this.config_form || !u.isVisible(this.config_form.el)) {
-                    this.showSpinner();
-                    this.model.fetchRoomConfiguration()
-                        .then(iq => this.renderConfigurationForm(iq))
-                        .catch(e => log.error(e));
-                } else {
-                    this.closeForm();
-                }
-            },
+    showSpinner () {
+        sizzle('.spinner', this.el).forEach(u.removeElement);
+        this.hideChatRoomContents();
+        const container_el = this.el.querySelector('.chatroom-body');
+        container_el.insertAdjacentHTML('afterbegin', tpl_spinner());
+    },
 
-            hideChatRoomContents () {
-                const container_el = this.el.querySelector('.chatroom-body');
-                if (container_el !== null) {
-                    [].forEach.call(container_el.children, child => child.classList.add('hidden'));
-                }
-            },
+    /**
+     * Check if the spinner is being shown and if so, hide it.
+     * Also make sure then that the chat area and occupants
+     * list are both visible.
+     * @private
+     * @method _converse.ChatRoomView#hideSpinner
+     */
+    hideSpinner () {
+        const spinner = this.el.querySelector('.spinner');
+        if (spinner !== null) {
+            u.removeElement(spinner);
+            this.renderAfterTransition();
+        }
+        return this;
+    }
+});
 
-            renderPasswordForm () {
-                this.hideChatRoomContents();
-                const message = this.model.get('password_validation_message');
-                this.model.save('password_validation_message', undefined);
-
-                if (!this.password_form) {
-                    this.password_form = new _converse.MUCPasswordForm({
-                        'model': new Model({
-                            'validation_message': message
-                        }),
-                        'chatroomview': this,
-                    });
-                    const container_el = this.el.querySelector('.chatroom-body');
-                    container_el.insertAdjacentElement('beforeend', this.password_form.el);
-                } else {
-                    this.password_form.model.set('validation_message', message);
-                }
-                u.showElement(this.password_form.el);
-                this.model.session.save('connection_status', converse.ROOMSTATUS.PASSWORD_REQUIRED);
-            },
 
-            showDestroyedMessage () {
-                u.hideElement(this.el.querySelector('.chat-area'));
-                u.hideElement(this.el.querySelector('.occupants'));
-                sizzle('.spinner', this.el).forEach(u.removeElement);
+/**
+ * View which renders MUC section of the control box.
+ * @class
+ * @namespace _converse.RoomsPanel
+ * @memberOf _converse
+ */
+export const RoomsPanel = View.extend({
+    tagName: 'div',
+    className: 'controlbox-section',
+    id: 'chatrooms',
+    events: {
+        'click a.controlbox-heading__btn.show-add-muc-modal': 'showAddRoomModal',
+        'click a.controlbox-heading__btn.show-list-muc-modal': 'showMUCListModal'
+    },
 
-                const reason = this.model.get('destroyed_reason');
-                const moved_jid = this.model.get('moved_jid');
-                this.model.save({
-                    'destroyed_reason': undefined,
-                    'moved_jid': undefined
-                });
-                const container = this.el.querySelector('.disconnect-container');
-                container.innerHTML = tpl_chatroom_destroyed({
-                    '__':__,
-                    'jid': moved_jid,
-                    'reason': reason ? `"${reason}"` : null
-                });
-                const switch_el = container.querySelector('a.switch-chat');
-                if (switch_el) {
-                    switch_el.addEventListener('click', async ev => {
-                        ev.preventDefault();
-                        const room = await api.rooms.get(moved_jid, null, true);
-                        room.maybeShow(true);
-                        this.model.destroy();
-                    });
-                }
-                u.showElement(container);
-            },
+    render () {
+        this.el.innerHTML = tpl_room_panel({
+            'heading_chatrooms': __('Groupchats'),
+            'title_new_room': __('Add a new groupchat'),
+            'title_list_rooms': __('Query for groupchats')
+        });
+        return this;
+    },
 
-            showDisconnectMessage () {
-                const message = this.model.get('disconnection_message');
-                if (!message) {
-                    return;
-                }
-                u.hideElement(this.el.querySelector('.chat-area'));
-                u.hideElement(this.el.querySelector('.occupants'));
-                sizzle('.spinner', this.el).forEach(u.removeElement);
+    showAddRoomModal (ev) {
+        if (this.add_room_modal === undefined) {
+            this.add_room_modal = new AddMUCModal({'model': this.model});
+        }
+        this.add_room_modal.show(ev);
+    },
 
-                const messages = [message];
-                const actor = this.model.get('disconnection_actor');
-                if (actor) {
-                    messages.push(__('This action was done by %1$s.', actor));
-                }
-                const reason = this.model.get('disconnection_reason');
-                if (reason) {
-                    messages.push(__('The reason given is: "%1$s".', reason));
-                }
-                this.model.save({
-                    'disconnection_message': undefined,
-                    'disconnection_reason': undefined,
-                    'disconnection_actor': undefined
-                });
-                const container = this.el.querySelector('.disconnect-container');
-                container.innerHTML = tpl_chatroom_disconnect({messages})
-                u.showElement(container);
-            },
+    showMUCListModal(ev) {
+        if (this.muc_list_modal === undefined) {
+            this.muc_list_modal = new MUCListModal({'model': this.model});
+        }
+        this.muc_list_modal.show(ev);
+    }
+});
 
-            onOccupantAdded (occupant) {
-                if (occupant.get('jid') === _converse.bare_jid) {
-                    this.renderHeading();
-                    this.renderBottomPanel();
-                }
-            },
 
-            /**
-             * Working backwards, get today's most recent join/leave notification
-             * from the same user (if any exists) after the most recent chat message.
-             * @private
-             * @method _converse.ChatRoomView#getPreviousJoinOrLeaveNotification
-             * @param {HTMLElement} el
-             * @param {string} nick
-             */
-            getPreviousJoinOrLeaveNotification (el, nick) {
-                const today = (new Date()).toISOString().split('T')[0];
-                while (el !== null) {
-                    if (!el.classList.contains('chat-info')) {
-                        return;
-                    }
-                    // Check whether el is still from today.
-                    // We don't use `Dayjs.same` here, since it's about 4 times slower.
-                    const date = el.getAttribute('data-isodate');
-                    if (date && date.split('T')[0] !== today) {
-                        return;
-                    }
-                    const data = el?.dataset || {};
-                    if (data.join === nick ||
-                            data.leave === nick ||
-                            data.leavejoin === nick ||
-                            data.joinleave === nick) {
-                        return el;
-                    }
-                    el = el.previousElementSibling;
-                }
-            },
+converse.plugins.add('converse-muc-views', {
+    /* Dependencies are other plugins which might be
+     * overridden or relied upon, and therefore need to be loaded before
+     * this plugin. They are "optional" because they might not be
+     * available, in which case any overrides applicable to them will be
+     * ignored.
+     *
+     * NB: These plugins need to have already been loaded via require.js.
+     *
+     * It's possible to make these dependencies "non-optional".
+     * If the setting "strict_plugin_dependencies" is set to true,
+     * an error will be raised if the plugin is not found.
+     */
+    dependencies: ["converse-autocomplete", "converse-modal", "converse-controlbox", "converse-chatview"],
 
-            /**
-             * Rerender the groupchat after some kind of transition. For
-             * example after the spinner has been removed or after a
-             * form has been submitted and removed.
-             * @private
-             * @method _converse.ChatRoomView#renderAfterTransition
-             */
-            renderAfterTransition () {
-                const conn_status = this.model.session.get('connection_status')
-                if (conn_status == converse.ROOMSTATUS.NICKNAME_REQUIRED) {
-                    this.renderNicknameForm();
-                } else if (conn_status == converse.ROOMSTATUS.PASSWORD_REQUIRED) {
-                    this.renderPasswordForm();
-                } else if (conn_status == converse.ROOMSTATUS.ENTERED) {
-                    this.hideChatRoomContents();
-                    u.showElement(this.el.querySelector('.chat-area'));
-                    u.showElement(this.el.querySelector('.occupants'));
-                    this.scrollDown();
+    overrides: {
+        ControlBoxView: {
+            renderControlBoxPane () {
+                this.__super__.renderControlBoxPane.apply(this, arguments);
+                if (api.settings.get('allow_muc')) {
+                    this.renderRoomsPanel();
                 }
-            },
+            }
+        }
+    },
 
-            showSpinner () {
-                sizzle('.spinner', this.el).forEach(u.removeElement);
-                this.hideChatRoomContents();
-                const container_el = this.el.querySelector('.chatroom-body');
-                container_el.insertAdjacentHTML('afterbegin', tpl_spinner());
-            },
+    initialize () {
+        const { _converse } = this;
 
-            /**
-             * Check if the spinner is being shown and if so, hide it.
-             * Also make sure then that the chat area and occupants
-             * list are both visible.
-             * @private
-             * @method _converse.ChatRoomView#hideSpinner
-             */
-            hideSpinner () {
-                const spinner = this.el.querySelector('.spinner');
-                if (spinner !== null) {
-                    u.removeElement(spinner);
-                    this.renderAfterTransition();
-                }
-                return this;
+        api.promises.add(['roomsPanelRendered']);
+
+        // Configuration values for this plugin
+        // ====================================
+        // Refer to docs/source/configuration.rst for explanations of these
+        // configuration settings.
+        api.settings.extend({
+            'auto_list_rooms': false,
+            'cache_muc_messages': true,
+            'locked_muc_nickname': false,
+            'modtools_disable_query': [],
+            'modtools_disable_assign': false,
+            'muc_disable_slash_commands': false,
+            'muc_mention_autocomplete_filter': 'contains',
+            'muc_mention_autocomplete_min_chars': 0,
+            'muc_mention_autocomplete_show_avatar': true,
+            'muc_roomid_policy': null,
+            'muc_roomid_policy_hint': null,
+            'roomconfig_whitelist': [],
+            'show_retraction_warning': true,
+            'visible_toolbar_buttons': {
+                'toggle_occupants': true
             }
         });
 
 
-        /**
-         * View which renders MUC section of the control box.
-         * @class
-         * @namespace _converse.RoomsPanel
-         * @memberOf _converse
-         */
-        _converse.RoomsPanel = View.extend({
-            tagName: 'div',
-            className: 'controlbox-section',
-            id: 'chatrooms',
-            events: {
-                'click a.controlbox-heading__btn.show-add-muc-modal': 'showAddRoomModal',
-                'click a.controlbox-heading__btn.show-list-muc-modal': 'showMUCListModal'
-            },
+        _converse.ChatRoomView = ChatRoomView;
+        _converse.RoomsPanel = RoomsPanel;
 
-            render () {
-                this.el.innerHTML = tpl_room_panel({
-                    'heading_chatrooms': __('Groupchats'),
-                    'title_new_room': __('Add a new groupchat'),
-                    'title_list_rooms': __('Query for groupchats')
-                });
-                return this;
-            },
 
-            showAddRoomModal (ev) {
-                if (this.add_room_modal === undefined) {
-                    this.add_room_modal = new AddMUCModal({'model': this.model});
+        const viewWithRoomsPanel = {
+            renderRoomsPanel () {
+                if (this.roomspanel && u.isInDOM(this.roomspanel.el)) {
+                    return this.roomspanel;
                 }
-                this.add_room_modal.show(ev);
+                const id = `converse.roomspanel${_converse.bare_jid}`;
+
+                this.roomspanel = new _converse.RoomsPanel({
+                    'model': new (_converse.RoomsPanelModel.extend({
+                        id,
+                        'browserStorage': _converse.createStore(id)
+                    }))()
+                });
+                this.roomspanel.model.fetch();
+                this.el.querySelector('.controlbox-pane').insertAdjacentElement(
+                    'beforeEnd', this.roomspanel.render().el);
+
+                /**
+                 * Triggered once the section of the { @link _converse.ControlBoxView }
+                 * which shows gropuchats has been rendered.
+                 * @event _converse#roomsPanelRendered
+                 * @example _converse.api.listen.on('roomsPanelRendered', () => { ... });
+                 */
+                api.trigger('roomsPanelRendered');
+                return this.roomspanel;
             },
 
-            showMUCListModal(ev) {
-                if (this.muc_list_modal === undefined) {
-                    this.muc_list_modal = new MUCListModal({'model': this.model});
+            getRoomsPanel () {
+                if (this.roomspanel && u.isInDOM(this.roomspanel.el)) {
+                    return this.roomspanel;
+                } else {
+                    return this.renderRoomsPanel();
                 }
-                this.muc_list_modal.show(ev);
             }
-        });
+        }
+
+        if (_converse.ControlBoxView) {
+            Object.assign(_converse.ControlBoxView.prototype, viewWithRoomsPanel);
+        }
 
 
         _converse.MUCConfigForm = View.extend({

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно