浏览代码

Move converse-muc-views plugin into own folder

JC Brand 4 年之前
父节点
当前提交
e8536ebc88

+ 1 - 1
src/converse.js

@@ -22,7 +22,7 @@ import "./plugins/dragresize.js";           // Allows chat boxes to be resized b
 import "./plugins/fullscreen.js";
 import "./plugins/mam-views.js";
 import "./plugins/minimize.js";             // Allows chat boxes to be minimized
-import "./plugins/muc-views.js";            // Views related to MUC
+import "./plugins/muc-views/index.js";            // Views related to MUC
 import "./plugins/headlines-view.js";
 import "./plugins/notifications.js";
 import "./plugins/omemo.js";

+ 69 - 0
src/plugins/muc-views/api.js

@@ -0,0 +1,69 @@
+import { _converse, api } from "@converse/headless/core";
+
+export default {
+    /**
+     * The "roomviews" namespace groups methods relevant to chatroom
+     * (aka groupchats) views.
+     *
+     * @namespace _converse.api.roomviews
+     * @memberOf _converse.api
+     */
+    roomviews: {
+        /**
+         * Retrieves a groupchat (aka chatroom) view. The chat should already be open.
+         *
+         * @method _converse.api.roomviews.get
+         * @param {String|string[]} name - e.g. 'coven@conference.shakespeare.lit' or
+         *  ['coven@conference.shakespeare.lit', 'cave@conference.shakespeare.lit']
+         * @returns {View} View representing the groupchat
+         *
+         * @example
+         * // To return a single view, provide the JID of the groupchat
+         * const view = _converse.api.roomviews.get('coven@conference.shakespeare.lit');
+         *
+         * @example
+         * // To return an array of views, provide an array of JIDs:
+         * const views = _converse.api.roomviews.get(['coven@conference.shakespeare.lit', 'cave@conference.shakespeare.lit']);
+         *
+         * @example
+         * // To return views of all open groupchats, call the method without any parameters::
+         * const views = _converse.api.roomviews.get();
+         *
+         */
+        get (jids) {
+            if (Array.isArray(jids)) {
+                const views = api.chatviews.get(jids);
+                return views.filter(v => v.model.get('type') === _converse.CHATROOMS_TYPE)
+            } else {
+                const view = api.chatviews.get(jids);
+                if (view.model.get('type') === _converse.CHATROOMS_TYPE) {
+                    return view;
+                } else {
+                    return null;
+                }
+            }
+        },
+        /**
+         * Lets you close open chatrooms.
+         *
+         * You can call this method without any arguments to close
+         * all open chatrooms, or you can specify a single JID or
+         * an array of JIDs.
+         *
+         * @method _converse.api.roomviews.close
+         * @param {(String[]|String)} jids The JID or array of JIDs of the chatroom(s)
+         * @returns { Promise } - Promise which resolves once the views have been closed.
+         */
+        close (jids) {
+            let views;
+            if (jids === undefined) {
+                views = _converse.chatboxviews;
+            } else if (typeof jids === 'string') {
+                views = [_converse.chatboxviews.get(jids)].filter(v => v);
+            } else if (Array.isArray(jids)) {
+                views = jids.map(jid => _converse.chatboxviews.get(jid));
+            }
+            return Promise.all(views.map(v => (v.is_chatroom && v.model && v.close())))
+        }
+    }
+}

+ 65 - 0
src/plugins/muc-views/config-form.js

@@ -0,0 +1,65 @@
+import log from "@converse/headless/log";
+import tpl_muc_config_form from "templates/muc_config_form.js";
+import { View } from '@converse/skeletor/src/view.js';
+import { __ } from 'i18n';
+import { api, converse } from "@converse/headless/core";
+
+const { sizzle } = converse.env;
+const u = converse.env.utils;
+
+
+const MUCConfigForm = View.extend({
+    className: 'chatroom-form-container muc-config-form',
+
+    initialize (attrs) {
+        this.chatroomview = attrs.chatroomview;
+        this.listenTo(this.chatroomview.model.features, 'change:passwordprotected', this.render);
+        this.listenTo(this.chatroomview.model.features, 'change:config_stanza', this.render);
+        this.render();
+    },
+
+    toHTML () {
+        const stanza = u.toStanza(this.model.get('config_stanza'));
+        const whitelist = api.settings.get('roomconfig_whitelist');
+        let fields = sizzle('field', stanza);
+        if (whitelist.length) {
+            fields = fields.filter(f => whitelist.includes(f.getAttribute('var')));
+        }
+        const password_protected = this.model.features.get('passwordprotected');
+        const options = {
+            'new_password': !password_protected,
+            'fixed_username': this.model.get('jid')
+        };
+        return tpl_muc_config_form({
+            'closeConfigForm': ev => this.closeConfigForm(ev),
+            'fields': fields.map(f => u.xForm2webForm(f, stanza, options)),
+            'instructions': stanza.querySelector('instructions')?.textContent,
+            'submitConfigForm': ev => this.submitConfigForm(ev),
+            'title': stanza.querySelector('title')?.textContent
+        });
+    },
+
+    async submitConfigForm (ev) {
+        ev.preventDefault();
+        const inputs = sizzle(':input:not([type=button]):not([type=submit])', ev.target);
+        const config_array = inputs.map(u.webForm2xForm).filter(f => f);
+        try {
+            await this.model.sendConfiguration(config_array);
+        } catch (e) {
+            log.error(e);
+            const message =
+                __("Sorry, an error occurred while trying to submit the config form.") + " " +
+                __("Check your browser's developer console for details.");
+            api.alert('error', __('Error'), message);
+        }
+        await this.model.refreshDiscoInfo();
+        this.chatroomview.closeForm();
+    },
+
+    closeConfigForm (ev) {
+        ev.preventDefault();
+        this.chatroomview.closeForm();
+    }
+});
+
+export default MUCConfigForm

+ 163 - 0
src/plugins/muc-views/index.js

@@ -0,0 +1,163 @@
+/**
+ * @module converse-muc-views
+ * @copyright 2020, the Converse.js contributors
+ * @description XEP-0045 Multi-User Chat Views
+ * @license Mozilla Public License (MPLv2)
+ */
+import '../../components/muc-sidebar';
+import '../chatview/index.js';
+import '../modal.js';
+import '@converse/headless/utils/muc';
+import ChatRoomViewMixin from './muc.js';
+import MUCConfigForm from './config-form.js';
+import MUCPasswordForm from './password-form.js';
+import log from '@converse/headless/log';
+import muc_api from './api.js';
+import { RoomsPanel, RoomsPanelViewMixin } from './rooms-panel.js';
+import { api, converse, _converse } from '@converse/headless/core';
+
+const { Strophe } = converse.env;
+
+function setMUCDomain (domain, controlboxview) {
+    controlboxview.getRoomsPanel().model.save('muc_domain', Strophe.getDomainFromJid(domain));
+}
+
+function setMUCDomainFromDisco (controlboxview) {
+    /* Check whether service discovery for the user's domain
+     * returned MUC information and use that to automatically
+     * set the MUC domain in the "Add groupchat" modal.
+     */
+    function featureAdded (feature) {
+        if (!feature) {
+            return;
+        }
+        if (feature.get('var') === Strophe.NS.MUC) {
+            feature.entity.getIdentity('conference', 'text').then(identity => {
+                if (identity) {
+                    setMUCDomain(feature.get('from'), controlboxview);
+                }
+            });
+        }
+    }
+    api.waitUntil('discoInitialized')
+        .then(() => {
+            api.listen.on('serviceDiscovered', featureAdded);
+            // Features could have been added before the controlbox was
+            // initialized. We're only interested in MUC
+            _converse.disco_entities.each(entity => featureAdded(entity.features.findWhere({ 'var': Strophe.NS.MUC })));
+        })
+        .catch(e => log.error(e));
+}
+
+function fetchAndSetMUCDomain (controlboxview) {
+    if (controlboxview.model.get('connected')) {
+        if (!controlboxview.getRoomsPanel().model.get('muc_domain')) {
+            if (api.settings.get('muc_domain') === undefined) {
+                setMUCDomainFromDisco(controlboxview);
+            } else {
+                setMUCDomain(api.settings.get('muc_domain'), controlboxview);
+            }
+        }
+    }
+}
+
+function openChatRoomFromURIClicked (ev) {
+    ev.preventDefault();
+    api.rooms.open(ev.target.href);
+}
+
+async function addView (model) {
+    const views = _converse.chatboxviews;
+    if (!views.get(model.get('id')) && model.get('type') === _converse.CHATROOMS_TYPE && model.isValid()) {
+        await model.initialized;
+        return views.add(model.get('id'), new _converse.ChatRoomView({ model }));
+    }
+}
+
+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();
+                }
+            }
+        }
+    },
+
+    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
+            }
+        });
+
+        _converse.MUCConfigForm = MUCConfigForm;
+        _converse.MUCPasswordForm = MUCPasswordForm;
+        _converse.ChatRoomView = _converse.ChatBoxView.extend(ChatRoomViewMixin);
+        _converse.RoomsPanel = RoomsPanel;
+        _converse.ControlBoxView && Object.assign(_converse.ControlBoxView.prototype, RoomsPanelViewMixin);
+
+        Object.assign(_converse.api, muc_api);
+
+        /************************ BEGIN Event Handlers ************************/
+        api.listen.on('chatBoxViewsInitialized', () => {
+            _converse.chatboxviews.delegate('click', 'a.open-chatroom', openChatRoomFromURIClicked);
+            _converse.chatboxes.on('add', addView);
+        });
+
+        api.listen.on('clearSession', () => {
+            const view = _converse.chatboxviews.get('controlbox');
+            if (view && view.roomspanel) {
+                view.roomspanel.model.destroy();
+                view.roomspanel.remove();
+                delete view.roomspanel;
+            }
+        });
+
+        api.listen.on('controlBoxInitialized', view => {
+            if (!api.settings.get('allow_muc')) {
+                return;
+            }
+            fetchAndSetMUCDomain(view);
+            view.model.on('change:connected', () => fetchAndSetMUCDomain(view));
+        });
+        /************************ END Event Handlers ************************/
+    }
+});

+ 206 - 569
src/plugins/muc-views.js → src/plugins/muc-views/muc.js

@@ -1,36 +1,22 @@
-/**
- * @module converse-muc-views
- * @copyright 2020, the Converse.js contributors
- * @description XEP-0045 Multi-User Chat Views
- * @license Mozilla Public License (MPLv2)
- */
-import "../components/muc-sidebar";
-import "./chatview/index.js";
-import "./modal.js";
-import "@converse/headless/utils/muc";
-import AddMUCModal from '../modals/add-muc.js';
-import MUCInviteModal from '../modals/muc-invite.js';
-import MUCListModal from '../modals/muc-list.js';
-import ModeratorToolsModal from "../modals/moderator-tools.js";
-import OccupantModal from '../modals/occupant.js';
-import RoomDetailsModal from '../modals/muc-details.js';
-import log from "@converse/headless/log";
-import tpl_chatroom from "../templates/chatroom.js";
-import tpl_chatroom_head from "../templates/chatroom_head.js";
-import tpl_muc_bottom_panel from "../templates/muc_bottom_panel.js";
-import tpl_muc_config_form from "../templates/muc_config_form.js";
-import tpl_muc_destroyed from "../templates/muc_destroyed.js";
-import tpl_muc_disconnect from "../templates/muc_disconnect.js";
-import tpl_muc_nickname_form from "../templates/muc_nickname_form.js";
-import tpl_muc_password_form from "../templates/muc_password_form.js";
-import tpl_room_panel from "../templates/room_panel.js";
-import tpl_spinner from "../templates/spinner.js";
+import './config-form.js';
+import './password-form.js';
+import MUCInviteModal from 'modals/muc-invite.js';
+import ModeratorToolsModal from 'modals/moderator-tools.js';
+import OccupantModal from 'modals/occupant.js';
+import RoomDetailsModal from 'modals/muc-details.js';
+import log from '@converse/headless/log';
+import tpl_chatroom from 'templates/chatroom.js';
+import tpl_chatroom_head from 'templates/chatroom_head.js';
+import tpl_muc_bottom_panel from 'templates/muc_bottom_panel.js';
+import tpl_muc_destroyed from 'templates/muc_destroyed.js';
+import tpl_muc_disconnect from 'templates/muc_disconnect.js';
+import tpl_muc_nickname_form from 'templates/muc_nickname_form.js';
+import tpl_spinner from 'templates/spinner.js';
 import { Model } from '@converse/skeletor/src/model.js';
-import { View } from '@converse/skeletor/src/view.js';
-import { __ } from '../i18n';
-import { _converse, api, converse } from "@converse/headless/core";
-import { debounce } from "lodash-es";
-import { render } from "lit-html";
+import { __ } from 'i18n';
+import { _converse, api, converse } from '@converse/headless/core';
+import { debounce } from 'lodash-es';
+import { render } from 'lit-html';
 
 const { Strophe, sizzle, $pres } = converse.env;
 const u = converse.env.utils;
@@ -46,24 +32,22 @@ const COMMAND_TO_ROLE = {
     'mute': 'visitor',
     'op': 'moderator',
     'voice': 'participant'
-}
+};
 const COMMAND_TO_AFFILIATION = {
     'admin': 'admin',
     'ban': 'outcast',
     'member': 'member',
     'owner': 'owner',
     'revoke': 'none'
-}
-
+};
 
 /**
- * NativeView which renders a groupchat, based upon
- * { @link _converse.ChatBoxView } for normal one-on-one chat boxes.
- * @class
+ * Mixin which turns a ChatBoxView into a ChatRoomView
+ * @mixin
  * @namespace _converse.ChatRoomView
  * @memberOf _converse
  */
-export const ChatRoomView = _converse.ChatBoxView.extend({
+const ChatRoomViewMixin = {
     length: 300,
     tagName: 'div',
     className: 'chatbox chatroom hidden',
@@ -73,7 +57,9 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
         '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 .occupant-nick': function (ev) {
+            this.insertIntoTextArea(ev.target.textContent);
+        },
         'click .send-button': 'onFormSubmitted',
         'dragover .chat-textarea': 'onDragOver',
         'drop .chat-textarea': 'onDrop',
@@ -82,15 +68,19 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
         'keyup .chat-textarea': 'onKeyUp',
         'mousedown .dragresize-occupants-left': 'onStartResizeOccupants',
         'paste .chat-textarea': 'onPaste',
-        'submit .muc-nickname-form': 'submitNickname',
+        'submit .muc-nickname-form': 'submitNickname'
     },
 
     async initialize () {
         this.initDebounced();
 
-        this.listenTo(this.model, 'change', debounce(() => this.renderHeading(), 250));
+        this.listenTo(
+            this.model,
+            'change',
+            debounce(() => this.renderHeading(), 250)
+        );
         this.listenTo(this.model, 'change:composing_spoiler', this.renderMessageForm);
-        this.listenTo(this.model, 'change:hidden', m => m.get('hidden') ? this.hide() : this.show());
+        this.listenTo(this.model, 'change:hidden', m => (m.get('hidden') ? this.hide() : this.show()));
         this.listenTo(this.model, 'change:hidden_occupants', this.onSidebarToggle);
         this.listenTo(this.model, 'configurationNeeded', this.getAndRenderConfigurationForm);
         this.listenTo(this.model, 'destroy', this.hide);
@@ -101,8 +91,8 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
         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);
+        this.onMouseMove = this.onMouseMove.bind(this);
+        this.onMouseUp = this.onMouseUp.bind(this);
 
         await this.render();
 
@@ -142,16 +132,20 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
     async render () {
         const sidebar_hidden = !this.shouldShowSidebar();
         this.el.setAttribute('id', this.model.get('box_id'));
-        render(tpl_chatroom({
-            sidebar_hidden,
-            'model': this.model,
-            'occupants': this.model.occupants,
-            'show_sidebar': !this.model.get('hidden_occupants') &&
-                this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED,
-            '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);
+        render(
+            tpl_chatroom({
+                sidebar_hidden,
+                'model': this.model,
+                'occupants': this.model.occupants,
+                'show_sidebar':
+                    !this.model.get('hidden_occupants') &&
+                    this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED,
+                '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');
@@ -159,8 +153,10 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
         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) {
+        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
@@ -173,17 +169,19 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
     getNotifications () {
         const actors_per_state = this.model.notifications.toJSON();
 
-        const role_changes = api.settings.get('muc_show_info_messages')
+        const role_changes = api.settings
+            .get('muc_show_info_messages')
             .filter(role_change => converse.MUC_ROLE_CHANGES_LIST.includes(role_change));
 
-        const join_leave_events = api.settings.get('muc_show_info_messages')
+        const join_leave_events = api.settings
+            .get('muc_show_info_messages')
             .filter(join_leave_event => converse.MUC_TRAFFIC_STATES_LIST.includes(join_leave_event));
 
         const states = [...converse.CHAT_STATES, ...join_leave_events, ...role_changes];
 
         return states.reduce((result, state) => {
             const existing_actors = actors_per_state[state];
-            if (!(existing_actors?.length)) {
+            if (!existing_actors?.length) {
                 return result;
             }
             const actors = existing_actors.map(a => this.model.getOccupant(a)?.getDisplayName() || a);
@@ -199,18 +197,20 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
                 } 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`;
+                    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`;
+                    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`;
+                    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`;
+                    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`;
+                    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);
@@ -227,13 +227,13 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
                 } 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`;
+                    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`;
+                    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`;
+                    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}${__('%1$s have been muted', actors[0])}\n`;
                 }
             }
             return result;
@@ -241,7 +241,7 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
     },
 
     getHelpMessages () {
-        const setting = api.settings.get("muc_disable_slash_commands");
+        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")}`,
@@ -259,13 +259,14 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
             `<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>/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)));
+        ]
+            .filter(line => disabled_commands.every(c => !line.startsWith(c + '<', 9)))
+            .filter(line => this.getAllowedCommands().some(c => line.startsWith(c + '<', 9)));
     },
 
     /**
@@ -319,7 +320,7 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
             const sidebar_el = this.el.querySelector('converse-muc-sidebar');
             const element_position = sidebar_el.getBoundingClientRect();
             const occupants_width = this.calculateSidebarWidth(element_position, 0);
-            const attrs = {occupants_width};
+            const attrs = { occupants_width };
             _converse.connection.connected ? this.model.save(attrs) : this.model.set(attrs);
         }
     },
@@ -333,23 +334,23 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
             this.is_maximum = element_position.left > current_mouse_position;
         } else {
             const occupants_width = this.calculateSidebarWidth(element_position, delta);
-            sidebar_el.style.flex = "0 0 " + occupants_width + "px";
+            sidebar_el.style.flex = '0 0 ' + occupants_width + 'px';
         }
     },
 
-    calculateSidebarWidth(element_position, delta) {
+    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)) {
+        if (occupants_width < room_width * 0.2) {
             // set pixel to 20% width
-            occupants_width = (room_width * 0.20);
+            occupants_width = room_width * 0.2;
             this.is_minimum = true;
-        } else if (occupants_width > (room_width * 0.75)) {
+        } else if (occupants_width > room_width * 0.75) {
             // set pixel to 75% width
-            occupants_width = (room_width * 0.75);
+            occupants_width = room_width * 0.75;
             this.is_maximum = true;
-        } else if ((room_width - occupants_width) < 250) {
+        } 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;
@@ -361,39 +362,39 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
     },
 
     getAutoCompleteList () {
-        return this.model.getAllKnownNicknames().map(nick => ({'label': nick, 'value': `@${nick}`}));
+        return this.model.getAllKnownNicknames().map(nick => ({ 'label': nick, 'value': `@${nick}` }));
     },
 
-    getAutoCompleteListItem(text, input) {
+    getAutoCompleteListItem (text, input) {
         input = input.trim();
-        const element = document.createElement("li");
-        element.setAttribute("aria-selected", "false");
+        const element = document.createElement('li');
+        element.setAttribute('aria-selected', 'false');
 
         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;
+            const img = document.createElement('img');
+            let dataUri = 'data:' + _converse.DEFAULT_IMAGE_TYPE + ';base64,' + _converse.DEFAULT_IMAGE;
 
             if (_converse.vcards) {
-                const vcard = _converse.vcards.findWhere({'nickname': text});
-                if (vcard) dataUri = "data:" + vcard.get('image_type') + ";base64," + vcard.get('image');
+                const vcard = _converse.vcards.findWhere({ 'nickname': text });
+                if (vcard) dataUri = 'data:' + vcard.get('image_type') + ';base64,' + vcard.get('image');
             }
 
-            img.setAttribute("src", dataUri);
-            img.setAttribute("width", "22");
-            img.setAttribute("class", "avatar avatar-autocomplete");
+            img.setAttribute('src', dataUri);
+            img.setAttribute('width', '22');
+            img.setAttribute('class', 'avatar avatar-autocomplete');
             element.appendChild(img);
         }
 
-        const regex = new RegExp("(" + input + ")", "ig");
+        const regex = new RegExp('(' + input + ')', 'ig');
         const parts = input ? text.split(regex) : [text];
 
         parts.forEach(txt => {
             if (input && txt.match(regex)) {
-              const match = document.createElement("mark");
-              match.textContent = txt;
-              element.appendChild(match);
+                const match = document.createElement('mark');
+                match.textContent = txt;
+                element.appendChild(match);
             } else {
-              element.appendChild(document.createTextNode(txt));
+                element.appendChild(document.createTextNode(txt));
             }
         });
 
@@ -407,10 +408,11 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
             '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", "@"],
+            'filter':
+                api.settings.get('muc_mention_autocomplete_filter') == 'contains'
+                    ? _converse.FILTER_CONTAINS
+                    : _converse.FILTER_STARTSWITH,
+            'ac_triggers': ['Tab', '@'],
             'include_triggers': [],
             'item': this.getAutoCompleteListItem
         });
@@ -442,10 +444,11 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
     },
 
     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.");
+        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 (message.mayBeRetracted()) {
             const messages = [__('Are you sure you want to retract this message?')];
@@ -457,7 +460,7 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
             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]]
+                    messages = [messages[0], retraction_warning, messages[1]];
                 }
                 !!(await api.confirm(__('Confirm'), messages)) && this.retractOtherMessage(message);
             } else {
@@ -466,14 +469,10 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
                     __('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]]
+                    messages = [messages[0], retraction_warning, messages[1]];
                 }
-                const reason = await api.prompt(
-                    __('Message Retraction'),
-                    messages,
-                    __('Optional reason')
-                );
-                (reason !== false) && this.retractOtherMessage(message, reason);
+                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`);
@@ -510,20 +509,20 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
         if (modal) {
             modal.model.set('affiliation', affiliation);
         } else {
-            const model = new Model({'affiliation': affiliation});
-            modal = api.modal.create(ModeratorToolsModal, {model, _converse, 'chatroomview': this});
+            const model = new Model({ 'affiliation': affiliation });
+            modal = api.modal.create(ModeratorToolsModal, { model, _converse, 'chatroomview': this });
         }
         modal.show();
     },
 
     showRoomDetailsModal (ev) {
         ev.preventDefault();
-        api.modal.show(RoomDetailsModal, {'model': this.model}, ev);
+        api.modal.show(RoomDetailsModal, { 'model': this.model }, ev);
     },
 
     showOccupantDetailsModal (ev, message) {
         ev.preventDefault();
-        api.modal.show(OccupantModal, {'model': message.occupant}, ev);
+        api.modal.show(OccupantModal, { 'model': message.occupant }, ev);
     },
 
     showChatStateNotification (message) {
@@ -534,8 +533,10 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
     },
 
     shouldShowSidebar () {
-        return !this.model.get('hidden_occupants') &&
-            this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED;
+        return (
+            !this.model.get('hidden_occupants') &&
+            this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED
+        );
     },
 
     onSidebarToggle () {
@@ -598,9 +599,9 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
         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'),
+                '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',
@@ -608,7 +609,6 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
             });
         }
 
-
         const conn_status = this.model.session.get('connection_status');
         if (conn_status === converse.ROOMSTATUS.ENTERED) {
             const allowed_commands = this.getAllowedCommands();
@@ -634,7 +634,7 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
             }
         }
 
-        if (!api.settings.get("singleton")) {
+        if (!api.settings.get('singleton')) {
             buttons.push({
                 'i18n_text': __('Leave'),
                 'i18n_title': __('Leave and close this groupchat'),
@@ -645,7 +645,7 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
                     result && this.close(ev);
                 },
                 'a_class': 'close-chatbox-button',
-                'standalone': api.settings.get("view_mode") === 'overlayed',
+                'standalone': api.settings.get('view_mode') === 'overlayed',
                 'icon_class': 'fa-sign-out-alt',
                 'name': 'signout'
             });
@@ -669,8 +669,9 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
                 subject_hidden,
                 'dropdown_btns': dropdown_btns.map(b => this.getHeadingDropdownItem(b)),
                 'standalone_btns': standalone_btns.map(b => this.getHeadingStandaloneButton(b)),
-                'title': this.model.getDisplayName(),
-        }));
+                'title': this.model.getDisplayName()
+            })
+        );
     },
 
     toggleTopic () {
@@ -679,10 +680,9 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
 
     showInviteModal (ev) {
         ev.preventDefault();
-        api.modal.show(MUCInviteModal, {'model': new Model(), 'chatroomview': this}, ev);
+        api.modal.show(MUCInviteModal, { 'model': new Model(), 'chatroomview': this }, ev);
     },
 
-
     /**
      * Callback method that gets called after the chat has become visible.
      * @private
@@ -718,13 +718,11 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
     },
 
     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
-            }
-        );
+        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
+        });
     },
 
     /**
@@ -734,7 +732,7 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
      */
     async close () {
         this.hide();
-        if (_converse.router.history.getFragment() === "converse/room?jid="+this.model.get('jid')) {
+        if (_converse.router.history.getFragment() === 'converse/room?jid=' + this.model.get('jid')) {
             _converse.router.navigate('');
         }
         await this.model.leave();
@@ -751,18 +749,18 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
             ev.preventDefault();
             ev.stopPropagation();
         }
-        this.model.save({'hidden_occupants': true});
+        this.model.save({ 'hidden_occupants': true });
         this.scrollDown();
     },
 
-    verifyRoles (roles, occupant, show_error=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});
+        occupant = occupant || this.model.occupants.findWhere({ 'jid': _converse.bare_jid });
         if (occupant) {
             const role = occupant.get('role');
             if (roles.includes(role)) {
@@ -771,19 +769,19 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
         }
         if (show_error) {
             const message = __('Forbidden: you do not have the necessary role in order to do that.');
-            this.model.createMessage({message, 'type': 'error'});
+            this.model.createMessage({ message, 'type': 'error' });
         }
         return false;
     },
 
-    verifyAffiliations (affiliations, occupant, show_error=true) {
+    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});
+        occupant = occupant || this.model.occupants.findWhere({ 'jid': _converse.bare_jid });
         if (occupant) {
             const a = occupant.get('affiliation');
             if (affiliations.includes(a)) {
@@ -792,7 +790,7 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
         }
         if (show_error) {
             const message = __('Forbidden: you do not have the necessary affiliation in order to do that.');
-            this.model.createMessage({message, 'type': 'error'});
+            this.model.createMessage({ message, 'type': 'error' });
         }
         return false;
     },
@@ -803,7 +801,7 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
                 'Error: the "%1$s" command takes two arguments, the user\'s nickname and optionally a reason.',
                 command
             );
-            this.model.createMessage({message, 'type': 'error'});
+            this.model.createMessage({ message, 'type': 'error' });
             return false;
         }
         return true;
@@ -814,24 +812,24 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
             return args.trim();
         }
         if (!args.startsWith('@')) {
-            args = '@'+ args;
+            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'});
+            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'});
+            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'});
+            this.model.createMessage({ message, 'type': 'error' });
             return;
         }
         return nick_or_jid;
@@ -863,10 +861,9 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
                 jid = nick_or_jid;
             } else {
                 const message = __(
-                    "Couldn't find a participant with that nickname. "+
-                    "They might have left the groupchat."
+                    "Couldn't find a participant with that nickname. " + 'They might have left the groupchat.'
                 );
-                this.model.createMessage({message, 'type': 'error'});
+                this.model.createMessage({ message, 'type': 'error' });
                 return;
             }
         }
@@ -874,16 +871,17 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
         if (occupant && api.settings.get('auto_register_muc_nickname')) {
             attrs['nick'] = occupant.get('nick');
         }
-        this.model.setAffiliation(affiliation, [attrs])
+        this.model
+            .setAffiliation(affiliation, [attrs])
             .then(() => this.model.occupants.fetchMembers())
             .catch(err => this.onCommandError(err));
     },
 
     getReason (args) {
-        return args.includes(',') ? args.slice(args.indexOf(',')+1).trim() : null;
+        return args.includes(',') ? args.slice(args.indexOf(',') + 1).trim() : null;
     },
 
-    setRole (command, args, required_affiliations=[], required_roles=[]) {
+    setRole (command, args, required_affiliations = [], required_roles = []) {
         /* Check that a command to change a groupchat user's role or
          * affiliation has anough arguments.
          */
@@ -911,9 +909,10 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
     onCommandError (err) {
         log.fatal(err);
         const message =
-            __("Sorry, an error happened while running the command.") + " " +
+            __('Sorry, an error happened while running the command.') +
+            ' ' +
             __("Check your browser's developer console for details.");
-        this.model.createMessage({message, 'type': 'error'});
+        this.model.createMessage({ message, 'type': 'error' });
     },
 
     getAllowedCommands () {
@@ -921,7 +920,7 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
         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});
+        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)) {
@@ -943,42 +942,48 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
 
     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')
-        }];
+        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())
+            return this.model.sendDestroyIQ(reason, newjid).then(() => this.close());
         } catch (e) {
             log.error(e);
         }
     },
 
     parseMessageForCommands (text) {
-        if (api.settings.get('muc_disable_slash_commands') &&
-                !Array.isArray(api.settings.get('muc_disable_slash_commands'))) {
+        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*/, "");
+        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();
+        const args = text.slice(('/' + command).length + 1).trim();
         if (!this.getAllowedCommands().includes(command)) {
             return false;
         }
@@ -1014,9 +1019,10 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
                 break;
             }
             case 'help': {
-                this.model.set({'show_help_messages': true});
+                this.model.set({ 'show_help_messages': true });
                 break;
-            } case 'kick': {
+            }
+            case 'kick': {
                 this.setRole(command, args, [], ['moderator']);
                 break;
             }
@@ -1034,15 +1040,16 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
                 } 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'});
-
+                    this.model.createMessage({ message, 'type': 'error' });
                 } else {
                     const jid = Strophe.getBareJidFromJid(this.model.get('jid'));
-                    api.send($pres({
-                        from: _converse.connection.jid,
-                        to: `${jid}/${args}`,
-                        id: u.getUniqueId()
-                    }).tree());
+                    api.send(
+                        $pres({
+                            from: _converse.connection.jid,
+                            to: `${jid}/${args}`,
+                            id: u.getUniqueId()
+                        }).tree()
+                    );
                 }
                 break;
             }
@@ -1061,7 +1068,7 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
                     });
                 } else {
                     this.model.registerNickname().then(err_msg => {
-                        err_msg && this.model.createMessage({'message': err_msg, 'type': 'error'});
+                        err_msg && this.model.createMessage({ 'message': err_msg, 'type': 'error' });
                     });
                 }
                 break;
@@ -1130,7 +1137,7 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
                 container.insertAdjacentElement('beforeend', form_el);
             }
         }
-        u.safeSave(this.model.session, {'connection_status': converse.ROOMSTATUS.NICKNAME_REQUIRED});
+        u.safeSave(this.model.session, { 'connection_status': converse.ROOMSTATUS.NICKNAME_REQUIRED });
     },
 
     /**
@@ -1160,7 +1167,8 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
     getAndRenderConfigurationForm () {
         if (!this.config_form || !u.isVisible(this.config_form.el)) {
             this.showSpinner();
-            this.model.fetchRoomConfiguration()
+            this.model
+                .fetchRoomConfiguration()
                 .then(iq => this.renderConfigurationForm(iq))
                 .catch(e => log.error(e));
         } else {
@@ -1185,7 +1193,7 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
                 'model': new Model({
                     'validation_message': message
                 }),
-                'chatroomview': this,
+                'chatroomview': this
             });
             const container_el = this.el.querySelector('.chatroom-body');
             container_el.insertAdjacentElement('beforeend', this.password_form.el);
@@ -1265,7 +1273,7 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
      * @param {string} nick
      */
     getPreviousJoinOrLeaveNotification (el, nick) {
-        const today = (new Date()).toISOString().split('T')[0];
+        const today = new Date().toISOString().split('T')[0];
         while (el !== null) {
             if (!el.classList.contains('chat-info')) {
                 return;
@@ -1277,10 +1285,7 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
                 return;
             }
             const data = el?.dataset || {};
-            if (data.join === nick ||
-                    data.leave === nick ||
-                    data.leavejoin === nick ||
-                    data.joinleave === nick) {
+            if (data.join === nick || data.leave === nick || data.leavejoin === nick || data.joinleave === nick) {
                 return el;
             }
             el = el.previousElementSibling;
@@ -1295,7 +1300,7 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
      * @method _converse.ChatRoomView#renderAfterTransition
      */
     renderAfterTransition () {
-        const conn_status = this.model.session.get('connection_status')
+        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) {
@@ -1312,10 +1317,7 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
         sizzle('.spinner', this.el).forEach(u.removeElement);
         this.hideChatRoomContents();
         const container_el = this.el.querySelector('.chatroom-body');
-        container_el.insertAdjacentElement(
-            'afterbegin',
-            u.getElementFromTemplateResult(tpl_spinner())
-        );
+        container_el.insertAdjacentElement('afterbegin', u.getElementFromTemplateResult(tpl_spinner()));
     },
 
     /**
@@ -1333,371 +1335,6 @@ export const ChatRoomView = _converse.ChatBoxView.extend({
         }
         return this;
     }
-});
-
-
-/**
- * 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'
-    },
-
-    toHTML () {
-        return tpl_room_panel({
-            'heading_chatrooms': __('Groupchats'),
-            'title_new_room': __('Add a new groupchat'),
-            'title_list_rooms': __('Query for groupchats')
-        });
-    },
-
-    showAddRoomModal (ev) {
-        api.modal.show(AddMUCModal, {'model': this.model}, ev);
-    },
-
-    showMUCListModal(ev) {
-        api.modal.show(MUCListModal, {'model': this.model}, ev);
-    }
-});
-
-
-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();
-                }
-            }
-        }
-    },
-
-    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
-            }
-        });
-
-
-        _converse.ChatRoomView = ChatRoomView;
-        _converse.RoomsPanel = RoomsPanel;
-
-
-        const viewWithRoomsPanel = {
-            renderRoomsPanel () {
-                if (this.roomspanel && u.isInDOM(this.roomspanel.el)) {
-                    return this.roomspanel;
-                }
-                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;
-            },
-
-            getRoomsPanel () {
-                if (this.roomspanel && u.isInDOM(this.roomspanel.el)) {
-                    return this.roomspanel;
-                } else {
-                    return this.renderRoomsPanel();
-                }
-            }
-        }
+};
 
-        if (_converse.ControlBoxView) {
-            Object.assign(_converse.ControlBoxView.prototype, viewWithRoomsPanel);
-        }
-
-
-        _converse.MUCConfigForm = View.extend({
-            className: 'chatroom-form-container muc-config-form',
-
-            initialize (attrs) {
-                this.chatroomview = attrs.chatroomview;
-                this.listenTo(this.chatroomview.model.features, 'change:passwordprotected', this.render);
-                this.listenTo(this.chatroomview.model.features, 'change:config_stanza', this.render);
-                this.render();
-            },
-
-            toHTML () {
-                const stanza = u.toStanza(this.model.get('config_stanza'));
-                const whitelist = api.settings.get('roomconfig_whitelist');
-                let fields = sizzle('field', stanza);
-                if (whitelist.length) {
-                    fields = fields.filter(f => whitelist.includes(f.getAttribute('var')));
-                }
-                const password_protected = this.model.features.get('passwordprotected');
-                const options = {
-                    'new_password': !password_protected,
-                    'fixed_username': this.model.get('jid')
-                };
-                return tpl_muc_config_form({
-                    'closeConfigForm': ev => this.closeConfigForm(ev),
-                    'fields': fields.map(f => u.xForm2webForm(f, stanza, options)),
-                    'instructions': stanza.querySelector('instructions')?.textContent,
-                    'submitConfigForm': ev => this.submitConfigForm(ev),
-                    'title': stanza.querySelector('title')?.textContent
-                });
-            },
-
-            async submitConfigForm (ev) {
-                ev.preventDefault();
-                const inputs = sizzle(':input:not([type=button]):not([type=submit])', ev.target);
-                const config_array = inputs.map(u.webForm2xForm).filter(f => f);
-                try {
-                    await this.model.sendConfiguration(config_array);
-                } catch (e) {
-                    log.error(e);
-                    const message =
-                        __("Sorry, an error occurred while trying to submit the config form.") + " " +
-                        __("Check your browser's developer console for details.");
-                    api.alert('error', __('Error'), message);
-                }
-                await this.model.refreshDiscoInfo();
-                this.chatroomview.closeForm();
-            },
-
-            closeConfigForm (ev) {
-                ev.preventDefault();
-                this.chatroomview.closeForm();
-            }
-        });
-
-
-        _converse.MUCPasswordForm = View.extend({
-            className: 'chatroom-form-container muc-password-form',
-
-            initialize (attrs) {
-                this.chatroomview = attrs.chatroomview;
-                this.listenTo(this.model, 'change:validation_message', this.render);
-                this.render();
-            },
-
-            toHTML () {
-                return tpl_muc_password_form({
-                    'jid': this.model.get('jid'),
-                    'submitPassword': ev => this.submitPassword(ev),
-                    'validation_message':  this.model.get('validation_message')
-                });
-            },
-
-            submitPassword (ev) {
-                ev.preventDefault();
-                const password = this.el.querySelector('input[type=password]').value;
-                this.chatroomview.model.join(this.chatroomview.model.get('nick'), password);
-                this.model.set('validation_message', null);
-            }
-        });
-
-
-        function setMUCDomain (domain, controlboxview) {
-            controlboxview.getRoomsPanel().model.save('muc_domain', Strophe.getDomainFromJid(domain));
-        }
-
-        function setMUCDomainFromDisco (controlboxview) {
-            /* Check whether service discovery for the user's domain
-             * returned MUC information and use that to automatically
-             * set the MUC domain in the "Add groupchat" modal.
-             */
-            function featureAdded (feature) {
-                if (!feature) { return; }
-                if (feature.get('var') === Strophe.NS.MUC) {
-                    feature.entity.getIdentity('conference', 'text').then(identity => {
-                        if (identity) {
-                            setMUCDomain(feature.get('from'), controlboxview);
-                        }
-                    });
-                }
-            }
-            api.waitUntil('discoInitialized').then(() => {
-                api.listen.on('serviceDiscovered', featureAdded);
-                // Features could have been added before the controlbox was
-                // initialized. We're only interested in MUC
-                _converse.disco_entities.each(entity => featureAdded(entity.features.findWhere({'var': Strophe.NS.MUC })));
-            }).catch(e => log.error(e));
-        }
-
-        function fetchAndSetMUCDomain (controlboxview) {
-            if (controlboxview.model.get('connected')) {
-                if (!controlboxview.getRoomsPanel().model.get('muc_domain')) {
-                    if (api.settings.get('muc_domain') === undefined) {
-                        setMUCDomainFromDisco(controlboxview);
-                    } else {
-                        setMUCDomain(api.settings.get('muc_domain'), controlboxview);
-                    }
-                }
-            }
-        }
-
-
-        /************************ BEGIN Event Handlers ************************/
-        api.listen.on('chatBoxViewsInitialized', () => {
-
-            function openChatRoomFromURIClicked (ev) {
-                ev.preventDefault();
-                api.rooms.open(ev.target.href);
-            }
-            _converse.chatboxviews.delegate('click', 'a.open-chatroom', openChatRoomFromURIClicked);
-
-            async function addView (model) {
-                const views = _converse.chatboxviews;
-                if (!views.get(model.get('id')) &&
-                        model.get('type') === _converse.CHATROOMS_TYPE &&
-                        model.isValid()
-                ) {
-                    await model.initialized;
-                    return views.add(model.get('id'), new _converse.ChatRoomView({model}));
-                }
-            }
-            _converse.chatboxes.on('add', addView);
-        });
-
-        api.listen.on('clearSession', () => {
-            const view = _converse.chatboxviews.get('controlbox');
-            if (view && view.roomspanel) {
-                view.roomspanel.model.destroy();
-                view.roomspanel.remove();
-                delete view.roomspanel;
-            }
-        });
-
-        api.listen.on('controlBoxInitialized', (view) => {
-            if (!api.settings.get('allow_muc')) {
-                return;
-            }
-            fetchAndSetMUCDomain(view);
-            view.model.on('change:connected', () => fetchAndSetMUCDomain(view));
-        });
-        /************************ END Event Handlers ************************/
-
-
-        /************************ BEGIN API ************************/
-        Object.assign(_converse.api, {
-            /**
-             * The "roomviews" namespace groups methods relevant to chatroom
-             * (aka groupchats) views.
-             *
-             * @namespace _converse.api.roomviews
-             * @memberOf _converse.api
-             */
-            roomviews: {
-                /**
-                 * Retrieves a groupchat (aka chatroom) view. The chat should already be open.
-                 *
-                 * @method _converse.api.roomviews.get
-                 * @param {String|string[]} name - e.g. 'coven@conference.shakespeare.lit' or
-                 *  ['coven@conference.shakespeare.lit', 'cave@conference.shakespeare.lit']
-                 * @returns {View} View representing the groupchat
-                 *
-                 * @example
-                 * // To return a single view, provide the JID of the groupchat
-                 * const view = _converse.api.roomviews.get('coven@conference.shakespeare.lit');
-                 *
-                 * @example
-                 * // To return an array of views, provide an array of JIDs:
-                 * const views = _converse.api.roomviews.get(['coven@conference.shakespeare.lit', 'cave@conference.shakespeare.lit']);
-                 *
-                 * @example
-                 * // To return views of all open groupchats, call the method without any parameters::
-                 * const views = _converse.api.roomviews.get();
-                 *
-                 */
-                get (jids) {
-                    if (Array.isArray(jids)) {
-                        const views = api.chatviews.get(jids);
-                        return views.filter(v => v.model.get('type') === _converse.CHATROOMS_TYPE)
-                    } else {
-                        const view = api.chatviews.get(jids);
-                        if (view.model.get('type') === _converse.CHATROOMS_TYPE) {
-                            return view;
-                        } else {
-                            return null;
-                        }
-                    }
-                },
-                /**
-                 * Lets you close open chatrooms.
-                 *
-                 * You can call this method without any arguments to close
-                 * all open chatrooms, or you can specify a single JID or
-                 * an array of JIDs.
-                 *
-                 * @method _converse.api.roomviews.close
-                 * @param {(String[]|String)} jids The JID or array of JIDs of the chatroom(s)
-                 * @returns { Promise } - Promise which resolves once the views have been closed.
-                 */
-                close (jids) {
-                    let views;
-                    if (jids === undefined) {
-                        views = _converse.chatboxviews;
-                    } else if (typeof jids === 'string') {
-                        views = [_converse.chatboxviews.get(jids)].filter(v => v);
-                    } else if (Array.isArray(jids)) {
-                        views = jids.map(jid => _converse.chatboxviews.get(jid));
-                    }
-                    return Promise.all(views.map(v => (v.is_chatroom && v.model && v.close())))
-                }
-            }
-        });
-    }
-});
+export default ChatRoomViewMixin;

+ 30 - 0
src/plugins/muc-views/password-form.js

@@ -0,0 +1,30 @@
+import tpl_muc_password_form from "templates/muc_password_form.js";
+import { View } from '@converse/skeletor/src/view.js';
+
+
+const MUCPasswordForm = View.extend({
+    className: 'chatroom-form-container muc-password-form',
+
+    initialize (attrs) {
+        this.chatroomview = attrs.chatroomview;
+        this.listenTo(this.model, 'change:validation_message', this.render);
+        this.render();
+    },
+
+    toHTML () {
+        return tpl_muc_password_form({
+            'jid': this.model.get('jid'),
+            'submitPassword': ev => this.submitPassword(ev),
+            'validation_message':  this.model.get('validation_message')
+        });
+    },
+
+    submitPassword (ev) {
+        ev.preventDefault();
+        const password = this.el.querySelector('input[type=password]').value;
+        this.chatroomview.model.join(this.chatroomview.model.get('nick'), password);
+        this.model.set('validation_message', null);
+    }
+});
+
+export default MUCPasswordForm;

+ 79 - 0
src/plugins/muc-views/rooms-panel.js

@@ -0,0 +1,79 @@
+import AddMUCModal from 'modals/add-muc.js';
+import tpl_room_panel from 'templates/room_panel.js';
+import { View } from '@converse/skeletor/src/view.js';
+import MUCListModal from 'modals/muc-list.js';
+import { _converse, api, converse } from '@converse/headless/core';
+import { __ } from 'i18n';
+
+const u = converse.env.utils;
+
+/**
+ * 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'
+    },
+
+    toHTML () {
+        return tpl_room_panel({
+            'heading_chatrooms': __('Groupchats'),
+            'title_new_room': __('Add a new groupchat'),
+            'title_list_rooms': __('Query for groupchats')
+        });
+    },
+
+    showAddRoomModal (ev) {
+        api.modal.show(AddMUCModal, { 'model': this.model }, ev);
+    },
+
+    showMUCListModal (ev) {
+        api.modal.show(MUCListModal, { 'model': this.model }, ev);
+    }
+});
+
+/**
+ * Mixin which adds the ability to a ControlBox to render a list of open groupchats
+ * @mixin
+ */
+export const RoomsPanelViewMixin = {
+    renderRoomsPanel () {
+        if (this.roomspanel && u.isInDOM(this.roomspanel.el)) {
+            return this.roomspanel;
+        }
+        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;
+    },
+
+    getRoomsPanel () {
+        if (this.roomspanel && u.isInDOM(this.roomspanel.el)) {
+            return this.roomspanel;
+        } else {
+            return this.renderRoomsPanel();
+        }
+    }
+};