Browse Source

Move converse-muc into a folder

JC Brand 4 years ago
parent
commit
c0fafcec70

+ 2 - 2
src/headless/headless.js

@@ -7,12 +7,12 @@ import "./plugins/bookmarks.js";   // XEP-0199 XMPP Ping
 import "./plugins/bosh.js";        // XEP-0206 BOSH
 import "./plugins/caps.js";        // XEP-0115 Entity Capabilities
 import "./plugins/carbons.js";     // XEP-0280 Message Carbons
-import "./plugins/chat/index.js";        // RFC-6121 Instant messaging
+import "./plugins/chat/index.js";  // RFC-6121 Instant messaging
 import "./plugins/chatboxes.js";
 import "./plugins/disco.js";       // XEP-0030 Service discovery
 import "./plugins/headlines.js";   // Support for headline messages
 import "./plugins/mam.js";         // XEP-0313 Message Archive Management
-import "./plugins/muc.js";         // XEP-0045 Multi-user chat
+import "./plugins/muc/index.js";   // XEP-0045 Multi-user chat
 import "./plugins/ping.js";        // XEP-0199 XMPP Ping
 import "./plugins/pubsub.js";      // XEP-0060 Pubsub
 import "./plugins/roster.js";      // RFC-6121 Contacts Roster

+ 1 - 1
src/headless/plugins/bookmarks.js

@@ -5,7 +5,7 @@
  * @copyright 2020, the Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  */
-import "@converse/headless/plugins/muc";
+import "@converse/headless/plugins/muc/index.js";
 import log from "../log.js";
 import { Collection } from "@converse/skeletor/src/collection";
 import { Model } from '@converse/skeletor/src/model.js';

+ 0 - 2988
src/headless/plugins/muc.js

@@ -1,2988 +0,0 @@
-/**
- * @module converse-muc
- * @copyright The Converse.js contributors
- * @license Mozilla Public License (MPLv2)
- * @description Implements the non-view logic for XEP-0045 Multi-User Chat
- */
-import "./chat/index.js";
-import "./disco";
-import "./emoji/index.js";
-import { Collection } from "@converse/skeletor/src/collection";
-import { Model } from '@converse/skeletor/src/model.js';
-import { debounce, intersection, invoke, isElement, isObject, pick, zipObject } from "lodash-es";
-import { _converse, api, converse } from "../core.js";
-import log from "../log";
-import muc_utils from "../utils/muc";
-import st from "../utils/stanza";
-import u from "../utils/form";
-import p from "../utils/parse-helpers";
-
-export const ROLES = ['moderator', 'participant', 'visitor'];
-export const AFFILIATIONS = ['owner', 'admin', 'member', 'outcast', 'none'];
-
-converse.AFFILIATION_CHANGES = {
-    OWNER: 'owner', ADMIN: 'admin', MEMBER: 'member', EXADMIN: 'exadmin',
-    EXOWNER: 'exowner', EXOUTCAST: 'exoutcast', EXMEMBER: 'exmember'
-}
-converse.AFFILIATION_CHANGES_LIST = Object.values(converse.AFFILIATION_CHANGES);
-converse.MUC_TRAFFIC_STATES = { ENTERED: 'entered', EXITED: 'exited' };
-converse.MUC_TRAFFIC_STATES_LIST = Object.values(converse.MUC_TRAFFIC_STATES);
-converse.MUC_ROLE_CHANGES = {OP: 'op', DEOP: 'deop', VOICE: 'voice', MUTE: 'mute'}
-converse.MUC_ROLE_CHANGES_LIST = Object.values(converse.MUC_ROLE_CHANGES);
-
-const ACTION_INFO_CODES = ['301', '303', '333', '307', '321', '322'];
-
-converse.MUC_INFO_CODES = {
-    'visibility_changes': ['100', '102', '103', '172', '173', '174'],
-    'self': ['110'],
-    'non_privacy_changes': ['104', '201'],
-    'muc_logging_changes': ['170', '171'],
-    'nickname_changes': ['210', '303'],
-    'disconnect_messages': ['301', '307', '321', '322', '332', '333'],
-    'affiliation_changes': [...converse.AFFILIATION_CHANGES_LIST],
-    'join_leave_events': [...converse.MUC_TRAFFIC_STATES_LIST],
-    'role_changes': [...converse.MUC_ROLE_CHANGES_LIST],
-};
-
-const MUC_ROLE_WEIGHTS = {
-    'moderator':    1,
-    'participant':  2,
-    'visitor':      3,
-    'none':         2,
-};
-
-const { Strophe, $iq, $build, $msg, $pres, sizzle } = converse.env;
-
-// Add Strophe Namespaces
-Strophe.addNamespace('MUC_ADMIN', Strophe.NS.MUC + "#admin");
-Strophe.addNamespace('MUC_OWNER', Strophe.NS.MUC + "#owner");
-Strophe.addNamespace('MUC_REGISTER', "jabber:iq:register");
-Strophe.addNamespace('MUC_ROOMCONF', Strophe.NS.MUC + "#roomconfig");
-Strophe.addNamespace('MUC_USER', Strophe.NS.MUC + "#user");
-Strophe.addNamespace('MUC_HATS', "xmpp:prosody.im/protocol/hats:1");
-
-converse.MUC_NICK_CHANGED_CODE = "303";
-
-converse.ROOM_FEATURES = [
-    'passwordprotected', 'unsecured', 'hidden',
-    'publicroom', 'membersonly', 'open', 'persistent',
-    'temporary', 'nonanonymous', 'semianonymous',
-    'moderated', 'unmoderated', 'mam_enabled'
-];
-
-// No longer used in code, but useful as reference.
-//
-// const ROOM_FEATURES_MAP = {
-//     'passwordprotected': 'unsecured',
-//     'unsecured': 'passwordprotected',
-//     'hidden': 'publicroom',
-//     'publicroom': 'hidden',
-//     'membersonly': 'open',
-//     'open': 'membersonly',
-//     'persistent': 'temporary',
-//     'temporary': 'persistent',
-//     'nonanonymous': 'semianonymous',
-//     'semianonymous': 'nonanonymous',
-//     'moderated': 'unmoderated',
-//     'unmoderated': 'moderated'
-// };
-
-converse.ROOMSTATUS = {
-    CONNECTED: 0,
-    CONNECTING: 1,
-    NICKNAME_REQUIRED: 2,
-    PASSWORD_REQUIRED: 3,
-    DISCONNECTED: 4,
-    ENTERED: 5,
-    DESTROYED: 6
-};
-
-
-converse.plugins.add('converse-muc', {
-    /* Optional dependencies are other plugins which might be
-     * overridden or relied upon, and therefore need to be loaded before
-     * this plugin. They are called "optional" because they might not be
-     * available, in which case any overrides applicable to them will be
-     * ignored.
-     *
-     * It's possible however to make optional dependencies non-optional.
-     * If the setting "strict_plugin_dependencies" is set to true,
-     * an error will be raised if the plugin is not found.
-     *
-     * NB: These plugins need to have already been loaded via require.js.
-     */
-    dependencies: ["converse-chatboxes", "converse-chat", "converse-disco", "converse-controlbox"],
-
-    overrides: {
-        ChatBoxes: {
-            model (attrs, options) {
-                const { _converse } = this.__super__;
-                if (attrs && attrs.type == _converse.CHATROOMS_TYPE) {
-                    return new _converse.ChatRoom(attrs, options);
-                } else {
-                    return this.__super__.model.apply(this, arguments);
-                }
-            },
-        }
-    },
-
-    initialize () {
-        /* The initialize function gets called as soon as the plugin is
-         * loaded by converse.js's plugin machinery.
-         */
-        const { __, ___ } = _converse;
-
-        // Configuration values for this plugin
-        // ====================================
-        // Refer to docs/source/configuration.rst for explanations of these
-        // configuration settings.
-        api.settings.extend({
-            'allow_muc': true,
-            'allow_muc_invitations': true,
-            'auto_join_on_invite': false,
-            'auto_join_rooms': [],
-            'auto_register_muc_nickname': false,
-            'hide_muc_participants': false,
-            'locked_muc_domain': false,
-            'muc_domain': undefined,
-            'muc_fetch_members': true,
-            'muc_history_max_stanzas': undefined,
-            'muc_instant_rooms': true,
-            'muc_nickname_from_jid': false,
-            'muc_send_probes': false,
-            'muc_show_info_messages': [
-                ...converse.MUC_INFO_CODES.visibility_changes,
-                ...converse.MUC_INFO_CODES.self,
-                ...converse.MUC_INFO_CODES.non_privacy_changes,
-                ...converse.MUC_INFO_CODES.muc_logging_changes,
-                ...converse.MUC_INFO_CODES.nickname_changes,
-                ...converse.MUC_INFO_CODES.disconnect_messages,
-                ...converse.MUC_INFO_CODES.affiliation_changes,
-                ...converse.MUC_INFO_CODES.join_leave_events,
-                ...converse.MUC_INFO_CODES.role_changes,
-            ],
-            'muc_show_logs_before_join': false,
-        });
-        api.promises.add(['roomsAutoJoined']);
-
-        if (api.settings.get('locked_muc_domain') && (typeof api.settings.get('muc_domain') !== 'string')) {
-            throw new Error("Config Error: it makes no sense to set locked_muc_domain "+
-                            "to true when muc_domain is not set");
-        }
-
-
-        /* https://xmpp.org/extensions/xep-0045.html
-         * ----------------------------------------
-         * 100 message      Entering a groupchat         Inform user that any occupant is allowed to see the user's full JID
-         * 101 message (out of band)                     Affiliation change  Inform user that his or her affiliation changed while not in the groupchat
-         * 102 message      Configuration change         Inform occupants that groupchat now shows unavailable members
-         * 103 message      Configuration change         Inform occupants that groupchat now does not show unavailable members
-         * 104 message      Configuration change         Inform occupants that a non-privacy-related groupchat configuration change has occurred
-         * 110 presence     Any groupchat presence       Inform user that presence refers to one of its own groupchat occupants
-         * 170 message or initial presence               Configuration change    Inform occupants that groupchat logging is now enabled
-         * 171 message      Configuration change         Inform occupants that groupchat logging is now disabled
-         * 172 message      Configuration change         Inform occupants that the groupchat is now non-anonymous
-         * 173 message      Configuration change         Inform occupants that the groupchat is now semi-anonymous
-         * 174 message      Configuration change         Inform occupants that the groupchat is now fully-anonymous
-         * 201 presence     Entering a groupchat         Inform user that a new groupchat has been created
-         * 210 presence     Entering a groupchat         Inform user that the service has assigned or modified the occupant's roomnick
-         * 301 presence     Removal from groupchat       Inform user that he or she has been banned from the groupchat
-         * 303 presence     Exiting a groupchat          Inform all occupants of new groupchat nickname
-         * 307 presence     Removal from groupchat       Inform user that he or she has been kicked from the groupchat
-         * 321 presence     Removal from groupchat       Inform user that he or she is being removed from the groupchat because of an affiliation change
-         * 322 presence     Removal from groupchat       Inform user that he or she is being removed from the groupchat because the groupchat has been changed to members-only and the user is not a member
-         * 332 presence     Removal from groupchat       Inform user that he or she is being removed from the groupchat because of a system shutdown
-         */
-        _converse.muc = {
-            info_messages: {
-                100: __('This groupchat is not anonymous'),
-                102: __('This groupchat now shows unavailable members'),
-                103: __('This groupchat does not show unavailable members'),
-                104: __('The groupchat configuration has changed'),
-                170: __('Groupchat logging is now enabled'),
-                171: __('Groupchat logging is now disabled'),
-                172: __('This groupchat is now no longer anonymous'),
-                173: __('This groupchat is now semi-anonymous'),
-                174: __('This groupchat is now fully-anonymous'),
-                201: __('A new groupchat has been created')
-            },
-
-            new_nickname_messages: {
-                // XXX: Note the triple underscore function and not double underscore.
-                210: ___('Your nickname has been automatically set to %1$s'),
-                303: ___('Your nickname has been changed to %1$s')
-            },
-
-            disconnect_messages: {
-                301: __('You have been banned from this groupchat'),
-                333: __('You have exited this groupchat due to a technical problem'),
-                307: __('You have been kicked from this groupchat'),
-                321: __("You have been removed from this groupchat because of an affiliation change"),
-                322: __("You have been removed from this groupchat because the groupchat has changed to members-only and you're not a member"),
-                332: __("You have been removed from this groupchat because the service hosting it is being shut down")
-            },
-
-        }
-
-        /**
-         * Determines info message visibility based on
-         * muc_show_info_messages configuration setting
-         * @param {*} code
-         * @memberOf _converse
-         */
-        _converse.isInfoVisible = function (code) {
-            const info_messages = api.settings.get('muc_show_info_messages');
-            if (info_messages.includes(code)) {
-                return true;
-            }
-            return false;
-        }
-
-        async function openRoom (jid) {
-            if (!u.isValidMUCJID(jid)) {
-                return log.warn(`invalid jid "${jid}" provided in url fragment`);
-            }
-            await api.waitUntil('roomsAutoJoined');
-            if (api.settings.get('allow_bookmarks')) {
-                await api.waitUntil('bookmarksInitialized');
-            }
-            api.rooms.open(jid);
-        }
-        _converse.router.route('converse/room?jid=:jid', openRoom);
-
-
-        _converse.getDefaultMUCNickname = function () {
-            // XXX: if anything changes here, update the docs for the
-            // locked_muc_nickname setting.
-            if (!_converse.xmppstatus) {
-                throw new Error(
-                    "Can't call _converse.getDefaultMUCNickname before the statusInitialized has been fired.");
-            }
-            const nick = _converse.xmppstatus.getNickname();
-            if (nick) {
-                return nick;
-            } else if (api.settings.get('muc_nickname_from_jid')) {
-                return Strophe.unescapeNode(Strophe.getNodeFromJid(_converse.bare_jid));
-            }
-        }
-
-        async function openChatRoom (jid, settings) {
-            /* Opens a groupchat, making sure that certain attributes
-             * are correct, for example that the "type" is set to
-             * "chatroom".
-             */
-            settings.type = _converse.CHATROOMS_TYPE;
-            settings.id = jid;
-            const chatbox = await api.rooms.get(jid, settings, true);
-            chatbox.maybeShow(true);
-            return chatbox;
-        }
-
-
-        /**
-         * Represents a MUC message
-         * @class
-         * @namespace _converse.ChatRoomMessage
-         * @memberOf _converse
-         */
-        _converse.ChatRoomMessage = _converse.Message.extend({
-
-            initialize () {
-                if (!this.checkValidity()) { return; }
-                if (this.get('file')) {
-                    this.on('change:put', this.uploadFile, this);
-                }
-                if (!this.setTimerForEphemeralMessage()) {
-                    this.setOccupant();
-                }
-                /**
-                 * Triggered once a {@link _converse.ChatRoomMessageInitialized} has been created and initialized.
-                 * @event _converse#chatRoomMessageInitialized
-                 * @type { _converse.ChatRoomMessages}
-                 * @example _converse.api.listen.on('chatRoomMessageInitialized', model => { ... });
-                 */
-                api.trigger('chatRoomMessageInitialized', this);
-            },
-
-            /**
-             * Determines whether this messsage may be moderated,
-             * based on configuration settings and server support.
-             * @async
-             * @private
-             * @method _converse.ChatRoomMessages#mayBeModerated
-             * @returns { Boolean }
-             */
-            mayBeModerated () {
-                return ['all', 'moderator'].includes(api.settings.get('allow_message_retraction')) &&
-                    this.collection.chatbox.canModerateMessages();
-            },
-
-            checkValidity () {
-                const result = _converse.Message.prototype.checkValidity.call(this);
-                !result && this.collection.chatbox.debouncedRejoin();
-                return result;
-            },
-
-            onOccupantRemoved () {
-                this.stopListening(this.occupant);
-                delete this.occupant;
-                const chatbox = this?.collection?.chatbox;
-                if (!chatbox) {
-                    return log.error(`Could not get collection.chatbox for message: ${JSON.stringify(this.toJSON())}`);
-                }
-                this.listenTo(chatbox.occupants, 'add', this.onOccupantAdded);
-            },
-
-            onOccupantAdded (occupant) {
-                if (occupant.get('nick') === Strophe.getResourceFromJid(this.get('from'))) {
-                    this.occupant = occupant;
-                    this.trigger('occupantAdded');
-                    this.listenTo(this.occupant, 'destroy', this.onOccupantRemoved);
-                    const chatbox = this?.collection?.chatbox;
-                    if (!chatbox) {
-                        return log.error(`Could not get collection.chatbox for message: ${JSON.stringify(this.toJSON())}`);
-                    }
-                    this.stopListening(chatbox.occupants, 'add', this.onOccupantAdded);
-                }
-            },
-
-            setOccupant () {
-                if (this.get('type') !== 'groupchat') { return; }
-                const chatbox = this?.collection?.chatbox;
-                if (!chatbox) {
-                    return log.error(`Could not get collection.chatbox for message: ${JSON.stringify(this.toJSON())}`);
-                }
-                const nick = Strophe.getResourceFromJid(this.get('from'));
-                this.occupant = chatbox.occupants.findWhere({ nick });
-
-                if (!this.occupant && api.settings.get("muc_send_probes")) {
-                    this.occupant = chatbox.occupants.create({ nick, 'type': 'unavailable' });
-                    const jid = `${chatbox.get('jid')}/${nick}`;
-                    api.user.presence.send('probe', jid);
-                }
-
-                if (this.occupant) {
-                    this.listenTo(this.occupant, 'destroy', this.onOccupantRemoved);
-                } else {
-                    this.listenTo(chatbox.occupants, 'add', this.onOccupantAdded);
-                }
-            }
-        });
-
-
-        const MUCSession = Model.extend({
-            defaults () {
-                return {
-                    'connection_status': converse.ROOMSTATUS.DISCONNECTED
-                }
-            }
-        });
-
-
-        /**
-         * Collection which stores MUC messages
-         * @class
-         * @namespace _converse.ChatRoomMessages
-         * @memberOf _converse
-         */
-        _converse.ChatRoomMessages = Collection.extend({
-            model: _converse.ChatRoomMessage,
-            comparator: 'time'
-        });
-
-
-        /**
-         * Represents an open/ongoing groupchat conversation.
-         * @class
-         * @namespace _converse.ChatRoom
-         * @memberOf _converse
-         */
-        _converse.ChatRoom = _converse.ChatBox.extend({
-
-            defaults () {
-                return {
-                    // For group chats, we distinguish between generally unread
-                    // messages and those ones that specifically mention the
-                    // user.
-                    //
-                    // To keep things simple, we reuse `num_unread` from
-                    // _converse.ChatBox to indicate unread messages which
-                    // mention the user and `num_unread_general` to indicate
-                    // generally unread messages (which *includes* mentions!).
-                    'num_unread_general': 0,
-                    'bookmarked': false,
-                    'chat_state': undefined,
-                    'hidden': _converse.isUniView() && !api.settings.get('singleton'),
-                    'hidden_occupants': !!api.settings.get('hide_muc_participants'),
-                    'message_type': 'groupchat',
-                    'name': '',
-                    'num_unread': 0,
-                    'roomconfig': {},
-                    'time_opened': this.get('time_opened') || (new Date()).getTime(),
-                    'time_sent': (new Date(0)).toISOString(),
-                    'type': _converse.CHATROOMS_TYPE
-                }
-            },
-
-            async initialize () {
-                this.initialized = u.getResolveablePromise();
-                this.debouncedRejoin = debounce(this.rejoin, 250);
-                this.set('box_id', `box-${this.get('jid')}`);
-                this.initNotifications();
-                this.initMessages();
-                this.initOccupants();
-                this.initDiscoModels(); // sendChatState depends on this.features
-                this.registerHandlers();
-
-                this.on('change:chat_state', this.sendChatState, this);
-                await this.restoreSession();
-                this.session.on('change:connection_status', this.onConnectionStatusChanged, this);
-
-                this.listenTo(this.occupants, 'add', this.onOccupantAdded);
-                this.listenTo(this.occupants, 'remove', this.onOccupantRemoved);
-                this.listenTo(this.occupants, 'change:show', this.onOccupantShowChanged);
-                this.listenTo(this.occupants, 'change:affiliation', this.createAffiliationChangeMessage);
-                this.listenTo(this.occupants, 'change:role', this.createRoleChangeMessage);
-
-                const restored = await this.restoreFromCache()
-                if (!restored) {
-                    this.join();
-                }
-                /**
-                 * Triggered once a {@link _converse.ChatRoom} has been created and initialized.
-                 * @event _converse#chatRoomInitialized
-                 * @type { _converse.ChatRoom }
-                 * @example _converse.api.listen.on('chatRoomInitialized', model => { ... });
-                 */
-                await api.trigger('chatRoomInitialized', this, {'Synchronous': true});
-                this.initialized.resolve();
-            },
-
-            /**
-             * Checks whether we're still joined and if so, restores the MUC state from cache.
-             * @private
-             * @method _converse.ChatRoom#restoreFromCache
-             * @returns { Boolean } Returns `true` if we're still joined, otherwise returns `false`.
-             */
-            async restoreFromCache () {
-                if (this.session.get('connection_status') === converse.ROOMSTATUS.ENTERED && (await this.isJoined())) {
-                    // We've restored the room from cache and we're still joined.
-                    await new Promise(resolve => this.features.fetch({'success': resolve, 'error': resolve}));
-                    await this.fetchOccupants().catch(e => log.error(e));
-                    await this.fetchMessages().catch(e => log.error(e));
-                    return true;
-                } else {
-                    await this.clearCache();
-                    return false;
-                }
-            },
-
-            /**
-             * Join the MUC
-             * @private
-             * @method _converse.ChatRoom#join
-             * @param { String } nick - The user's nickname
-             * @param { String } [password] - Optional password, if required by the groupchat.
-             *  Will fall back to the `password` value stored in the room
-             *  model (if available).
-             */
-            async join (nick, password) {
-                if (this.session.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
-                    // We have restored a groupchat from session storage,
-                    // so we don't send out a presence stanza again.
-                    return this;
-                }
-                await this.refreshDiscoInfo();
-                nick = await this.getAndPersistNickname(nick);
-                if (!nick) {
-                    u.safeSave(this.session, {'connection_status': converse.ROOMSTATUS.NICKNAME_REQUIRED});
-                    if (api.settings.get('muc_show_logs_before_join')) {
-                        await this.fetchMessages();
-                    }
-                    return this;
-                }
-                const stanza = $pres({
-                    'from': _converse.connection.jid,
-                    'to': this.getRoomJIDAndNick()
-                }).c("x", {'xmlns': Strophe.NS.MUC})
-                  .c("history", {'maxstanzas': this.features.get('mam_enabled') ? 0 : api.settings.get('muc_history_max_stanzas')}).up();
-
-                password = password || this.get('password');
-                if (password) {
-                    stanza.cnode(Strophe.xmlElement("password", [], password));
-                }
-                this.session.save('connection_status', converse.ROOMSTATUS.CONNECTING);
-                api.send(stanza);
-                return this;
-            },
-
-            async clearCache () {
-                this.session.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
-                if (this.occupants.length) {
-                    // Remove non-members when reconnecting
-                    this.occupants.filter(o => !o.isMember()).forEach(o => o.destroy());
-                } else {
-                    // Looks like we haven't restored occupants from cache, so we clear it.
-                    this.occupants.clearStore();
-                }
-                if (api.settings.get('clear_messages_on_reconnection')) {
-                    await this.clearMessages();
-                }
-            },
-
-            onOccupantAdded (occupant) {
-                if (_converse.isInfoVisible(converse.MUC_TRAFFIC_STATES.ENTERED) &&
-                        this.session.get('connection_status') ===  converse.ROOMSTATUS.ENTERED &&
-                        occupant.get('show') === 'online') {
-                    this.updateNotifications(occupant.get('nick'), converse.MUC_TRAFFIC_STATES.ENTERED);
-                }
-            },
-
-            onOccupantRemoved (occupant) {
-                if (_converse.isInfoVisible(converse.MUC_TRAFFIC_STATES.EXITED) &&
-                        this.session.get('connection_status') ===  converse.ROOMSTATUS.ENTERED &&
-                        occupant.get('show') === 'online') {
-                    this.updateNotifications(occupant.get('nick'), converse.MUC_TRAFFIC_STATES.EXITED);
-                }
-            },
-
-            onOccupantShowChanged (occupant) {
-                if (occupant.get('states').includes('303')) {
-                    return;
-                }
-                if (occupant.get('show') === 'offline' &&
-                _converse.isInfoVisible(converse.MUC_TRAFFIC_STATES.EXITED)) {
-                    this.updateNotifications(occupant.get('nick'), converse.MUC_TRAFFIC_STATES.EXITED);
-                } else if (occupant.get('show') === 'online' &&
-                _converse.isInfoVisible(converse.MUC_TRAFFIC_STATES.ENTERED)) {
-                    this.updateNotifications(occupant.get('nick'), converse.MUC_TRAFFIC_STATES.ENTERED);
-                }
-            },
-
-            /**
-             * Clear stale cache and re-join a MUC we've been in before.
-             * @private
-             * @method _converse.ChatRoom#rejoin
-             */
-            rejoin () {
-                this.clearCache();
-                return this.join();
-            },
-
-            async onConnectionStatusChanged () {
-                if (this.session.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
-                    await this.occupants.fetchMembers();
-                    await this.fetchMessages();
-                    /**
-                     * Triggered when the user has entered a new MUC
-                     * @event _converse#enteredNewRoom
-                     * @type { _converse.ChatRoom}
-                     * @example _converse.api.listen.on('enteredNewRoom', model => { ... });
-                     */
-                    api.trigger('enteredNewRoom', this);
-
-                    if (api.settings.get('auto_register_muc_nickname') &&
-                            await api.disco.supports(Strophe.NS.MUC_REGISTER, this.get('jid'))) {
-                        this.registerNickname()
-                    }
-                }
-            },
-
-            async onReconnection () {
-                this.registerHandlers();
-                await this.rejoin();
-                this.announceReconnection();
-            },
-
-            getMessagesCollection () {
-                return new _converse.ChatRoomMessages();
-            },
-
-            restoreSession () {
-                const id = `muc.session-${_converse.bare_jid}-${this.get('jid')}`;
-                this.session = new MUCSession({id});
-                this.session.browserStorage = _converse.createStore(id, "session");
-                return new Promise(r => this.session.fetch({'success': r, 'error': r}));
-            },
-
-            initDiscoModels () {
-                let id = `converse.muc-features-${_converse.bare_jid}-${this.get('jid')}`;
-                this.features = new Model(
-                    Object.assign({id}, zipObject(converse.ROOM_FEATURES, converse.ROOM_FEATURES.map(() => false)))
-                );
-                this.features.browserStorage = _converse.createStore(id, "session");
-
-                id = `converse.muc-config-{_converse.bare_jid}-${this.get('jid')}`;
-                this.config = new Model();
-                this.config.browserStorage = _converse.createStore(id, "session");
-            },
-
-            initOccupants () {
-                this.occupants = new _converse.ChatRoomOccupants();
-                const id = `converse.occupants-${_converse.bare_jid}${this.get('jid')}`;
-                this.occupants.browserStorage = _converse.createStore(id, 'session');
-                this.occupants.chatroom = this;
-            },
-
-            fetchOccupants () {
-                this.occupants.fetched = new Promise(resolve => {
-                    this.occupants.fetch({
-                        'add': true,
-                        'silent': true,
-                        'success': resolve,
-                        'error': resolve
-                    });
-                });
-                return this.occupants.fetched;
-            },
-
-            handleAffiliationChangedMessage (stanza) {
-                const item = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] item`, stanza).pop();
-                if (item) {
-                    const from = stanza.getAttribute("from");
-                    const type = stanza.getAttribute("type");
-                    const affiliation = item.getAttribute('affiliation');
-                    const jid = item.getAttribute('jid');
-                    const data = {
-                        from, type, affiliation,
-                        'nick': Strophe.getNodeFromJid(jid),
-                        'states': [],
-                        'show': type == 'unavailable' ? 'offline' : 'online',
-                        'role': item.getAttribute('role'),
-                        'jid': Strophe.getBareJidFromJid(jid),
-                        'resource': Strophe.getResourceFromJid(jid)
-                    }
-                    const occupant = this.occupants.findOccupant({'jid': data.jid});
-                    if (occupant) {
-                        occupant.save(data);
-                    } else {
-                        this.occupants.create(data);
-                    }
-                }
-            },
-
-            async handleErrorMessageStanza (stanza) {
-                const attrs = await st.parseMUCMessage(stanza, this, _converse);
-                if (!await this.shouldShowErrorMessage(attrs)) {
-                    return;
-                }
-                const message = this.getMessageReferencedByError(attrs);
-                if (message) {
-                    const new_attrs = {
-                        'error': attrs.error,
-                        'error_condition': attrs.error_condition,
-                        'error_text': attrs.error_text,
-                        'error_type': attrs.error_type,
-                        'editable': false,
-                    };
-                    if (attrs.msgid === message.get('retraction_id')) {
-                        // The error message refers to a retraction
-                        new_attrs.retraction_id = undefined;
-                        if (!attrs.error) {
-                            if (attrs.error_condition === 'forbidden') {
-                                new_attrs.error = __("You're not allowed to retract your message.");
-                            } else if (attrs.error_condition === 'not-acceptable') {
-                                new_attrs.error = __("Your retraction was not delivered because you're not present in the groupchat.");
-                            } else {
-                                new_attrs.error = __('Sorry, an error occurred while trying to retract your message.');
-                            }
-                        }
-                    } else if (!attrs.error) {
-                        if (attrs.error_condition === 'forbidden') {
-                            new_attrs.error = __("Your message was not delivered because you weren't allowed to send it.");
-                        } else if (attrs.error_condition === 'not-acceptable') {
-                            new_attrs.error = __("Your message was not delivered because you're not present in the groupchat.");
-                        } else {
-                            new_attrs.error = __('Sorry, an error occurred while trying to send your message.');
-                        }
-                    }
-                    message.save(new_attrs);
-                } else {
-                    this.createMessage(attrs);
-                }
-            },
-
-            /**
-             * Parses an incoming message stanza and queues it for processing.
-             * @private
-             * @method _converse.ChatRoom#handleMessageStanza
-             * @param { XMLElement } stanza
-             */
-            async handleMessageStanza (stanza) {
-                if (st.isArchived(stanza)) {
-                    // MAM messages are handled in converse-mam.
-                    // We shouldn't get MAM messages here because
-                    // they shouldn't have a `type` attribute.
-                    return log.warn(`Received a MAM message with type "groupchat"`);
-                }
-                this.createInfoMessages(stanza);
-                this.fetchFeaturesIfConfigurationChanged(stanza);
-
-                /**
-                 * @typedef { Object } MUCMessageData
-                 * An object containing the original groupchat message stanza,
-                 * as well as the parsed attributes.
-                 * @property { XMLElement } stanza
-                 * @property { MUCMessageAttributes } attrs
-                 * @property { ChatRoom } chatbox
-                 */
-                const attrs = await st.parseMUCMessage(stanza, this, _converse);
-                const data = {stanza, attrs, 'chatbox': this};
-                /**
-                 * Triggered when a groupchat message stanza has been received and parsed.
-                 * @event _converse#message
-                 * @type { object }
-                 * @property { module:converse-muc~MUCMessageData } data
-                 */
-                api.trigger('message', data);
-                return attrs && this.queueMessage(attrs);
-            },
-
-            registerHandlers () {
-                // Register presence and message handlers for this groupchat
-                const room_jid = this.get('jid');
-                this.removeHandlers();
-                this.presence_handler = _converse.connection.addHandler(
-                    stanza => (this.onPresence(stanza) || true),
-                    null, 'presence', null, null, room_jid,
-                    {'ignoreNamespaceFragment': true, 'matchBareFromJid': true}
-                );
-
-                this.message_handler = _converse.connection.addHandler(
-                    stanza => (!!this.handleMessageStanza(stanza) || true),
-                    null, 'message', 'groupchat', null, room_jid,
-                    {'matchBareFromJid': true}
-                );
-
-                this.affiliation_message_handler = _converse.connection.addHandler(
-                    stanza => (this.handleAffiliationChangedMessage(stanza) || true),
-                    Strophe.NS.MUC_USER, 'message', null, null, room_jid
-                );
-            },
-
-            removeHandlers () {
-                // Remove the presence and message handlers that were
-                // registered for this groupchat.
-                if (this.message_handler) {
-                    _converse.connection && _converse.connection.deleteHandler(this.message_handler);
-                    delete this.message_handler;
-                }
-                if (this.presence_handler) {
-                    _converse.connection && _converse.connection.deleteHandler(this.presence_handler);
-                    delete this.presence_handler;
-                }
-                if (this.affiliation_message_handler) {
-                    _converse.connection && _converse.connection.deleteHandler(this.affiliation_message_handler);
-                    delete this.affiliation_message_handler;
-                }
-                return this;
-            },
-
-            invitesAllowed () {
-                return api.settings.get('allow_muc_invitations') &&
-                    (this.features.get('open') ||
-                        this.getOwnAffiliation() === "owner"
-                    );
-            },
-
-            getDisplayName () {
-                const name = this.get('name');
-                if (name) {
-                    return name;
-                } else if (api.settings.get('locked_muc_domain') === 'hidden') {
-                    return Strophe.getNodeFromJid(this.get('jid'));
-                } else {
-                    return this.get('jid');
-                }
-            },
-
-            /**
-             * Sends a message stanza to the XMPP server and expects a reflection
-             * or error message within a specific timeout period.
-             * @private
-             * @method _converse.ChatRoom#sendTimedMessage
-             * @param { _converse.Message|XMLElement } message
-             * @returns { Promise<XMLElement>|Promise<_converse.TimeoutError> } Returns a promise
-             *  which resolves with the reflected message stanza or rejects
-             *  with an error stanza or with a {@link _converse.TimeoutError}.
-             */
-            sendTimedMessage (el) {
-                if (typeof(el.tree) === "function") {
-                    el = el.tree();
-                }
-                let id = el.getAttribute('id');
-                if (!id) { // inject id if not found
-                    id = this.getUniqueId("sendIQ");
-                    el.setAttribute("id", id);
-                }
-                const promise = u.getResolveablePromise();
-                const timeoutHandler = _converse.connection.addTimedHandler(
-                    _converse.STANZA_TIMEOUT,
-                    () => {
-                        _converse.connection.deleteHandler(handler);
-                        promise.reject(new _converse.TimeoutError("Timeout Error: No response from server"));
-                        return false;
-                    }
-                );
-                const handler = _converse.connection.addHandler(stanza => {
-                    timeoutHandler && _converse.connection.deleteTimedHandler(timeoutHandler);
-                    if (stanza.getAttribute('type') === 'groupchat') {
-                        promise.resolve(stanza);
-                    } else {
-                        promise.reject(stanza);
-                    }
-                }, null, 'message', ['error', 'groupchat'], id);
-                api.send(el)
-                return promise;
-            },
-
-            /**
-             * Retract one of your messages in this groupchat
-             * @private
-             * @method _converse.ChatRoom#retractOwnMessage
-             * @param { _converse.Message } message - The message which we're retracting.
-             */
-            async retractOwnMessage(message) {
-                const origin_id = message.get('origin_id');
-                if (!origin_id) {
-                    throw new Error("Can't retract message without a XEP-0359 Origin ID");
-                }
-                const editable = message.get('editable');
-                const stanza = $msg({
-                        'id': u.getUniqueId(),
-                        'to': this.get('jid'),
-                        'type': "groupchat"
-                    })
-                    .c('store', {xmlns: Strophe.NS.HINTS}).up()
-                    .c("apply-to", {
-                        'id': origin_id,
-                        'xmlns': Strophe.NS.FASTEN
-                    }).c('retract', {xmlns: Strophe.NS.RETRACT});
-
-                // Optimistic save
-                message.set({
-                    'retracted': (new Date()).toISOString(),
-                    'retracted_id': origin_id,
-                    'retraction_id': stanza.nodeTree.getAttribute('id'),
-                    'editable': false
-                });
-                try {
-                    await this.sendTimedMessage(stanza);
-                } catch (e) {
-                    message.save({
-                        editable,
-                        'error_type': 'timeout',
-                        'error': __('A timeout happened while while trying to retract your message.'),
-                        'retracted': undefined,
-                        'retracted_id': undefined
-                    });
-                    throw e;
-                }
-            },
-
-            /**
-             * Retract someone else's message in this groupchat.
-             * @private
-             * @method _converse.ChatRoom#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 editable = message.get('editable');
-                // Optimistic save
-                message.save({
-                    'moderated': 'retracted',
-                    'moderated_by': _converse.bare_jid,
-                    'moderated_id': message.get('msgid'),
-                    'moderation_reason': reason,
-                    'editable': false
-                });
-                const result = await this.sendRetractionIQ(message, reason);
-                if (result === null || u.isErrorStanza(result)) {
-                    // Undo the save if something went wrong
-                    message.save({
-                        editable,
-                        'moderated': undefined,
-                        'moderated_by': undefined,
-                        'moderated_id': undefined,
-                        'moderation_reason': undefined,
-                    });
-                }
-                return result;
-            },
-
-            /**
-             * Sends an IQ stanza to the XMPP server to retract a message in this groupchat.
-             * @private
-             * @method _converse.ChatRoom#sendRetractionIQ
-             * @param { _converse.Message } message - The message which we're retracting.
-             * @param { string } [reason] - The reason for retracting the message.
-             */
-            sendRetractionIQ (message, reason) {
-                const iq = $iq({'to': this.get('jid'), 'type': "set"})
-                    .c("apply-to", {
-                        'id': message.get(`stanza_id ${this.get('jid')}`),
-                        'xmlns': Strophe.NS.FASTEN
-                    }).c('moderate', {xmlns: Strophe.NS.MODERATE})
-                        .c('retract', {xmlns: Strophe.NS.RETRACT}).up()
-                        .c('reason').t(reason || '');
-                return api.sendIQ(iq, null, false);
-            },
-
-            /**
-             * Sends an IQ stanza to the XMPP server to destroy this groupchat. Not
-             * to be confused with the {@link _converse.ChatRoom#destroy}
-             * method, which simply removes the room from the local browser storage cache.
-             * @private
-             * @method _converse.ChatRoom#sendDestroyIQ
-             * @param { string } [reason] - The reason for destroying the groupchat.
-             * @param { string } [new_jid] - The JID of the new groupchat which replaces this one.
-             */
-            sendDestroyIQ (reason, new_jid) {
-                const destroy = $build("destroy");
-                if (new_jid) {
-                    destroy.attrs({'jid': new_jid});
-                }
-                const iq = $iq({
-                    'to': this.get('jid'),
-                    'type': "set"
-                }).c("query", {'xmlns': Strophe.NS.MUC_OWNER}).cnode(destroy.node);
-                if (reason && reason.length > 0) {
-                    iq.c("reason", reason);
-                }
-                return api.sendIQ(iq);
-            },
-
-            /**
-             * Leave the groupchat.
-             * @private
-             * @method _converse.ChatRoom#leave
-             * @param { string } [exit_msg] - Message to indicate your reason for leaving
-             */
-            async leave (exit_msg) {
-                this.features.destroy();
-                this.occupants.clearStore();
-                if (_converse.disco_entities) {
-                    const disco_entity = _converse.disco_entities.get(this.get('jid'));
-                    if (disco_entity) {
-                        await new Promise((success, error) => disco_entity.destroy({success, error}));
-                    }
-                }
-                if (api.connection.connected()) {
-                    api.user.presence.send('unavailable', this.getRoomJIDAndNick(), exit_msg);
-                }
-                u.safeSave(this.session, {'connection_status': converse.ROOMSTATUS.DISCONNECTED});
-                this.removeHandlers();
-            },
-
-            async close () {
-                // Delete the session model
-                await new Promise(resolve => this.session.destroy({
-                    'success': resolve,
-                    'error': (m, e) => { log.error(e); resolve() }
-                }));
-                // Delete the features model
-                await new Promise(resolve => this.features.destroy({
-                    'success': resolve,
-                    'error': (m, e) => { log.error(e); resolve() }
-                }));
-                return _converse.ChatBox.prototype.close.call(this);
-            },
-
-            canModerateMessages () {
-                const self = this.getOwnOccupant();
-                return self && self.isModerator() && api.disco.supports(Strophe.NS.MODERATE, this.get('jid'));
-            },
-
-            /**
-             * Return an array of unique nicknames based on all occupants and messages in this MUC.
-             * @private
-             * @method _converse.ChatRoom#getAllKnownNicknames
-             * @returns { String[] }
-             */
-            getAllKnownNicknames () {
-                return [...new Set([
-                    ...this.occupants.map(o => o.get('nick')),
-                    ...this.messages.map(m => m.get('nick'))
-                ])].filter(n => n);
-            },
-
-            getAllKnownNicknamesRegex () {
-                const longNickString = this.getAllKnownNicknames().join('|');
-                const escapedLongNickString = p.escapeRegexString(longNickString)
-                return RegExp(`(?:\\p{P}|\\p{Z}|^)@(${escapedLongNickString})(?![\\w@-])`, 'uig');
-            },
-
-            getOccupantByJID (jid) {
-                return this.occupants.findOccupant({ jid });
-            },
-
-            getOccupantByNickname (nick) {
-                return this.occupants.findOccupant({ nick });
-            },
-
-            /**
-             * Given a text message, look for `@` mentions and turn them into
-             * XEP-0372 references
-             * @param { String } text
-             */
-            parseTextForReferences (text) {
-                const mentions_regex = /(\p{P}|\p{Z}|^)([@][\w_-]+(?:\.\w+)*)/ugi;
-                if (!text || !mentions_regex.test(text)) {
-                    return [text, []];
-                }
-
-                const getMatchingNickname = p.findFirstMatchInArray(this.getAllKnownNicknames());
-
-                const uriFromNickname = nickname => {
-                    const jid = this.get('jid');
-                    const occupant  = this.getOccupant(nickname) || this.getOccupant(jid);
-                    const uri = (occupant && occupant.get('jid')) || `${jid}/${nickname}`;
-                    return encodeURI(`xmpp:${uri}`);
-                };
-
-                const matchToReference = match => {
-                    let at_sign_index = match[0].indexOf('@');
-                    if (match[0][at_sign_index+1] === '@') { // edge-case
-                        at_sign_index += 1;
-                    }
-                    const begin = match.index + at_sign_index;
-                    const end = begin + match[0].length - at_sign_index;
-                    const value = getMatchingNickname(match[1]);
-                    const type = 'mention';
-                    const uri = uriFromNickname(value);
-                    return { begin, end, value, type, uri }
-                }
-
-                const regex = this.getAllKnownNicknamesRegex();
-                const mentions = [...text.matchAll(regex)].filter(m => !m[0].startsWith('/'));
-                const references = mentions.map(matchToReference);
-
-                const [updated_message, updated_references] = p.reduceTextFromReferences(
-                    text,
-                    references
-                );
-                return [updated_message, updated_references];
-            },
-
-            getOutgoingMessageAttributes (original_message, spoiler_hint) {
-                const is_spoiler = this.get('composing_spoiler');
-                const [text, references] = this.parseTextForReferences(original_message);
-                const origin_id = u.getUniqueId();
-                const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined;
-                return {
-                    body,
-                    is_spoiler,
-                    origin_id,
-                    references,
-                    'id': origin_id,
-                    'msgid': origin_id,
-                    'from': `${this.get('jid')}/${this.get('nick')}`,
-                    'fullname': this.get('nick'),
-                    'is_only_emojis': text ? u.isOnlyEmojis(text) : false,
-                    'message': body,
-                    'nick': this.get('nick'),
-                    'sender': 'me',
-                    'spoiler_hint': is_spoiler ? spoiler_hint : undefined,
-                    'type': 'groupchat'
-                };
-            },
-
-            /**
-             * Utility method to construct the JID for the current user as occupant of the groupchat.
-             * @private
-             * @method _converse.ChatRoom#getRoomJIDAndNick
-             * @returns {string} - The groupchat JID with the user's nickname added at the end.
-             * @example groupchat@conference.example.org/nickname
-             */
-            getRoomJIDAndNick () {
-                const nick = this.get('nick');
-                const jid = Strophe.getBareJidFromJid(this.get('jid'));
-                return jid + (nick !== null ? `/${nick}` : "");
-            },
-
-            /**
-             * Sends a message with the current XEP-0085 chat state of the user
-             * as taken from the `chat_state` attribute of the {@link _converse.ChatRoom}.
-             * @private
-             * @method _converse.ChatRoom#sendChatState
-             */
-            sendChatState () {
-                if (!api.settings.get('send_chat_state_notifications') ||
-                        !this.get('chat_state') ||
-                        this.session.get('connection_status') !== converse.ROOMSTATUS.ENTERED ||
-                        this.features.get('moderated') && this.getOwnRole() === 'visitor') {
-                    return;
-                }
-                const allowed = api.settings.get('send_chat_state_notifications');
-                if (Array.isArray(allowed) && !allowed.includes(this.get('chat_state'))) {
-                    return;
-                }
-                const chat_state = this.get('chat_state');
-                if (chat_state === _converse.GONE) {
-                    // <gone/> is not applicable within MUC context
-                    return;
-                }
-                api.send(
-                    $msg({'to':this.get('jid'), 'type': 'groupchat'})
-                        .c(chat_state, {'xmlns': Strophe.NS.CHATSTATES}).up()
-                        .c('no-store', {'xmlns': Strophe.NS.HINTS}).up()
-                        .c('no-permanent-store', {'xmlns': Strophe.NS.HINTS})
-                );
-            },
-
-            /**
-             * Send a direct invitation as per XEP-0249
-             * @private
-             * @method _converse.ChatRoom#directInvite
-             * @param { String } recipient - JID of the person being invited
-             * @param { String } [reason] - Reason for the invitation
-             */
-            directInvite (recipient, reason) {
-                if (this.features.get('membersonly')) {
-                    // When inviting to a members-only groupchat, we first add
-                    // the person to the member list by giving them an
-                    // affiliation of 'member' otherwise they won't be able to join.
-                    this.updateMemberLists([{'jid': recipient, 'affiliation': 'member', 'reason': reason}]);
-                }
-                const attrs = {
-                    'xmlns': 'jabber:x:conference',
-                    'jid': this.get('jid')
-                };
-                if (reason !== null) { attrs.reason = reason; }
-                if (this.get('password')) {
-                    attrs.password = this.get('password');
-                }
-                const invitation = $msg({
-                    'from': _converse.connection.jid,
-                    'to': recipient,
-                    'id': u.getUniqueId()
-                }).c('x', attrs);
-                api.send(invitation);
-                /**
-                 * After the user has sent out a direct invitation (as per XEP-0249),
-                 * to a roster contact, asking them to join a room.
-                 * @event _converse#chatBoxMaximized
-                 * @type {object}
-                 * @property {_converse.ChatRoom} room
-                 * @property {string} recipient - The JID of the person being invited
-                 * @property {string} reason - The original reason for the invitation
-                 * @example _converse.api.listen.on('chatBoxMaximized', view => { ... });
-                 */
-                api.trigger('roomInviteSent', {
-                    'room': this,
-                    'recipient': recipient,
-                    'reason': reason
-                });
-            },
-
-            /**
-             * Refresh the disco identity, features and fields for this {@link _converse.ChatRoom}.
-             * *features* are stored on the features {@link Model} attribute on this {@link _converse.ChatRoom}.
-             * *fields* are stored on the config {@link Model} attribute on this {@link _converse.ChatRoom}.
-             * @private
-             * @returns {Promise}
-             */
-            refreshDiscoInfo () {
-                return api.disco.refresh(this.get('jid'))
-                    .then(() => this.getDiscoInfo())
-                    .catch(e => log.error(e));
-            },
-
-            /**
-             * Fetch the *extended* MUC info from the server and cache it locally
-             * https://xmpp.org/extensions/xep-0045.html#disco-roominfo
-             * @private
-             * @method _converse.ChatRoom#getDiscoInfo
-             * @returns {Promise}
-             */
-            getDiscoInfo () {
-                return api.disco.getIdentity('conference', 'text', this.get('jid'))
-                    .then(identity => this.save({'name': identity?.get('name')}))
-                    .then(() => this.getDiscoInfoFields())
-                    .then(() => this.getDiscoInfoFeatures())
-                    .catch(e => log.error(e));
-            },
-
-            /**
-             * Fetch the *extended* MUC info fields from the server and store them locally
-             * in the `config` {@link Model} attribute.
-             * See: https://xmpp.org/extensions/xep-0045.html#disco-roominfo
-             * @private
-             * @method _converse.ChatRoom#getDiscoInfoFields
-             * @returns {Promise}
-             */
-            async getDiscoInfoFields () {
-                const fields = await api.disco.getFields(this.get('jid'));
-                const config = fields.reduce((config, f) => {
-                    const name = f.get('var');
-                    if (name && name.startsWith('muc#roominfo_')) {
-                        config[name.replace('muc#roominfo_', '')] = f.get('value');
-                    }
-                    return config;
-                }, {});
-                this.config.save(config);
-            },
-
-            /**
-             * Use converse-disco to populate the features {@link Model} which
-             * is stored as an attibute on this {@link _converse.ChatRoom}.
-             * The results may be cached. If you want to force fetching the features from the
-             * server, call {@link _converse.ChatRoom#refreshDiscoInfo} instead.
-             * @private
-             * @returns {Promise}
-             */
-            async getDiscoInfoFeatures () {
-                const features = await api.disco.getFeatures(this.get('jid'));
-                const attrs = Object.assign(
-                    zipObject(converse.ROOM_FEATURES, converse.ROOM_FEATURES.map(() => false)),
-                    {'fetched': (new Date()).toISOString()}
-                );
-                features.each(feature => {
-                    const fieldname = feature.get('var');
-                    if (!fieldname.startsWith('muc_')) {
-                        if (fieldname === Strophe.NS.MAM) {
-                            attrs.mam_enabled = true;
-                        }
-                        return;
-                    }
-                    attrs[fieldname.replace('muc_', '')] = true;
-                });
-                this.features.save(attrs);
-            },
-
-            /**
-             * Send IQ stanzas to the server to set an affiliation for
-             * the provided JIDs.
-             * See: https://xmpp.org/extensions/xep-0045.html#modifymember
-             *
-             * Prosody doesn't accept multiple JIDs' affiliations
-             * being set in one IQ stanza, so as a workaround we send
-             * a separate stanza for each JID.
-             * Related ticket: https://issues.prosody.im/345
-             *
-             * @private
-             * @method _converse.ChatRoom#setAffiliation
-             * @param { string } affiliation - The affiliation
-             * @param { object } members - A map of jids, affiliations and
-             *      optionally reasons. Only those entries with the
-             *      same affiliation as being currently set will be considered.
-             * @returns { Promise } A promise which resolves and fails depending on the XMPP server response.
-             */
-            setAffiliation (affiliation, members) {
-                members = members.filter(m => m.affiliation === undefined || m.affiliation === affiliation);
-                return Promise.all(members.map(m => this.sendAffiliationIQ(affiliation, m)));
-            },
-
-            /**
-             * Given a <field> element, return a copy with a <value> child if
-             * we can find a value for it in this rooms config.
-             * @private
-             * @method _converse.ChatRoom#addFieldValue
-             * @returns { Element }
-             */
-            addFieldValue (field) {
-                const type = field.getAttribute('type');
-                if (type === 'fixed') {
-                    return field;
-                }
-                const fieldname = field.getAttribute('var').replace('muc#roomconfig_', '');
-                const config = this.get('roomconfig');
-                if (fieldname in config) {
-                    let values;
-                    switch (type) {
-                        case 'boolean':
-                            values = [config[fieldname] ? 1 : 0];
-                            break;
-                        case 'list-multi':
-                            values = config[fieldname];
-                            break;
-                        default:
-                            values= [config[fieldname]];
-                    }
-                    field.innerHTML = values.map(v => $build('value').t(v)).join('');
-                }
-                return field;
-            },
-
-            /**
-             * Automatically configure the groupchat based on this model's
-             * 'roomconfig' data.
-             * @private
-             * @method _converse.ChatRoom#autoConfigureChatRoom
-             * @returns { Promise<XMLElement> }
-             * Returns a promise which resolves once a response IQ has
-             * been received.
-             */
-            async autoConfigureChatRoom () {
-                const stanza = await this.fetchRoomConfiguration();
-                const fields = sizzle('field', stanza);
-                const configArray = fields.map(f => this.addFieldValue(f))
-                if (configArray.length) {
-                    return this.sendConfiguration(configArray);
-                }
-            },
-
-            /**
-             * Send an IQ stanza to fetch the groupchat configuration data.
-             * Returns a promise which resolves once the response IQ
-             * has been received.
-             * @private
-             * @method _converse.ChatRoom#fetchRoomConfiguration
-             * @returns { Promise<XMLElement> }
-             */
-            fetchRoomConfiguration () {
-                return api.sendIQ(
-                    $iq({'to': this.get('jid'), 'type': "get"})
-                     .c("query", {xmlns: Strophe.NS.MUC_OWNER})
-                );
-            },
-
-            /**
-             * Sends an IQ stanza with the groupchat configuration.
-             * @private
-             * @method _converse.ChatRoom#sendConfiguration
-             * @param { Array } config - The groupchat configuration
-             * @returns { Promise<XMLElement> } - A promise which resolves with
-             * the `result` stanza received from the XMPP server.
-             */
-            sendConfiguration (config=[]) {
-                const iq = $iq({to: this.get('jid'), type: "set"})
-                    .c("query", {xmlns: Strophe.NS.MUC_OWNER})
-                    .c("x", {xmlns: Strophe.NS.XFORM, type: "submit"});
-                config.forEach(node => iq.cnode(node).up());
-                return api.sendIQ(iq);
-            },
-
-            /**
-             * Returns the `role` which the current user has in this MUC
-             * @private
-             * @method _converse.ChatRoom#getOwnRole
-             * @returns { ('none'|'visitor'|'participant'|'moderator') }
-             */
-            getOwnRole () {
-                return this.getOwnOccupant()?.attributes?.role;
-            },
-
-            /**
-             * Returns the `affiliation` which the current user has in this MUC
-             * @private
-             * @method _converse.ChatRoom#getOwnAffiliation
-             * @returns { ('none'|'outcast'|'member'|'admin'|'owner') }
-             */
-            getOwnAffiliation () {
-                return this.getOwnOccupant()?.attributes?.affiliation;
-            },
-
-            /**
-             * Get the {@link _converse.ChatRoomOccupant} instance which
-             * represents the current user.
-             * @private
-             * @method _converse.ChatRoom#getOwnOccupant
-             * @returns { _converse.ChatRoomOccupant }
-             */
-            getOwnOccupant () {
-                return this.occupants.findWhere({'jid': _converse.bare_jid});
-            },
-
-            /**
-             * Send an IQ stanza specifying an affiliation change.
-             * @private
-             * @method _converse.ChatRoom#
-             * @param { String } affiliation: affiliation
-             *     (could also be stored on the member object).
-             * @param { Object } member: Map containing the member's jid and
-             *     optionally a reason and affiliation.
-             */
-            sendAffiliationIQ (affiliation, member) {
-                const iq = $iq({to: this.get('jid'), type: "set"})
-                    .c("query", {xmlns: Strophe.NS.MUC_ADMIN})
-                    .c("item", {
-                        'affiliation': member.affiliation || affiliation,
-                        'nick': member.nick,
-                        'jid': member.jid
-                    });
-                if (member.reason !== undefined) {
-                    iq.c("reason", member.reason);
-                }
-                return api.sendIQ(iq);
-            },
-
-            /**
-             * Send IQ stanzas to the server to modify affiliations for users in this groupchat.
-             *
-             * See: https://xmpp.org/extensions/xep-0045.html#modifymember
-             * @private
-             * @method _converse.ChatRoom#setAffiliations
-             * @param { Object[] } members
-             * @param { string } members[].jid - The JID of the user whose affiliation will change
-             * @param { Array } members[].affiliation - The new affiliation for this user
-             * @param { string } [members[].reason] - An optional reason for the affiliation change
-             * @returns { Promise }
-             */
-            setAffiliations (members) {
-                const affiliations = [...new Set(members.map(m => m.affiliation))];
-                return Promise.all(affiliations.map(a => this.setAffiliation(a, members)));
-            },
-
-            /**
-             * Send an IQ stanza to modify an occupant's role
-             * @private
-             * @method _converse.ChatRoom#setRole
-             * @param { _converse.ChatRoomOccupant } occupant
-             * @param { String } role
-             * @param { String } reason
-             * @param { function } onSuccess - callback for a succesful response
-             * @param { function } onError - callback for an error response
-             */
-            setRole (occupant, role, reason, onSuccess, onError) {
-                const item = $build("item", {
-                    'nick': occupant.get('nick'),
-                    role
-                });
-                const iq = $iq({
-                    'to': this.get('jid'),
-                    'type': 'set'
-                }).c("query", {xmlns: Strophe.NS.MUC_ADMIN}).cnode(item.node);
-                if (reason !== null) {
-                    iq.c("reason", reason);
-                }
-                return api.sendIQ(iq).then(onSuccess).catch(onError);
-            },
-
-            /**
-             * @private
-             * @method _converse.ChatRoom#getOccupant
-             * @param { String } nickname_or_jid - The nickname or JID of the occupant to be returned
-             * @returns { _converse.ChatRoomOccupant }
-             */
-            getOccupant (nickname_or_jid) {
-                return u.isValidJID(nickname_or_jid)
-                    ? this.getOccupantByJID(nickname_or_jid)
-                    : this.getOccupantByNickname(nickname_or_jid);
-            },
-
-            /**
-             * Return an array of occupant models that have the required role
-             * @private
-             * @method _converse.ChatRoom#getOccupantsWithRole
-             * @param { String } role
-             * @returns { _converse.ChatRoomOccupant[] }
-             */
-            getOccupantsWithRole (role) {
-                return this.getOccupantsSortedBy('nick')
-                    .filter(o => o.get('role') === role)
-                    .map(item => {
-                        return {
-                            'jid': item.get('jid'),
-                            'nick': item.get('nick'),
-                            'role': item.get('role')
-                        }
-                    });
-            },
-
-            /**
-             * Return an array of occupant models that have the required affiliation
-             * @private
-             * @method _converse.ChatRoom#getOccupantsWithAffiliation
-             * @param { String } affiliation
-             * @returns { _converse.ChatRoomOccupant[] }
-             */
-            getOccupantsWithAffiliation (affiliation) {
-                return this.getOccupantsSortedBy('nick')
-                    .filter(o => o.get('affiliation') === affiliation)
-                    .map(item => {
-                        return {
-                            'jid': item.get('jid'),
-                            'nick': item.get('nick'),
-                            'affiliation': item.get('affiliation')
-                        }
-                    });
-            },
-
-            /**
-             * Return an array of occupant models, sorted according to the passed-in attribute.
-             * @private
-             * @method _converse.ChatRoom#getOccupantsSortedBy
-             * @param { String } attr - The attribute to sort the returned array by
-             * @returns { _converse.ChatRoomOccupant[] }
-             */
-            getOccupantsSortedBy (attr) {
-                return Array.from(this.occupants.models)
-                    .sort((a, b) => a.get(attr) < b.get(attr) ? -1 : (a.get(attr) > b.get(attr) ? 1 : 0));
-            },
-
-            /**
-             * Sends an IQ stanza to the server, asking it for the relevant affiliation list .
-             * Returns an array of {@link MemberListItem} objects, representing occupants
-             * that have the given affiliation.
-             * See: https://xmpp.org/extensions/xep-0045.html#modifymember
-             * @private
-             * @method _converse.ChatRoom#getAffiliationList
-             * @param { ("admin"|"owner"|"member") } affiliation
-             * @returns { Promise<MemberListItem[]> }
-             */
-            async getAffiliationList (affiliation) {
-                const iq = $iq({to: this.get('jid'), type: "get"})
-                    .c("query", {xmlns: Strophe.NS.MUC_ADMIN})
-                        .c("item", {'affiliation': affiliation});
-                const result = await api.sendIQ(iq, null, false);
-                if (result === null) {
-                    const err_msg = `Error: timeout while fetching ${affiliation} list for MUC ${this.get('jid')}`;
-                    const err = new Error(err_msg);
-                    log.warn(err_msg);
-                    log.warn(result);
-                    return err;
-                }
-                if (u.isErrorStanza(result)) {
-                    const err_msg = `Error: not allowed to fetch ${affiliation} list for MUC ${this.get('jid')}`;
-                    const err = new Error(err_msg);
-                    log.warn(err_msg);
-                    log.warn(result);
-                    return err;
-                }
-                return muc_utils.parseMemberListIQ(result)
-                    .filter(p => p)
-                    .sort((a, b) => a.nick < b.nick ? -1 : (a.nick > b.nick ? 1 : 0))
-            },
-
-            /**
-             * Fetch the lists of users with the given affiliations.
-             * Then compute the delta between those users and
-             * the passed in members, and if it exists, send the delta
-             * to the XMPP server to update the member list.
-             * @private
-             * @method _converse.ChatRoom#updateMemberLists
-             * @param { object } members - Map of member jids and affiliations.
-             * @returns { Promise }
-             *  A promise which is resolved once the list has been
-             *  updated or once it's been established there's no need
-             *  to update the list.
-             */
-            async updateMemberLists (members) {
-                const all_affiliations = ['member', 'admin', 'owner'];
-                const aff_lists = await Promise.all(all_affiliations.map(a => this.getAffiliationList(a)));
-                const old_members = aff_lists.reduce((acc, val) => (u.isErrorObject(val) ? acc: [...val, ...acc]), []);
-                await this.setAffiliations(muc_utils.computeAffiliationsDelta(true, false, members, old_members));
-                await this.occupants.fetchMembers();
-            },
-
-            /**
-             * Given a nick name, save it to the model state, otherwise, look
-             * for a server-side reserved nickname or default configured
-             * nickname and if found, persist that to the model state.
-             * @private
-             * @method _converse.ChatRoom#getAndPersistNickname
-             * @returns { Promise<string> } A promise which resolves with the nickname
-             */
-            async getAndPersistNickname (nick) {
-                nick = nick ||
-                    this.get('nick') ||
-                    await this.getReservedNick() ||
-                    _converse.getDefaultMUCNickname();
-
-                if (nick) {
-                    this.save({nick}, {'silent': true});
-                }
-                return nick;
-            },
-
-            /**
-             * Use service-discovery to ask the XMPP server whether
-             * this user has a reserved nickname for this groupchat.
-             * If so, we'll use that, otherwise we render the nickname form.
-             * @private
-             * @method _converse.ChatRoom#getReservedNick
-             * @returns { Promise<string> } A promise which resolves with the reserved nick or null
-             */
-            async getReservedNick () {
-                const stanza = $iq({
-                    'to': this.get('jid'),
-                    'from': _converse.connection.jid,
-                    'type': "get"
-                }).c("query", {
-                    'xmlns': Strophe.NS.DISCO_INFO,
-                    'node': 'x-roomuser-item'
-                })
-                const result = await api.sendIQ(stanza, null, false);
-                if (u.isErrorObject(result)) {
-                    throw result;
-                }
-                const identity_el = result.querySelector('query[node="x-roomuser-item"] identity');
-                return identity_el ? identity_el.getAttribute('name') : null;
-            },
-
-            async registerNickname () {
-                // See https://xmpp.org/extensions/xep-0045.html#register
-                const nick = this.get('nick');
-                const jid = this.get('jid');
-                let iq, err_msg;
-                try {
-                    iq = await api.sendIQ(
-                        $iq({
-                            'to': jid,
-                            'from': _converse.connection.jid,
-                            'type': 'get'
-                        }).c('query', {'xmlns': Strophe.NS.MUC_REGISTER})
-                    );
-                } catch (e) {
-                    if (sizzle(`not-allowed[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
-                        err_msg = __("You're not allowed to register yourself in this groupchat.");
-                    } else if (sizzle(`registration-required[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
-                        err_msg = __("You're not allowed to register in this groupchat because it's members-only.");
-                    }
-                    log.error(e);
-                    return err_msg;
-                }
-                const required_fields = sizzle('field required', iq).map(f => f.parentElement);
-                if (required_fields.length > 1 && required_fields[0].getAttribute('var') !== 'muc#register_roomnick') {
-                    return log.error(`Can't register the user register in the groupchat ${jid} due to the required fields`);
-                }
-                try {
-                    await api.sendIQ($iq({
-                            'to': jid,
-                            'from': _converse.connection.jid,
-                            'type': 'set'
-                        }).c('query', {'xmlns': Strophe.NS.MUC_REGISTER})
-                            .c('x', {'xmlns': Strophe.NS.XFORM, 'type': 'submit'})
-                                .c('field', {'var': 'FORM_TYPE'}).c('value').t('http://jabber.org/protocol/muc#register').up().up()
-                                .c('field', {'var': 'muc#register_roomnick'}).c('value').t(nick)
-                    );
-                } catch (e) {
-                    if (sizzle(`service-unavailable[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
-                        err_msg = __("Can't register your nickname in this groupchat, it doesn't support registration.");
-                    } else if (sizzle(`bad-request[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
-                        err_msg = __("Can't register your nickname in this groupchat, invalid data form supplied.");
-                    }
-                    log.error(err_msg);
-                    log.error(e);
-                    return err_msg;
-                }
-            },
-
-            /**
-             * Given a presence stanza, update the occupant model based on its contents.
-             * @private
-             * @method _converse.ChatRoom#updateOccupantsOnPresence
-             * @param { XMLElement } pres - The presence stanza
-             */
-            updateOccupantsOnPresence (pres) {
-                const data = st.parseMUCPresence(pres);
-                if (data.type === 'error' || (!data.jid && !data.nick)) {
-                    return true;
-                }
-                const occupant = this.occupants.findOccupant(data);
-                // Destroy an unavailable occupant if this isn't a nick change operation and if they're not affiliated
-                if (data.type === 'unavailable' && occupant &&
-                        !data.states.includes(converse.MUC_NICK_CHANGED_CODE) &&
-                        !['admin', 'owner', 'member'].includes(data['affiliation'])) {
-                    // Before destroying we set the new data, so that we can show the disconnection message
-                    occupant.set(data);
-                    occupant.destroy();
-                    return;
-                }
-                const jid = data.jid || '';
-                const attributes = Object.assign(data, {
-                    'jid': Strophe.getBareJidFromJid(jid) || occupant?.attributes?.jid,
-                    'resource': Strophe.getResourceFromJid(jid) || occupant?.attributes?.resource
-                });
-                if (occupant) {
-                    occupant.save(attributes);
-                } else {
-                    this.occupants.create(attributes);
-                }
-            },
-
-            fetchFeaturesIfConfigurationChanged (stanza) {
-                // 104: configuration change
-                // 170: logging enabled
-                // 171: logging disabled
-                // 172: room no longer anonymous
-                // 173: room now semi-anonymous
-                // 174: room now fully anonymous
-                const codes = ['104', '170', '171', '172', '173', '174'];
-                if (sizzle('status', stanza).filter(e => codes.includes(e.getAttribute('status'))).length) {
-                    this.refreshDiscoInfo();
-                }
-            },
-
-            /**
-             * Given two JIDs, which can be either user JIDs or MUC occupant JIDs,
-             * determine whether they belong to the same user.
-             * @private
-             * @method _converse.ChatRoom#isSameUser
-             * @param { String } jid1
-             * @param { String } jid2
-             * @returns { Boolean }
-             */
-            isSameUser (jid1, jid2) {
-                const bare_jid1 = Strophe.getBareJidFromJid(jid1);
-                const bare_jid2 = Strophe.getBareJidFromJid(jid2);
-                const resource1 = Strophe.getResourceFromJid(jid1);
-                const resource2 = Strophe.getResourceFromJid(jid2);
-                if (u.isSameBareJID(jid1, jid2)) {
-                    if (bare_jid1 === this.get('jid')) {
-                        // MUC JIDs
-                        return resource1 === resource2;
-                    } else {
-                        return true;
-                    }
-                } else {
-                    const occupant1 = (bare_jid1 === this.get('jid')) ?
-                        this.occupants.findOccupant({'nick': resource1}) :
-                        this.occupants.findOccupant({'jid': bare_jid1});
-
-                    const occupant2 = (bare_jid2 === this.get('jid')) ?
-                        this.occupants.findOccupant({'nick': resource2}) :
-                        this.occupants.findOccupant({'jid': bare_jid2});
-                    return occupant1 === occupant2;
-                }
-            },
-
-            async isSubjectHidden () {
-                const jids = await api.user.settings.get('mucs_with_hidden_subject', [])
-                return jids.includes(this.get('jid'));
-            },
-
-            async toggleSubjectHiddenState () {
-                const muc_jid = this.get('jid');
-                const jids = await api.user.settings.get('mucs_with_hidden_subject', []);
-                if (jids.includes(this.get('jid'))) {
-                    api.user.settings.set('mucs_with_hidden_subject', jids.filter(jid => jid !== muc_jid));
-                } else {
-                    api.user.settings.set('mucs_with_hidden_subject', [...jids, muc_jid]);
-                }
-            },
-
-            /**
-             * Handle a possible subject change and return `true` if so.
-             * @private
-             * @method _converse.ChatRoom#handleSubjectChange
-             * @param { object } attrs - Attributes representing a received
-             *  message, as returned by {@link st.parseMUCMessage}
-             */
-            async handleSubjectChange (attrs) {
-                if (typeof attrs.subject === 'string' && !attrs.thread && !attrs.message) {
-                    // https://xmpp.org/extensions/xep-0045.html#subject-mod
-                    // -----------------------------------------------------
-                    // The subject is changed by sending a message of type "groupchat" to the <room@service>,
-                    // where the <message/> MUST contain a <subject/> element that specifies the new subject but
-                    // MUST NOT contain a <body/> element (or a <thread/> element).
-                    const subject = attrs.subject;
-                    const author = attrs.nick;
-                    u.safeSave(this, {'subject': {author, 'text': attrs.subject || ''}});
-                    if (!attrs.is_delayed && author) {
-                        const message = subject ? __('Topic set by %1$s', author) : __('Topic cleared by %1$s', author);
-                        const prev_msg = this.messages.last();
-                        if (prev_msg?.get('nick') !== attrs.nick ||
-                                prev_msg?.get('type') !== 'info' ||
-                                prev_msg?.get('message') !== message) {
-                            this.createMessage({message, 'nick': attrs.nick, 'type': 'info'});
-                        }
-                        if (await this.isSubjectHidden()) {
-                            this.toggleSubjectHiddenState();
-                        }
-                     }
-                    return true;
-                }
-                return false;
-            },
-
-            /**
-             * Set the subject for this {@link _converse.ChatRoom}
-             * @private
-             * @method _converse.ChatRoom#setSubject
-             * @param { String } value
-             */
-            setSubject(value='') {
-                api.send(
-                    $msg({
-                        to: this.get('jid'),
-                        from: _converse.connection.jid,
-                        type: "groupchat"
-                    }).c("subject", {xmlns: "jabber:client"}).t(value).tree()
-                );
-            },
-
-            /**
-             * Is this a chat state notification that can be ignored,
-             * because it's old or because it's from us.
-             * @private
-             * @method _converse.ChatRoom#ignorableCSN
-             * @param { Object } attrs - The message attributes
-             */
-            ignorableCSN (attrs) {
-                return attrs.chat_state && !attrs.body && (attrs.is_delayed || this.isOwnMessage(attrs));
-            },
-
-
-            /**
-             * Determines whether the message is from ourselves by checking
-             * the `from` attribute. Doesn't check the `type` attribute.
-             * @private
-             * @method _converse.ChatRoom#isOwnMessage
-             * @param { Object|XMLElement|_converse.Message } msg
-             * @returns { boolean }
-             */
-            isOwnMessage (msg) {
-                let from;
-                if (isElement(msg)) {
-                    from = msg.getAttribute('from');
-                } else if (msg instanceof _converse.Message) {
-                    from = msg.get('from');
-                } else {
-                    from = msg.from;
-                }
-                return Strophe.getResourceFromJid(from) == this.get('nick');
-            },
-
-
-            getUpdatedMessageAttributes (message, attrs) {
-                const new_attrs = _converse.ChatBox.prototype.getUpdatedMessageAttributes.call(this, message, attrs);
-                if (this.isOwnMessage(attrs)) {
-                    const stanza_id_keys = Object.keys(attrs).filter(k => k.startsWith('stanza_id'));
-                    Object.assign(new_attrs,  pick(attrs, stanza_id_keys));
-                    if (!message.get('received')) {
-                        new_attrs.received = (new Date()).toISOString();
-                    }
-                }
-                return new_attrs;
-            },
-
-            /**
-             * Send a MUC-0410 MUC Self-Ping stanza to room to determine
-             * whether we're still joined.
-             * @async
-             * @private
-             * @method _converse.ChatRoom#isJoined
-             * @returns {Promise<boolean>}
-             */
-            async isJoined () {
-                const jid = this.get('jid');
-                const ping = $iq({
-                    'to': `${jid}/${this.get('nick')}`,
-                    'type': "get"
-                }).c("ping", {'xmlns': Strophe.NS.PING});
-                try {
-                    await api.sendIQ(ping);
-                } catch (e) {
-                    if (e === null) {
-                        log.warn(`isJoined: Timeout error while checking whether we're joined to MUC: ${jid}`);
-                    } else {
-                        log.warn(`isJoined: Apparently we're no longer connected to MUC: ${jid}`);
-                    }
-                    return false;
-                }
-                return true;
-            },
-
-            /**
-             * Check whether we're still joined and re-join if not
-             * @async
-             * @private
-             * @method _converse.ChatRoom#rejoinIfNecessary
-             */
-            async rejoinIfNecessary () {
-                if (! await this.isJoined()) {
-                    this.rejoin();
-                    return true;
-                }
-            },
-
-            /**
-             * @private
-             * @method _converse.ChatRoom#shouldShowErrorMessage
-             * @returns {Promise<boolean>}
-             */
-            async shouldShowErrorMessage (attrs) {
-                if (attrs['error_condition'] === 'not-acceptable' && await this.rejoinIfNecessary()) {
-                    return false;
-                }
-                return _converse.ChatBox.prototype.shouldShowErrorMessage.call(this, attrs);
-            },
-
-            /**
-             * Looks whether we already have a moderation message for this
-             * incoming message. If so, it's considered "dangling" because
-             * it probably hasn't been applied to anything yet, given that
-             * the relevant message is only coming in now.
-             * @private
-             * @method _converse.ChatRoom#findDanglingModeration
-             * @param { object } attrs - Attributes representing a received
-             *  message, as returned by {@link st.parseMUCMessage}
-             * @returns { _converse.ChatRoomMessage }
-             */
-            findDanglingModeration (attrs) {
-                if (!this.messages.length) {
-                    return null;
-                }
-                // Only look for dangling moderation if there are newer
-                // messages than this one, since moderation come after.
-                if (this.messages.last().get('time') > attrs.time) {
-                    // Search from latest backwards
-                    const messages = Array.from(this.messages.models);
-                    const stanza_id = attrs[`stanza_id ${this.get('jid')}`];
-                    if (!stanza_id) {
-                        return null;
-                    }
-                    messages.reverse();
-                    return messages.find(
-                        ({attributes}) =>
-                            attributes.moderated === 'retracted' &&
-                            attributes.moderated_id === stanza_id &&
-                            attributes.moderated_by
-                    );
-                }
-            },
-
-            /**
-             * Handles message moderation based on the passed in attributes.
-             * @private
-             * @method _converse.ChatRoom#handleModeration
-             * @param { object } attrs - Attributes representing a received
-             *  message, as returned by {@link st.parseMUCMessage}
-             * @returns { Boolean } Returns `true` or `false` depending on
-             *  whether a message was moderated or not.
-             */
-            async handleModeration (attrs) {
-                const MODERATION_ATTRIBUTES = [
-                    'editable',
-                    'moderated',
-                    'moderated_by',
-                    'moderated_id',
-                    'moderation_reason'
-                ];
-                if (attrs.moderated === 'retracted') {
-                    const query = {};
-                    const key = `stanza_id ${this.get('jid')}`;
-                    query[key] = attrs.moderated_id;
-                    const message = this.messages.findWhere(query);
-                    if (!message) {
-                        attrs['dangling_moderation'] = true;
-                        await this.createMessage(attrs);
-                        return true;
-                    }
-                    message.save(pick(attrs, MODERATION_ATTRIBUTES));
-                    return true;
-                } else {
-                    // Check if we have dangling moderation message
-                    const message = this.findDanglingModeration(attrs);
-                    if (message) {
-                        const moderation_attrs = pick(message.attributes, MODERATION_ATTRIBUTES);
-                        const new_attrs = Object.assign({'dangling_moderation': false}, attrs, moderation_attrs);
-                        delete new_attrs['id']; // Delete id, otherwise a new cache entry gets created
-                        message.save(new_attrs);
-                        return true;
-                    }
-                }
-                return false;
-            },
-
-            /**
-             * @param {String} actor - The nickname of the actor that caused the notification
-             * @param {String|Array<String>} states - The state or states representing the type of notificcation
-             */
-            removeNotification (actor, states) {
-                const actors_per_state = this.notifications.toJSON();
-                states = Array.isArray(states) ? states : [states];
-                states.forEach(state => {
-                    const existing_actors = Array.from(actors_per_state[state] || []);
-                    if (existing_actors.includes(actor)) {
-                        const idx = existing_actors.indexOf(actor);
-                        existing_actors.splice(idx, 1);
-                        this.notifications.set(state, Array.from(existing_actors));
-                    }
-                });
-            },
-
-            /**
-             * Update the notifications model by adding the passed in nickname
-             * to the array of nicknames that all match a particular state.
-             *
-             * Removes the nickname from any other states it might be associated with.
-             *
-             * The state can be a XEP-0085 Chat State or a XEP-0045 join/leave
-             * state.
-             * @param {String} actor - The nickname of the actor that causes the notification
-             * @param {String} state - The state representing the type of notificcation
-             */
-            updateNotifications (actor, state) {
-                const actors_per_state = this.notifications.toJSON();
-                const existing_actors = actors_per_state[state] || [];
-                if (existing_actors.includes(actor)) {
-                    return;
-                }
-                const reducer = (out, s) => {
-                    if (s === state) {
-                        out[s] =  [...existing_actors, actor];
-                    } else {
-                        out[s] = (actors_per_state[s] || []).filter(a => a !== actor);
-                    }
-                    return out;
-                };
-                const actors_per_chat_state = converse.CHAT_STATES.reduce(reducer, {});
-                const actors_per_traffic_state = converse.MUC_TRAFFIC_STATES_LIST.reduce(reducer, {});
-                const actors_per_role_change = converse.MUC_ROLE_CHANGES_LIST.reduce(reducer, {});
-                this.notifications.set(Object.assign(
-                    actors_per_chat_state,
-                    actors_per_traffic_state,
-                    actors_per_role_change
-                ));
-                window.setTimeout(() => this.removeNotification(actor, state), 10000);
-            },
-
-            /**
-             * Handler for all MUC messages sent to this groupchat. This method
-             * shouldn't be called directly, instead {@link _converse.ChatRoom#queueMessage}
-             * should be called.
-             * @private
-             * @method _converse.ChatRoom#onMessage
-             * @param { MessageAttributes } attrs - A promise which resolves to the message attributes.
-             */
-            async onMessage (attrs) {
-                attrs = await attrs;
-                if (u.isErrorObject(attrs)) {
-                    attrs.stanza && log.error(attrs.stanza);
-                    return log.error(attrs.message);
-                }
-                const message = this.getDuplicateMessage(attrs);
-                if (message) {
-                    return this.updateMessage(message, attrs);
-                } else if (attrs.is_valid_receipt_request || attrs.is_marker || this.ignorableCSN(attrs)) {
-                    return;
-                }
-                if (await this.handleRetraction(attrs) ||
-                        await this.handleModeration(attrs) ||
-                        await this.handleSubjectChange(attrs)) {
-                    return this.removeNotification(attrs.nick, ['composing', 'paused']);
-                }
-                this.setEditable(attrs, attrs.time);
-
-                if (attrs['chat_state']) {
-                    this.updateNotifications(attrs.nick, attrs.chat_state);
-                }
-                if (u.shouldCreateGroupchatMessage(attrs)) {
-                    const msg = this.handleCorrection(attrs) || await this.createMessage(attrs);
-                    this.removeNotification(attrs.nick, ['composing', 'paused']);
-                    this.handleUnreadMessage(msg);
-                }
-            },
-
-            handleModifyError(pres) {
-                const text = pres.querySelector('error text')?.textContent;
-                if (text) {
-                    if (this.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING) {
-                        this.setDisconnectionMessage(text);
-                    } else {
-                        const attrs = {
-                            'type': 'error',
-                            'message': text,
-                            'is_ephemeral': true
-                        }
-                        this.createMessage(attrs);
-                    }
-                }
-            },
-
-            /**
-             * Handle a presence stanza that disconnects the user from the MUC
-             * @param { XMLElement } stanza
-             */
-            handleDisconnection (stanza) {
-                const is_self = stanza.querySelector("status[code='110']") !== null;
-                const x = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"]`, stanza).pop();
-                if (!x) {
-                    return;
-                }
-                const codes = sizzle('status', x).map(s => s.getAttribute('code'));
-                const disconnection_codes = intersection(codes, Object.keys(_converse.muc.disconnect_messages));
-                const disconnected = is_self && disconnection_codes.length > 0;
-                if (!disconnected) {
-                    return;
-                }
-                // By using querySelector we assume here there is
-                // one <item> per <x xmlns='http://jabber.org/protocol/muc#user'>
-                // element. This appears to be a safe assumption, since
-                // each <x/> element pertains to a single user.
-                const item = x.querySelector('item');
-                const reason = item ? item.querySelector('reason')?.textContent : undefined;
-                const actor = item ? invoke(item.querySelector('actor'), 'getAttribute', 'nick') : undefined;
-                const message = _converse.muc.disconnect_messages[disconnection_codes[0]];
-                this.setDisconnectionMessage(message, reason, actor);
-            },
-
-
-            getActionInfoMessage (code, nick, actor) {
-                if (code === '301') {
-                    return actor ? __("%1$s has been banned by %2$s", nick, actor) : __("%1$s has been banned", nick);
-                } else if (code === '303') {
-                    return __("%1$s\'s nickname has changed", nick);
-                } else  if (code === '307') {
-                    return actor ? __("%1$s has been kicked out by %2$s", nick, actor) : __("%1$s has been kicked out", nick);
-                } else if (code === '321') {
-                    return __("%1$s has been removed because of an affiliation change", nick);
-                } else if (code === '322') {
-                    return __("%1$s has been removed for not being a member", nick);
-                }
-            },
-
-            createAffiliationChangeMessage (occupant) {
-                const previous_affiliation = occupant._previousAttributes.affiliation;
-
-                if (!previous_affiliation) {
-                    // If no previous affiliation was set, then we don't
-                    // interpret this as an affiliation change.
-                    // For example, if muc_send_probes is true, then occupants
-                    // are created based on incoming messages, in which case
-                    // we don't yet know the affiliation
-                    return
-                }
-
-                const current_affiliation = occupant.get('affiliation');
-                if (previous_affiliation === 'admin' &&
-                    _converse.isInfoVisible(converse.AFFILIATION_CHANGES.EXADMIN)) {
-                    this.createMessage({
-                        'type': 'info',
-                        'message': __("%1$s is no longer an admin of this groupchat", occupant.get('nick'))
-                    });
-                } else if (previous_affiliation === 'owner' &&
-                    _converse.isInfoVisible(converse.AFFILIATION_CHANGES.EXOWNER)) {
-                    this.createMessage({
-                        'type': 'info',
-                        'message': __("%1$s is no longer an owner of this groupchat", occupant.get('nick'))
-                    });
-                } else if (previous_affiliation === 'outcast' &&
-                    _converse.isInfoVisible(converse.AFFILIATION_CHANGES.EXOUTCAST)) {
-                    this.createMessage({
-                        'type': 'info',
-                        'message': __("%1$s is no longer banned from this groupchat", occupant.get('nick'))
-                    });
-                }
-
-                if (current_affiliation === 'none' && previous_affiliation === 'member' &&
-                    _converse.isInfoVisible(converse.AFFILIATION_CHANGES.EXMEMBER)) {
-                    this.createMessage({
-                        'type': 'info',
-                        'message': __("%1$s is no longer a member of this groupchat", occupant.get('nick'))
-                    });
-                }
-
-                if (current_affiliation === 'member' &&
-                    _converse.isInfoVisible(converse.AFFILIATION_CHANGES.MEMBER)) {
-                    this.createMessage({
-                        'type': 'info',
-                        'message': __("%1$s is now a member of this groupchat", occupant.get('nick'))
-                    });
-                } else if ((current_affiliation === 'admin' && _converse.isInfoVisible(converse.AFFILIATION_CHANGES.ADMIN))
-                || (current_affiliation == 'owner' && _converse.isInfoVisible(converse.AFFILIATION_CHANGES.OWNER))) {
-                    // For example: AppleJack is now an (admin|owner) of this groupchat
-                    this.createMessage({
-                        'type': 'info',
-                        'message': __(
-                            '%1$s is now an %2$s of this groupchat',
-                            occupant.get('nick'),
-                            current_affiliation
-                        )
-                    });
-                }
-            },
-
-            createRoleChangeMessage (occupant, changed) {
-                if (changed === "none" || occupant.changed.affiliation) {
-                    // We don't inform of role changes if they accompany affiliation changes.
-                    return;
-                }
-                const previous_role = occupant._previousAttributes.role;
-                if (previous_role === 'moderator' && _converse.isInfoVisible(converse.MUC_ROLE_CHANGES.DEOP)) {
-                    this.updateNotifications(occupant.get('nick'), converse.MUC_ROLE_CHANGES.DEOP);
-                } else if (previous_role === 'visitor' && _converse.isInfoVisible(converse.MUC_ROLE_CHANGES.VOICE)) {
-                    this.updateNotifications(occupant.get('nick'), converse.MUC_ROLE_CHANGES.VOICE);
-                }
-                if (occupant.get('role') === 'visitor'  && _converse.isInfoVisible(converse.MUC_ROLE_CHANGES.MUTE)) {
-                    this.updateNotifications(occupant.get('nick'), converse.MUC_ROLE_CHANGES.MUTE);
-                } else if (occupant.get('role') === 'moderator') {
-                    if (!['owner', 'admin'].includes(occupant.get('affiliation')) && _converse.isInfoVisible(converse.MUC_ROLE_CHANGES.OP)) {
-                        // Oly show this message if the user isn't already
-                        // an admin or owner, otherwise this isn't new information.
-                        this.updateNotifications(occupant.get('nick'), converse.MUC_ROLE_CHANGES.OP);
-                    }
-                }
-            },
-
-
-            /**
-             * Create an info message based on a received MUC status code
-             * @private
-             * @method _converse.ChatRoom#createInfoMessage
-             * @param { string } code - The MUC status code
-             * @param { XMLElement } stanza - The original stanza that contains the code
-             * @param { Boolean } is_self - Whether this stanza refers to our own presence
-             */
-            createInfoMessage (code, stanza, is_self) {
-                const data = { 'type': 'info' };
-                if (!_converse.isInfoVisible(code)){
-                    return;
-                }
-                if (code === '110' || (code === '100' && !is_self)) {
-                    return;
-                } else if (code in _converse.muc.info_messages) {
-                    data.message = _converse.muc.info_messages[code];
-                } else if (!is_self && ACTION_INFO_CODES.includes(code)) {
-                    const nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
-                    const item = stanza.querySelector(`x[xmlns="${Strophe.NS.MUC_USER}"] item`);
-                    data.actor = item
-                        ? item.querySelector('actor')?.getAttribute('nick')
-                        : undefined;
-                    data.reason = item ? item.querySelector('reason')?.textContent : undefined;
-                    data.message = this.getActionInfoMessage(code, nick, data.actor);
-                } else if (is_self && code in _converse.muc.new_nickname_messages) {
-                    // XXX: Side-effect of setting the nick. Should ideally be refactored out of this method
-                    let nick;
-                    if (is_self && code === '210') {
-                        nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
-                    } else if (is_self && code === '303') {
-                        nick = stanza
-                            .querySelector(`x[xmlns="${Strophe.NS.MUC_USER}"] item`)
-                            .getAttribute('nick');
-                    }
-                    this.save('nick', nick);
-                    data.message = __(_converse.muc.new_nickname_messages[code], nick);
-                }
-                if (data.message) {
-                    if (code === '201' && this.messages.findWhere(data)) {
-                        return;
-                    } else if (
-                        code in _converse.muc.info_messages &&
-                        this.messages.length &&
-                        this.messages.pop().get('message') === data.message
-                    ) {
-                        // XXX: very naive duplication checking
-                        return;
-                    }
-                    this.createMessage(data);
-                }
-            },
-
-
-            /**
-             * Create info messages based on a received presence or message stanza
-             * @private
-             * @method _converse.ChatRoom#createInfoMessages
-             * @param { XMLElement } stanza
-             */
-            createInfoMessages (stanza) {
-                const codes = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] status`, stanza).map(s => s.getAttribute('code'));
-                if (codes.includes('333') && codes.includes('307')) {
-                    // See: https://github.com/xsf/xeps/pull/969/files#diff-ac5113766e59219806793c1f7d967f1bR4966
-                    codes.splice(codes.indexOf('307'), 1);
-                }
-                const is_self = codes.includes('110');
-                codes.forEach(code => this.createInfoMessage(code, stanza, is_self));
-            },
-
-
-            setDisconnectionMessage (message, reason, actor) {
-                this.save({
-                    'disconnection_message': message,
-                    'disconnection_reason': reason,
-                    'disconnection_actor': actor
-                });
-                this.session.save({'connection_status': converse.ROOMSTATUS.DISCONNECTED});
-            },
-
-
-            onNicknameClash (presence) {
-                if (api.settings.get('muc_nickname_from_jid')) {
-                    const nick = presence.getAttribute('from').split('/')[1];
-                    if (nick === _converse.getDefaultMUCNickname()) {
-                        this.join(nick + '-2');
-                    } else {
-                        const del= nick.lastIndexOf("-");
-                        const num = nick.substring(del+1, nick.length);
-                        this.join(nick.substring(0, del+1) + String(Number(num)+1));
-                    }
-                } else {
-                    this.save({
-                        'nickname_validation_message': __(
-                            "The nickname you chose is reserved or "+
-                            "currently in use, please choose a different one."
-                        )
-                    });
-                    this.session.save({'connection_status': converse.ROOMSTATUS.NICKNAME_REQUIRED});
-                }
-            },
-
-
-            /**
-             * Parses a <presence> stanza with type "error" and sets the proper
-             * `connection_status` value for this {@link _converse.ChatRoom} as
-             * well as any additional output that can be shown to the user.
-             * @private
-             * @param { XMLElement } stanza - The presence stanza
-             */
-            onErrorPresence (stanza) {
-                const error = stanza.querySelector('error');
-                const error_type = error.getAttribute('type');
-                const reason = sizzle(`text[xmlns="${Strophe.NS.STANZAS}"]`, error).pop()?.textContent;
-
-                if (error_type === 'modify') {
-                    this.handleModifyError(stanza);
-                } else if (error_type === 'auth') {
-                    if (sizzle(`not-authorized[xmlns="${Strophe.NS.STANZAS}"]`, error).length) {
-                        this.save({'password_validation_message': reason || __("Password incorrect")});
-                        this.session.save({'connection_status': converse.ROOMSTATUS.PASSWORD_REQUIRED});
-                    }
-                    if (error.querySelector('registration-required')) {
-                        const message = __('You are not on the member list of this groupchat.');
-                        this.setDisconnectionMessage(message, reason);
-                    } else if (error.querySelector('forbidden')) {
-                        const message = __('You have been banned from this groupchat.');
-                        this.setDisconnectionMessage(message, reason);
-                    }
-                } else if (error_type === 'cancel') {
-                    if (error.querySelector('not-allowed')) {
-                        const message = __('You are not allowed to create new groupchats.');
-                        this.setDisconnectionMessage(message, reason);
-                    } else if (error.querySelector('not-acceptable')) {
-                        const message = __("Your nickname doesn't conform to this groupchat's policies.");
-                        this.setDisconnectionMessage(message, reason);
-                    } else if (sizzle(`gone[xmlns="${Strophe.NS.STANZAS}"]`, error).length) {
-                        const moved_jid = sizzle(`gone[xmlns="${Strophe.NS.STANZAS}"]`, error).pop()?.textContent
-                            .replace(/^xmpp:/, '')
-                            .replace(/\?join$/, '');
-                        this.save({ moved_jid, 'destroyed_reason': reason});
-                        this.session.save({'connection_status': converse.ROOMSTATUS.DESTROYED});
-                    } else if (error.querySelector('conflict')) {
-                        this.onNicknameClash(stanza);
-                    } else if (error.querySelector('item-not-found')) {
-                        const message = __("This groupchat does not (yet) exist.");
-                        this.setDisconnectionMessage(message, reason);
-                    } else if (error.querySelector('service-unavailable')) {
-                        const message = __("This groupchat has reached its maximum number of participants.");
-                        this.setDisconnectionMessage(message, reason);
-                    } else if (error.querySelector('remote-server-not-found')) {
-                        const message = __("Remote server not found");
-                        const feedback = reason ? __('The explanation given is: "%1$s".', reason) : undefined;
-                        this.setDisconnectionMessage(message, feedback);
-                    }
-                }
-            },
-
-
-            /**
-             * Handles all MUC presence stanzas.
-             * @private
-             * @method _converse.ChatRoom#onPresence
-             * @param { XMLElement } stanza
-             */
-            onPresence (stanza) {
-                if (stanza.getAttribute('type') === 'error') {
-                    return this.onErrorPresence(stanza);
-                }
-                this.createInfoMessages(stanza);
-                if (stanza.querySelector("status[code='110']")) {
-                    this.onOwnPresence(stanza);
-                    if (this.getOwnRole() !== 'none' &&
-                            this.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING) {
-                        this.session.save('connection_status', converse.ROOMSTATUS.CONNECTED);
-                    }
-                } else {
-                    this.updateOccupantsOnPresence(stanza);
-                }
-            },
-
-            /**
-             * Handles a received presence relating to the current user.
-             *
-             * For locked groupchats (which are by definition "new"), the
-             * groupchat will either be auto-configured or created instantly
-             * (with default config) or a configuration groupchat will be
-             * rendered.
-             *
-             * If the groupchat is not locked, then the groupchat will be
-             * auto-configured only if applicable and if the current
-             * user is the groupchat's owner.
-             * @private
-             * @method _converse.ChatRoom#onOwnPresence
-             * @param { XMLElement } pres - The stanza
-             */
-            onOwnPresence (stanza) {
-                if (stanza.getAttribute('type') !== 'unavailable') {
-                    const old_status = this.session.get('connection_status');
-                    if (old_status !== converse.ROOMSTATUS.ENTERED) {
-                        // Set connection_status before creating the occupant, but
-                        // only trigger afterwards, so that plugins can access the
-                        // occupant in their event handlers.
-                        this.session.save('connection_status', converse.ROOMSTATUS.ENTERED, {'silent': true});
-                        this.updateOccupantsOnPresence(stanza);
-                        this.session.trigger('change:connection_status', this.session, old_status);
-                    } else {
-                        this.updateOccupantsOnPresence(stanza);
-                    }
-                } else {
-                    this.updateOccupantsOnPresence(stanza);
-                }
-
-                if (stanza.getAttribute('type') === 'unavailable') {
-                    this.handleDisconnection(stanza);
-                } else {
-                    const locked_room = stanza.querySelector("status[code='201']");
-                    if (locked_room) {
-                        if (this.get('auto_configure')) {
-                            this.autoConfigureChatRoom().then(() => this.refreshDiscoInfo());
-                        } else if (api.settings.get('muc_instant_rooms')) {
-                            // Accept default configuration
-                            this.sendConfiguration().then(() => this.refreshDiscoInfo());
-                        } else {
-                            /**
-                             * Triggered when a new room has been created which first needs to be configured
-                             * and when `auto_configure` is set to `false`.
-                             * Used by `_converse.ChatRoomView` in order to know when to render the
-                             * configuration form for a new room.
-                             * @event _converse.ChatRoom#configurationNeeded
-                             * @example _converse.api.listen.on('configurationNeeded', () => { ... });
-                             */
-                            this.trigger('configurationNeeded');
-                            return; // We haven't yet entered the groupchat, so bail here.
-                        }
-                    } else if (!this.features.get('fetched')) {
-                        // The features for this groupchat weren't fetched.
-                        // That must mean it's a new groupchat without locking
-                        // (in which case Prosody doesn't send a 201 status),
-                        // otherwise the features would have been fetched in
-                        // the "initialize" method already.
-                        if (this.getOwnAffiliation() === 'owner' && this.get('auto_configure')) {
-                            this.autoConfigureChatRoom().then(() => this.refreshDiscoInfo());
-                        } else {
-                            this.getDiscoInfo();
-                        }
-                    }
-                }
-                this.session.save({'connection_status': converse.ROOMSTATUS.ENTERED});
-            },
-
-            /**
-             * Returns a boolean to indicate whether the current user
-             * was mentioned in a message.
-             * @private
-             * @method _converse.ChatRoom#isUserMentioned
-             * @param { String } - The text message
-             */
-            isUserMentioned (message) {
-                const nick = this.get('nick');
-                if (message.get('references').length) {
-                    const mentions = message.get('references').filter(ref => (ref.type === 'mention')).map(ref => ref.value);
-                    return mentions.includes(nick);
-                } else {
-                    return (new RegExp(`\\b${nick}\\b`)).test(message.get('message'));
-                }
-            },
-
-            /* Given a newly received message, update the unread counter if necessary.
-             * @private
-             * @method _converse.ChatRoom#handleUnreadMessage
-             * @param { XMLElement } - The <messsage> stanza
-             */
-            handleUnreadMessage (message) {
-                if (!message?.get('body')) {
-                    return
-                }
-                if (u.isNewMessage(message)) {
-                    if (this.isHidden()) {
-                        const settings = {
-                            'num_unread_general': this.get('num_unread_general') + 1
-                        };
-                        if (this.get('num_unread_general') === 0) {
-                            settings['first_unread_id'] = message.get('id');
-                        }
-                        if (this.isUserMentioned(message)) {
-                            settings.num_unread = this.get('num_unread') + 1;
-                        }
-                        this.save(settings);
-                    } else {
-                        this.sendMarkerForMessage(message);
-                    }
-                }
-            },
-
-            clearUnreadMsgCounter() {
-                if (this.get('num_unread_general') > 0 || this.get('num_unread') > 0) {
-                    this.sendMarkerForMessage(this.messages.last());
-                }
-                u.safeSave(this, {
-                    'num_unread': 0,
-                    'num_unread_general': 0
-                });
-            }
-        });
-
-
-        /**
-         * Represents a participant in a MUC
-         * @class
-         * @namespace _converse.ChatRoomOccupant
-         * @memberOf _converse
-         */
-        _converse.ChatRoomOccupant = Model.extend({
-
-            defaults: {
-                'hats': [],
-                'show': 'offline',
-                'states': []
-            },
-
-            initialize (attributes) {
-                this.set(Object.assign({'id': u.getUniqueId()}, attributes));
-                this.on('change:image_hash', this.onAvatarChanged, this);
-            },
-
-            onAvatarChanged () {
-                const hash = this.get('image_hash');
-                const vcards = [];
-                if (this.get('jid')) {
-                    vcards.push(_converse.vcards.findWhere({'jid': this.get('jid')}));
-                }
-                vcards.push(_converse.vcards.findWhere({'jid': this.get('from')}));
-
-                vcards.filter(v => v).forEach(vcard => {
-                    if (hash && vcard.get('image_hash') !== hash) {
-                        api.vcard.update(vcard, true);
-                    }
-                });
-            },
-
-            getDisplayName () {
-                return this.get('nick') || this.get('jid');
-            },
-
-            isMember () {
-                return ['admin', 'owner', 'member'].includes(this.get('affiliation'));
-            },
-
-            isModerator () {
-                return ['admin', 'owner'].includes(this.get('affiliation')) || this.get('role') === 'moderator';
-            },
-
-            isSelf () {
-                return this.get('states').includes('110');
-            }
-        });
-
-
-        /**
-         * A list of {@link _converse.ChatRoomOccupant} instances, representing participants in a MUC.
-         * @class
-         * @namespace _converse.ChatRoomOccupants
-         * @memberOf _converse
-         */
-        _converse.ChatRoomOccupants = Collection.extend({
-            model: _converse.ChatRoomOccupant,
-
-            comparator (occupant1, occupant2) {
-                const role1 = occupant1.get('role') || 'none';
-                const role2 = occupant2.get('role') || 'none';
-                if (MUC_ROLE_WEIGHTS[role1] === MUC_ROLE_WEIGHTS[role2]) {
-                    const nick1 = occupant1.getDisplayName().toLowerCase();
-                    const nick2 = occupant2.getDisplayName().toLowerCase();
-                    return nick1 < nick2 ? -1 : (nick1 > nick2? 1 : 0);
-                } else  {
-                    return MUC_ROLE_WEIGHTS[role1] < MUC_ROLE_WEIGHTS[role2] ? -1 : 1;
-                }
-            },
-
-            getAutoFetchedAffiliationLists () {
-                const affs = api.settings.get('muc_fetch_members');
-                return Array.isArray(affs) ? affs :  (affs ? ['member', 'admin', 'owner'] : []);
-            },
-
-            async fetchMembers () {
-                const affiliations = this.getAutoFetchedAffiliationLists();
-                if (affiliations.length === 0) {
-                    return;
-                }
-                const aff_lists = await Promise.all(affiliations.map(a => this.chatroom.getAffiliationList(a)));
-                const new_members = aff_lists.reduce((acc, val) => (u.isErrorObject(val) ? acc : [...val, ...acc]), []);
-                const known_affiliations = affiliations.filter(a => !u.isErrorObject(aff_lists[affiliations.indexOf(a)]));
-                const new_jids = new_members.map(m => m.jid).filter(m => m !== undefined);
-                const new_nicks = new_members.map(m => !m.jid && m.nick || undefined).filter(m => m !== undefined);
-                const removed_members = this.filter(m => {
-                        return known_affiliations.includes(m.get('affiliation')) &&
-                            !new_nicks.includes(m.get('nick')) &&
-                            !new_jids.includes(m.get('jid'));
-                    });
-
-                removed_members.forEach(occupant => {
-                    if (occupant.get('jid') === _converse.bare_jid) { return; }
-                    if (occupant.get('show') === 'offline') {
-                        occupant.destroy();
-                    } else {
-                        occupant.save('affiliation', null);
-                    }
-                });
-                new_members.forEach(attrs => {
-                    const occupant = attrs.jid ?
-                        this.findOccupant({'jid': attrs.jid}) :
-                        this.findOccupant({'nick': attrs.nick});
-                    if (occupant) {
-                        occupant.save(attrs);
-                    } else {
-                        this.create(attrs);
-                    }
-                });
-                /**
-                 * Triggered once the member lists for this MUC have been fetched and processed.
-                 * @event _converse#membersFetched
-                 * @example _converse.api.listen.on('membersFetched', () => { ... });
-                 */
-                api.trigger('membersFetched');
-            },
-
-            /**
-             * @typedef { Object} OccupantData
-             * @property { String } [jid]
-             * @property { String } [nick]
-             */
-            /**
-             * Try to find an existing occupant based on the passed in
-             * data object.
-             *
-             * If we have a JID, we use that as lookup variable,
-             * otherwise we use the nick. We don't always have both,
-             * but should have at least one or the other.
-             * @private
-             * @method _converse.ChatRoomOccupants#findOccupant
-             * @param { OccupantData } data
-             */
-            findOccupant (data) {
-                const jid = Strophe.getBareJidFromJid(data.jid);
-                return (jid && this.findWhere({ jid })) || this.findWhere({'nick': data.nick});
-            }
-        });
-
-
-        _converse.RoomsPanelModel = Model.extend({
-            defaults: function () {
-                return {
-                    'muc_domain': api.settings.get('muc_domain'),
-                    'nick': _converse.getDefaultMUCNickname()
-                }
-            },
-
-            setDomain (jid) {
-                if (!api.settings.get('locked_muc_domain')) {
-                    this.save('muc_domain', Strophe.getDomainFromJid(jid));
-                }
-            }
-        });
-
-
-        /**
-         * A direct MUC invitation to join a groupchat has been received
-         * See XEP-0249: Direct MUC invitations.
-         * @private
-         * @method _converse.ChatRoom#onDirectMUCInvitation
-         * @param { XMLElement } message - The message stanza containing the invitation.
-         */
-        _converse.onDirectMUCInvitation = async function (message) {
-            const x_el = sizzle('x[xmlns="jabber:x:conference"]', message).pop(),
-                from = Strophe.getBareJidFromJid(message.getAttribute('from')),
-                room_jid = x_el.getAttribute('jid'),
-                reason = x_el.getAttribute('reason');
-
-            let result;
-            if (api.settings.get('auto_join_on_invite')) {
-                result = true;
-            } else {
-                // Invite request might come from someone not your roster list
-                let contact = _converse.roster.get(from);
-                contact = contact ? contact.getDisplayName(): from;
-                if (!reason) {
-                    result = confirm(
-                        __("%1$s has invited you to join a groupchat: %2$s", contact, room_jid)
-                    );
-                } else {
-                    result = confirm(
-                        __('%1$s has invited you to join a groupchat: %2$s, and left the following reason: "%3$s"',
-                            contact, room_jid, reason)
-                    );
-                }
-            }
-            if (result === true) {
-                const chatroom = await openChatRoom(room_jid, {'password': x_el.getAttribute('password') });
-                if (chatroom.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED) {
-                    _converse.chatboxes.get(room_jid).rejoin();
-                }
-            }
-        };
-
-        if (api.settings.get('allow_muc_invitations')) {
-            const registerDirectInvitationHandler = function () {
-                _converse.connection.addHandler(
-                    (message) =>  {
-                        _converse.onDirectMUCInvitation(message);
-                        return true;
-                    }, 'jabber:x:conference', 'message');
-            };
-            api.listen.on('connected', registerDirectInvitationHandler);
-            api.listen.on('reconnected', registerDirectInvitationHandler);
-        }
-
-        /* Automatically join groupchats, based on the
-         * "auto_join_rooms" configuration setting, which is an array
-         * of strings (groupchat JIDs) or objects (with groupchat JID and other settings).
-         */
-        async function autoJoinRooms () {
-            await Promise.all(api.settings.get('auto_join_rooms').map(muc => {
-                if (typeof muc === 'string') {
-                    if (_converse.chatboxes.where({'jid': muc}).length) {
-                        return Promise.resolve();
-                    }
-                    return api.rooms.open(muc);
-                } else if (isObject(muc)) {
-                    return api.rooms.open(muc.jid, {...muc});
-                } else {
-                    log.error('Invalid muc criteria specified for "auto_join_rooms"');
-                    return Promise.resolve();
-                }
-            }));
-            /**
-             * Triggered once any rooms that have been configured to be automatically joined,
-             * specified via the _`auto_join_rooms` setting, have been entered.
-             * @event _converse#roomsAutoJoined
-             * @example _converse.api.listen.on('roomsAutoJoined', () => { ... });
-             * @example _converse.api.waitUntil('roomsAutoJoined').then(() => { ... });
-             */
-            api.trigger('roomsAutoJoined');
-        }
-
-        async function onWindowStateChanged (data) {
-            if (data.state === 'visible' && api.connection.connected()) {
-                const rooms = await api.rooms.get();
-                rooms.forEach(room => room.rejoinIfNecessary());
-            }
-        }
-
-        /************************ BEGIN Event Handlers ************************/
-        api.listen.on('beforeTearDown', () => {
-            const groupchats = _converse.chatboxes.where({'type': _converse.CHATROOMS_TYPE});
-            groupchats.forEach(muc => u.safeSave(muc.session, {'connection_status': converse.ROOMSTATUS.DISCONNECTED}));
-        });
-
-        api.listen.on('windowStateChanged', onWindowStateChanged);
-
-        api.listen.on('addClientFeatures', () => {
-            if (api.settings.get('allow_muc')) {
-                api.disco.own.features.add(Strophe.NS.MUC);
-            }
-            if (api.settings.get('allow_muc_invitations')) {
-                api.disco.own.features.add('jabber:x:conference'); // Invites
-            }
-        });
-        api.listen.on('chatBoxesFetched', autoJoinRooms);
-
-
-        api.listen.on('beforeResourceBinding', () => {
-            _converse.connection.addHandler(stanza => {
-                const muc_jid = Strophe.getBareJidFromJid(stanza.getAttribute('from'));
-                if (!_converse.chatboxes.get(muc_jid)) {
-                    api.waitUntil('chatBoxesFetched')
-                        .then(async () => {
-                            const muc = _converse.chatboxes.get(muc_jid);
-                            if (muc) {
-                                await muc.initialized;
-                                muc.message_handler.run(stanza);
-                            }
-                        });
-                }
-                return true;
-            }, null, 'message', 'groupchat')
-        });
-
-
-        function disconnectChatRooms () {
-            /* When disconnecting, mark all groupchats as
-             * disconnected, so that they will be properly entered again
-             * when fetched from session storage.
-             */
-            return _converse.chatboxes
-                .filter(m => (m.get('type') === _converse.CHATROOMS_TYPE))
-                .forEach(m => m.session.save({'connection_status': converse.ROOMSTATUS.DISCONNECTED}));
-        }
-        api.listen.on('disconnected', disconnectChatRooms);
-
-        api.listen.on('statusInitialized', () => {
-            window.addEventListener(_converse.unloadevent, () => {
-                const using_websocket = api.connection.isType('websocket');
-                if (using_websocket &&
-                        (!api.settings.get('enable_smacks') || !_converse.session.get('smacks_stream_id'))) {
-                    // For non-SMACKS websocket connections, or non-resumeable
-                    // connections, we disconnect all chatrooms when the page unloads.
-                    // See issue #1111
-                    disconnectChatRooms();
-                }
-            });
-        });
-        /************************ END Event Handlers ************************/
-
-
-        /************************ BEGIN API ************************/
-        converse.env.muc_utils = muc_utils;
-
-        // We extend the default converse.js API to add methods specific to MUC groupchats.
-        Object.assign(api, {
-            /**
-             * The "rooms" namespace groups methods relevant to chatrooms
-             * (aka groupchats).
-             *
-             * @namespace api.rooms
-             * @memberOf api
-             */
-            rooms: {
-                /**
-                 * Creates a new MUC chatroom (aka groupchat)
-                 *
-                 * Similar to {@link api.rooms.open}, but creates
-                 * the chatroom in the background (i.e. doesn't cause a view to open).
-                 *
-                 * @method api.rooms.create
-                 * @param {(string[]|string)} jid|jids The JID or array of
-                 *     JIDs of the chatroom(s) to create
-                 * @param {object} [attrs] attrs The room attributes
-                 * @returns {Promise} Promise which resolves with the Model representing the chat.
-                 */
-                create (jids, attrs={}) {
-                    attrs = typeof attrs === 'string' ? {'nick': attrs} : (attrs || {});
-                    if (!attrs.nick && api.settings.get('muc_nickname_from_jid')) {
-                        attrs.nick = Strophe.getNodeFromJid(_converse.bare_jid);
-                    }
-                    if (jids === undefined) {
-                        throw new TypeError('rooms.create: You need to provide at least one JID');
-                    } else if (typeof jids === 'string') {
-                        return api.rooms.get(u.getJIDFromURI(jids), attrs, true);
-                    }
-                    return jids.map(jid => api.rooms.get(u.getJIDFromURI(jid), attrs, true));
-                },
-
-                /**
-                 * Opens a MUC chatroom (aka groupchat)
-                 *
-                 * Similar to {@link api.chats.open}, but for groupchats.
-                 *
-                 * @method api.rooms.open
-                 * @param {string} jid The room JID or JIDs (if not specified, all
-                 *     currently open rooms will be returned).
-                 * @param {string} attrs A map  containing any extra room attributes.
-                 * @param {string} [attrs.nick] The current user's nickname for the MUC
-                 * @param {boolean} [attrs.auto_configure] A boolean, indicating
-                 *     whether the room should be configured automatically or not.
-                 *     If set to `true`, then it makes sense to pass in configuration settings.
-                 * @param {object} [attrs.roomconfig] A map of configuration settings to be used when the room gets
-                 *     configured automatically. Currently it doesn't make sense to specify
-                 *     `roomconfig` values if `auto_configure` is set to `false`.
-                 *     For a list of configuration values that can be passed in, refer to these values
-                 *     in the [XEP-0045 MUC specification](https://xmpp.org/extensions/xep-0045.html#registrar-formtype-owner).
-                 *     The values should be named without the `muc#roomconfig_` prefix.
-                 * @param {boolean} [attrs.minimized] A boolean, indicating whether the room should be opened minimized or not.
-                 * @param {boolean} [attrs.bring_to_foreground] A boolean indicating whether the room should be
-                 *     brought to the foreground and therefore replace the currently shown chat.
-                 *     If there is no chat currently open, then this option is ineffective.
-                 * @param {Boolean} [force=false] - By default, a minimized
-                 *   room won't be maximized (in `overlayed` view mode) and in
-                 *   `fullscreen` view mode a newly opened room won't replace
-                 *   another chat already in the foreground.
-                 *   Set `force` to `true` if you want to force the room to be
-                 *   maximized or shown.
-                 * @returns {Promise} Promise which resolves with the Model representing the chat.
-                 *
-                 * @example
-                 * this.api.rooms.open('group@muc.example.com')
-                 *
-                 * @example
-                 * // To return an array of rooms, provide an array of room JIDs:
-                 * api.rooms.open(['group1@muc.example.com', 'group2@muc.example.com'])
-                 *
-                 * @example
-                 * // To setup a custom nickname when joining the room, provide the optional nick argument:
-                 * api.rooms.open('group@muc.example.com', {'nick': 'mycustomnick'})
-                 *
-                 * @example
-                 * // For example, opening a room with a specific default configuration:
-                 * api.rooms.open(
-                 *     'myroom@conference.example.org',
-                 *     { 'nick': 'coolguy69',
-                 *       'auto_configure': true,
-                 *       'roomconfig': {
-                 *           'changesubject': false,
-                 *           'membersonly': true,
-                 *           'persistentroom': true,
-                 *           'publicroom': true,
-                 *           'roomdesc': 'Comfy room for hanging out',
-                 *           'whois': 'anyone'
-                 *       }
-                 *     }
-                 * );
-                 */
-                async open (jids, attrs={}, force=false) {
-                    await api.waitUntil('chatBoxesFetched');
-                    if (jids === undefined) {
-                        const err_msg = 'rooms.open: You need to provide at least one JID';
-                        log.error(err_msg);
-                        throw(new TypeError(err_msg));
-                    } else if (typeof jids === 'string') {
-                        const room = await api.rooms.get(jids, attrs, true);
-                        room && room.maybeShow(force);
-                        return room;
-                    } else {
-                        const rooms = await Promise.all(jids.map(jid => api.rooms.get(jid, attrs, true)));
-                        rooms.forEach(r => r.maybeShow(force));
-                        return rooms;
-                    }
-                },
-
-                /**
-                 * Fetches the object representing a MUC chatroom (aka groupchat)
-                 *
-                 * @method api.rooms.get
-                 * @param {string} [jid] The room JID (if not specified, all rooms will be returned).
-                 * @param {object} [attrs] A map containing any extra room attributes For example, if you want
-                 *     to specify a nickname and password, use `{'nick': 'bloodninja', 'password': 'secret'}`.
-                 * @param {boolean} create A boolean indicating whether the room should be created
-                 *     if not found (default: `false`)
-                 * @returns { Promise<_converse.ChatRoom> }
-                 * @example
-                 * api.waitUntil('roomsAutoJoined').then(() => {
-                 *     const create_if_not_found = true;
-                 *     api.rooms.get(
-                 *         'group@muc.example.com',
-                 *         {'nick': 'dread-pirate-roberts'},
-                 *         create_if_not_found
-                 *     )
-                 * });
-                 */
-                async get (jids, attrs={}, create=false) {
-                    async function _get (jid) {
-                        jid = u.getJIDFromURI(jid);
-                        let model = await api.chatboxes.get(jid);
-                        if (!model && create) {
-                            model = await api.chatboxes.create(jid, attrs, _converse.ChatRoom);
-                        } else {
-                            model = (model && model.get('type') === _converse.CHATROOMS_TYPE) ? model : null;
-                            if (model && Object.keys(attrs).length) {
-                                model.save(attrs);
-                            }
-                        }
-                        return model;
-                    }
-                    if (jids === undefined) {
-                        const chats = await api.chatboxes.get();
-                        return chats.filter(c => (c.get('type') === _converse.CHATROOMS_TYPE));
-                    } else if (typeof jids === 'string') {
-                        return _get(jids);
-                    }
-                    return Promise.all(jids.map(jid => _get(jid)));
-                }
-            }
-        });
-        /************************ END API ************************/
-    }
-});

+ 160 - 0
src/headless/plugins/muc/api.js

@@ -0,0 +1,160 @@
+import log from '../../log';
+import u from '../../utils/form';
+import { Strophe } from 'strophe.js/src/strophe';
+import { _converse, api } from '../../core.js';
+
+
+export default {
+    /**
+     * The "rooms" namespace groups methods relevant to chatrooms
+     * (aka groupchats).
+     *
+     * @namespace api.rooms
+     * @memberOf api
+     */
+    rooms: {
+        /**
+         * Creates a new MUC chatroom (aka groupchat)
+         *
+         * Similar to {@link api.rooms.open}, but creates
+         * the chatroom in the background (i.e. doesn't cause a view to open).
+         *
+         * @method api.rooms.create
+         * @param {(string[]|string)} jid|jids The JID or array of
+         *     JIDs of the chatroom(s) to create
+         * @param {object} [attrs] attrs The room attributes
+         * @returns {Promise} Promise which resolves with the Model representing the chat.
+         */
+        create (jids, attrs = {}) {
+            attrs = typeof attrs === 'string' ? { 'nick': attrs } : attrs || {};
+            if (!attrs.nick && api.settings.get('muc_nickname_from_jid')) {
+                attrs.nick = Strophe.getNodeFromJid(_converse.bare_jid);
+            }
+            if (jids === undefined) {
+                throw new TypeError('rooms.create: You need to provide at least one JID');
+            } else if (typeof jids === 'string') {
+                return api.rooms.get(u.getJIDFromURI(jids), attrs, true);
+            }
+            return jids.map(jid => api.rooms.get(u.getJIDFromURI(jid), attrs, true));
+        },
+
+        /**
+         * Opens a MUC chatroom (aka groupchat)
+         *
+         * Similar to {@link api.chats.open}, but for groupchats.
+         *
+         * @method api.rooms.open
+         * @param {string} jid The room JID or JIDs (if not specified, all
+         *     currently open rooms will be returned).
+         * @param {string} attrs A map  containing any extra room attributes.
+         * @param {string} [attrs.nick] The current user's nickname for the MUC
+         * @param {boolean} [attrs.auto_configure] A boolean, indicating
+         *     whether the room should be configured automatically or not.
+         *     If set to `true`, then it makes sense to pass in configuration settings.
+         * @param {object} [attrs.roomconfig] A map of configuration settings to be used when the room gets
+         *     configured automatically. Currently it doesn't make sense to specify
+         *     `roomconfig` values if `auto_configure` is set to `false`.
+         *     For a list of configuration values that can be passed in, refer to these values
+         *     in the [XEP-0045 MUC specification](https://xmpp.org/extensions/xep-0045.html#registrar-formtype-owner).
+         *     The values should be named without the `muc#roomconfig_` prefix.
+         * @param {boolean} [attrs.minimized] A boolean, indicating whether the room should be opened minimized or not.
+         * @param {boolean} [attrs.bring_to_foreground] A boolean indicating whether the room should be
+         *     brought to the foreground and therefore replace the currently shown chat.
+         *     If there is no chat currently open, then this option is ineffective.
+         * @param {Boolean} [force=false] - By default, a minimized
+         *   room won't be maximized (in `overlayed` view mode) and in
+         *   `fullscreen` view mode a newly opened room won't replace
+         *   another chat already in the foreground.
+         *   Set `force` to `true` if you want to force the room to be
+         *   maximized or shown.
+         * @returns {Promise} Promise which resolves with the Model representing the chat.
+         *
+         * @example
+         * this.api.rooms.open('group@muc.example.com')
+         *
+         * @example
+         * // To return an array of rooms, provide an array of room JIDs:
+         * api.rooms.open(['group1@muc.example.com', 'group2@muc.example.com'])
+         *
+         * @example
+         * // To setup a custom nickname when joining the room, provide the optional nick argument:
+         * api.rooms.open('group@muc.example.com', {'nick': 'mycustomnick'})
+         *
+         * @example
+         * // For example, opening a room with a specific default configuration:
+         * api.rooms.open(
+         *     'myroom@conference.example.org',
+         *     { 'nick': 'coolguy69',
+         *       'auto_configure': true,
+         *       'roomconfig': {
+         *           'changesubject': false,
+         *           'membersonly': true,
+         *           'persistentroom': true,
+         *           'publicroom': true,
+         *           'roomdesc': 'Comfy room for hanging out',
+         *           'whois': 'anyone'
+         *       }
+         *     }
+         * );
+         */
+        async open (jids, attrs = {}, force = false) {
+            await api.waitUntil('chatBoxesFetched');
+            if (jids === undefined) {
+                const err_msg = 'rooms.open: You need to provide at least one JID';
+                log.error(err_msg);
+                throw new TypeError(err_msg);
+            } else if (typeof jids === 'string') {
+                const room = await api.rooms.get(jids, attrs, true);
+                room && room.maybeShow(force);
+                return room;
+            } else {
+                const rooms = await Promise.all(jids.map(jid => api.rooms.get(jid, attrs, true)));
+                rooms.forEach(r => r.maybeShow(force));
+                return rooms;
+            }
+        },
+
+        /**
+         * Fetches the object representing a MUC chatroom (aka groupchat)
+         *
+         * @method api.rooms.get
+         * @param {string} [jid] The room JID (if not specified, all rooms will be returned).
+         * @param {object} [attrs] A map containing any extra room attributes For example, if you want
+         *     to specify a nickname and password, use `{'nick': 'bloodninja', 'password': 'secret'}`.
+         * @param {boolean} create A boolean indicating whether the room should be created
+         *     if not found (default: `false`)
+         * @returns { Promise<_converse.ChatRoom> }
+         * @example
+         * api.waitUntil('roomsAutoJoined').then(() => {
+         *     const create_if_not_found = true;
+         *     api.rooms.get(
+         *         'group@muc.example.com',
+         *         {'nick': 'dread-pirate-roberts'},
+         *         create_if_not_found
+         *     )
+         * });
+         */
+        async get (jids, attrs = {}, create = false) {
+            async function _get (jid) {
+                jid = u.getJIDFromURI(jid);
+                let model = await api.chatboxes.get(jid);
+                if (!model && create) {
+                    model = await api.chatboxes.create(jid, attrs, _converse.ChatRoom);
+                } else {
+                    model = model && model.get('type') === _converse.CHATROOMS_TYPE ? model : null;
+                    if (model && Object.keys(attrs).length) {
+                        model.save(attrs);
+                    }
+                }
+                return model;
+            }
+            if (jids === undefined) {
+                const chats = await api.chatboxes.get();
+                return chats.filter(c => c.get('type') === _converse.CHATROOMS_TYPE);
+            } else if (typeof jids === 'string') {
+                return _get(jids);
+            }
+            return Promise.all(jids.map(jid => _get(jid)));
+        }
+    }
+}

+ 488 - 0
src/headless/plugins/muc/index.js

@@ -0,0 +1,488 @@
+/**
+ * @module converse-muc
+ * @copyright The Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ * @description Implements the non-view logic for XEP-0045 Multi-User Chat
+ */
+import '../chat/index.js';
+import '../disco';
+import '../emoji/index.js';
+import ChatRoomMessageMixin from './message.js';
+import ChatRoomMixin from './muc.js';
+import ChatRoomOccupant from './occupant.js';
+import ChatRoomOccupants from './occupants.js';
+import log from '../../log';
+import muc_api from './api.js';
+import muc_utils from '../../utils/muc';
+import u from '../../utils/form';
+import { Collection } from '@converse/skeletor/src/collection';
+import { Model } from '@converse/skeletor/src/model.js';
+import { _converse, api, converse } from '../../core.js';
+import { isObject } from 'lodash-es';
+
+export const ROLES = ['moderator', 'participant', 'visitor'];
+export const AFFILIATIONS = ['owner', 'admin', 'member', 'outcast', 'none'];
+
+converse.AFFILIATION_CHANGES = {
+    OWNER: 'owner',
+    ADMIN: 'admin',
+    MEMBER: 'member',
+    EXADMIN: 'exadmin',
+    EXOWNER: 'exowner',
+    EXOUTCAST: 'exoutcast',
+    EXMEMBER: 'exmember'
+};
+converse.AFFILIATION_CHANGES_LIST = Object.values(converse.AFFILIATION_CHANGES);
+converse.MUC_TRAFFIC_STATES = { ENTERED: 'entered', EXITED: 'exited' };
+converse.MUC_TRAFFIC_STATES_LIST = Object.values(converse.MUC_TRAFFIC_STATES);
+converse.MUC_ROLE_CHANGES = { OP: 'op', DEOP: 'deop', VOICE: 'voice', MUTE: 'mute' };
+converse.MUC_ROLE_CHANGES_LIST = Object.values(converse.MUC_ROLE_CHANGES);
+
+converse.MUC_INFO_CODES = {
+    'visibility_changes': ['100', '102', '103', '172', '173', '174'],
+    'self': ['110'],
+    'non_privacy_changes': ['104', '201'],
+    'muc_logging_changes': ['170', '171'],
+    'nickname_changes': ['210', '303'],
+    'disconnect_messages': ['301', '307', '321', '322', '332', '333'],
+    'affiliation_changes': [...converse.AFFILIATION_CHANGES_LIST],
+    'join_leave_events': [...converse.MUC_TRAFFIC_STATES_LIST],
+    'role_changes': [...converse.MUC_ROLE_CHANGES_LIST]
+};
+
+const { Strophe, sizzle } = converse.env;
+
+// Add Strophe Namespaces
+Strophe.addNamespace('MUC_ADMIN', Strophe.NS.MUC + '#admin');
+Strophe.addNamespace('MUC_OWNER', Strophe.NS.MUC + '#owner');
+Strophe.addNamespace('MUC_REGISTER', 'jabber:iq:register');
+Strophe.addNamespace('MUC_ROOMCONF', Strophe.NS.MUC + '#roomconfig');
+Strophe.addNamespace('MUC_USER', Strophe.NS.MUC + '#user');
+Strophe.addNamespace('MUC_HATS', 'xmpp:prosody.im/protocol/hats:1');
+
+converse.MUC_NICK_CHANGED_CODE = '303';
+
+converse.ROOM_FEATURES = [
+    'passwordprotected',
+    'unsecured',
+    'hidden',
+    'publicroom',
+    'membersonly',
+    'open',
+    'persistent',
+    'temporary',
+    'nonanonymous',
+    'semianonymous',
+    'moderated',
+    'unmoderated',
+    'mam_enabled'
+];
+
+// No longer used in code, but useful as reference.
+//
+// const ROOM_FEATURES_MAP = {
+//     'passwordprotected': 'unsecured',
+//     'unsecured': 'passwordprotected',
+//     'hidden': 'publicroom',
+//     'publicroom': 'hidden',
+//     'membersonly': 'open',
+//     'open': 'membersonly',
+//     'persistent': 'temporary',
+//     'temporary': 'persistent',
+//     'nonanonymous': 'semianonymous',
+//     'semianonymous': 'nonanonymous',
+//     'moderated': 'unmoderated',
+//     'unmoderated': 'moderated'
+// };
+
+converse.ROOMSTATUS = {
+    CONNECTED: 0,
+    CONNECTING: 1,
+    NICKNAME_REQUIRED: 2,
+    PASSWORD_REQUIRED: 3,
+    DISCONNECTED: 4,
+    ENTERED: 5,
+    DESTROYED: 6
+};
+
+
+function disconnectChatRooms () {
+    /* When disconnecting, mark all groupchats as
+     * disconnected, so that they will be properly entered again
+     * when fetched from session storage.
+     */
+    return _converse.chatboxes
+        .filter(m => m.get('type') === _converse.CHATROOMS_TYPE)
+        .forEach(m => m.session.save({ 'connection_status': converse.ROOMSTATUS.DISCONNECTED }));
+}
+
+async function onWindowStateChanged (data) {
+    if (data.state === 'visible' && api.connection.connected()) {
+        const rooms = await api.rooms.get();
+        rooms.forEach(room => room.rejoinIfNecessary());
+    }
+}
+
+async function routeToRoom (jid) {
+    if (!u.isValidMUCJID(jid)) {
+        return log.warn(`invalid jid "${jid}" provided in url fragment`);
+    }
+    await api.waitUntil('roomsAutoJoined');
+    if (api.settings.get('allow_bookmarks')) {
+        await api.waitUntil('bookmarksInitialized');
+    }
+    api.rooms.open(jid);
+}
+
+/* Opens a groupchat, making sure that certain attributes
+ * are correct, for example that the "type" is set to
+ * "chatroom".
+ */
+async function openChatRoom (jid, settings) {
+    settings.type = _converse.CHATROOMS_TYPE;
+    settings.id = jid;
+    const chatbox = await api.rooms.get(jid, settings, true);
+    chatbox.maybeShow(true);
+    return chatbox;
+}
+
+/* Automatically join groupchats, based on the
+ * "auto_join_rooms" configuration setting, which is an array
+ * of strings (groupchat JIDs) or objects (with groupchat JID and other settings).
+ */
+async function autoJoinRooms () {
+    await Promise.all(
+        api.settings.get('auto_join_rooms').map(muc => {
+            if (typeof muc === 'string') {
+                if (_converse.chatboxes.where({ 'jid': muc }).length) {
+                    return Promise.resolve();
+                }
+                return api.rooms.open(muc);
+            } else if (isObject(muc)) {
+                return api.rooms.open(muc.jid, { ...muc });
+            } else {
+                log.error('Invalid muc criteria specified for "auto_join_rooms"');
+                return Promise.resolve();
+            }
+        })
+    );
+    /**
+     * Triggered once any rooms that have been configured to be automatically joined,
+     * specified via the _`auto_join_rooms` setting, have been entered.
+     * @event _converse#roomsAutoJoined
+     * @example _converse.api.listen.on('roomsAutoJoined', () => { ... });
+     * @example _converse.api.waitUntil('roomsAutoJoined').then(() => { ... });
+     */
+    api.trigger('roomsAutoJoined');
+}
+
+
+converse.plugins.add('converse-muc', {
+    /* Optional dependencies are other plugins which might be
+     * overridden or relied upon, and therefore need to be loaded before
+     * this plugin. They are called "optional" because they might not be
+     * available, in which case any overrides applicable to them will be
+     * ignored.
+     *
+     * It's possible however to make optional dependencies non-optional.
+     * If the setting "strict_plugin_dependencies" is set to true,
+     * an error will be raised if the plugin is not found.
+     *
+     * NB: These plugins need to have already been loaded via require.js.
+     */
+    dependencies: ['converse-chatboxes', 'converse-chat', 'converse-disco', 'converse-controlbox'],
+
+    overrides: {
+        ChatBoxes: {
+            model (attrs, options) {
+                const { _converse } = this.__super__;
+                if (attrs && attrs.type == _converse.CHATROOMS_TYPE) {
+                    return new _converse.ChatRoom(attrs, options);
+                } else {
+                    return this.__super__.model.apply(this, arguments);
+                }
+            }
+        }
+    },
+
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { __, ___ } = _converse;
+
+        // Configuration values for this plugin
+        // ====================================
+        // Refer to docs/source/configuration.rst for explanations of these
+        // configuration settings.
+        api.settings.extend({
+            'allow_muc': true,
+            'allow_muc_invitations': true,
+            'auto_join_on_invite': false,
+            'auto_join_rooms': [],
+            'auto_register_muc_nickname': false,
+            'hide_muc_participants': false,
+            'locked_muc_domain': false,
+            'muc_domain': undefined,
+            'muc_fetch_members': true,
+            'muc_history_max_stanzas': undefined,
+            'muc_instant_rooms': true,
+            'muc_nickname_from_jid': false,
+            'muc_send_probes': false,
+            'muc_show_info_messages': [
+                ...converse.MUC_INFO_CODES.visibility_changes,
+                ...converse.MUC_INFO_CODES.self,
+                ...converse.MUC_INFO_CODES.non_privacy_changes,
+                ...converse.MUC_INFO_CODES.muc_logging_changes,
+                ...converse.MUC_INFO_CODES.nickname_changes,
+                ...converse.MUC_INFO_CODES.disconnect_messages,
+                ...converse.MUC_INFO_CODES.affiliation_changes,
+                ...converse.MUC_INFO_CODES.join_leave_events,
+                ...converse.MUC_INFO_CODES.role_changes
+            ],
+            'muc_show_logs_before_join': false
+        });
+        api.promises.add(['roomsAutoJoined']);
+
+        if (api.settings.get('locked_muc_domain') && typeof api.settings.get('muc_domain') !== 'string') {
+            throw new Error(
+                'Config Error: it makes no sense to set locked_muc_domain ' + 'to true when muc_domain is not set'
+            );
+        }
+
+        converse.env.muc_utils = muc_utils;
+        Object.assign(api, muc_api);
+
+        /* https://xmpp.org/extensions/xep-0045.html
+         * ----------------------------------------
+         * 100 message      Entering a groupchat         Inform user that any occupant is allowed to see the user's full JID
+         * 101 message (out of band)                     Affiliation change  Inform user that his or her affiliation changed while not in the groupchat
+         * 102 message      Configuration change         Inform occupants that groupchat now shows unavailable members
+         * 103 message      Configuration change         Inform occupants that groupchat now does not show unavailable members
+         * 104 message      Configuration change         Inform occupants that a non-privacy-related groupchat configuration change has occurred
+         * 110 presence     Any groupchat presence       Inform user that presence refers to one of its own groupchat occupants
+         * 170 message or initial presence               Configuration change    Inform occupants that groupchat logging is now enabled
+         * 171 message      Configuration change         Inform occupants that groupchat logging is now disabled
+         * 172 message      Configuration change         Inform occupants that the groupchat is now non-anonymous
+         * 173 message      Configuration change         Inform occupants that the groupchat is now semi-anonymous
+         * 174 message      Configuration change         Inform occupants that the groupchat is now fully-anonymous
+         * 201 presence     Entering a groupchat         Inform user that a new groupchat has been created
+         * 210 presence     Entering a groupchat         Inform user that the service has assigned or modified the occupant's roomnick
+         * 301 presence     Removal from groupchat       Inform user that he or she has been banned from the groupchat
+         * 303 presence     Exiting a groupchat          Inform all occupants of new groupchat nickname
+         * 307 presence     Removal from groupchat       Inform user that he or she has been kicked from the groupchat
+         * 321 presence     Removal from groupchat       Inform user that he or she is being removed from the groupchat because of an affiliation change
+         * 322 presence     Removal from groupchat       Inform user that he or she is being removed from the groupchat because the groupchat has been changed to members-only and the user is not a member
+         * 332 presence     Removal from groupchat       Inform user that he or she is being removed from the groupchat because of a system shutdown
+         */
+        _converse.muc = {
+            info_messages: {
+                100: __('This groupchat is not anonymous'),
+                102: __('This groupchat now shows unavailable members'),
+                103: __('This groupchat does not show unavailable members'),
+                104: __('The groupchat configuration has changed'),
+                170: __('Groupchat logging is now enabled'),
+                171: __('Groupchat logging is now disabled'),
+                172: __('This groupchat is now no longer anonymous'),
+                173: __('This groupchat is now semi-anonymous'),
+                174: __('This groupchat is now fully-anonymous'),
+                201: __('A new groupchat has been created')
+            },
+
+            new_nickname_messages: {
+                // XXX: Note the triple underscore function and not double underscore.
+                210: ___('Your nickname has been automatically set to %1$s'),
+                303: ___('Your nickname has been changed to %1$s')
+            },
+
+            disconnect_messages: {
+                301: __('You have been banned from this groupchat'),
+                333: __('You have exited this groupchat due to a technical problem'),
+                307: __('You have been kicked from this groupchat'),
+                321: __('You have been removed from this groupchat because of an affiliation change'),
+                322: __(
+                    "You have been removed from this groupchat because the groupchat has changed to members-only and you're not a member"
+                ),
+                332: __('You have been removed from this groupchat because the service hosting it is being shut down')
+            }
+        };
+
+        /**
+         * Determines info message visibility based on
+         * muc_show_info_messages configuration setting
+         * @param {*} code
+         * @memberOf _converse
+         */
+        _converse.isInfoVisible = function (code) {
+            const info_messages = api.settings.get('muc_show_info_messages');
+            if (info_messages.includes(code)) {
+                return true;
+            }
+            return false;
+        };
+
+        _converse.router.route('converse/room?jid=:jid', routeToRoom);
+
+        _converse.ChatRoom = _converse.ChatBox.extend(ChatRoomMixin);
+        _converse.ChatRoomMessage = _converse.Message.extend(ChatRoomMessageMixin);
+        _converse.ChatRoomOccupants = ChatRoomOccupants;
+        _converse.ChatRoomOccupant = ChatRoomOccupant;
+
+        _converse.getDefaultMUCNickname = function () {
+            // XXX: if anything changes here, update the docs for the
+            // locked_muc_nickname setting.
+            if (!_converse.xmppstatus) {
+                throw new Error(
+                    "Can't call _converse.getDefaultMUCNickname before the statusInitialized has been fired."
+                );
+            }
+            const nick = _converse.xmppstatus.getNickname();
+            if (nick) {
+                return nick;
+            } else if (api.settings.get('muc_nickname_from_jid')) {
+                return Strophe.unescapeNode(Strophe.getNodeFromJid(_converse.bare_jid));
+            }
+        };
+
+        /**
+         * Collection which stores MUC messages
+         * @class
+         * @namespace _converse.ChatRoomMessages
+         * @memberOf _converse
+         */
+        _converse.ChatRoomMessages = Collection.extend({
+            model: _converse.ChatRoomMessage,
+            comparator: 'time'
+        });
+
+
+        _converse.RoomsPanelModel = Model.extend({
+            defaults: function () {
+                return {
+                    'muc_domain': api.settings.get('muc_domain'),
+                    'nick': _converse.getDefaultMUCNickname()
+                };
+            },
+
+            setDomain (jid) {
+                if (!api.settings.get('locked_muc_domain')) {
+                    this.save('muc_domain', Strophe.getDomainFromJid(jid));
+                }
+            }
+        });
+
+        /**
+         * A direct MUC invitation to join a groupchat has been received
+         * See XEP-0249: Direct MUC invitations.
+         * @private
+         * @method _converse.ChatRoom#onDirectMUCInvitation
+         * @param { XMLElement } message - The message stanza containing the invitation.
+         */
+        _converse.onDirectMUCInvitation = async function (message) {
+            const x_el = sizzle('x[xmlns="jabber:x:conference"]', message).pop(),
+                from = Strophe.getBareJidFromJid(message.getAttribute('from')),
+                room_jid = x_el.getAttribute('jid'),
+                reason = x_el.getAttribute('reason');
+
+            let result;
+            if (api.settings.get('auto_join_on_invite')) {
+                result = true;
+            } else {
+                // Invite request might come from someone not your roster list
+                let contact = _converse.roster.get(from);
+                contact = contact ? contact.getDisplayName() : from;
+                if (!reason) {
+                    result = confirm(__('%1$s has invited you to join a groupchat: %2$s', contact, room_jid));
+                } else {
+                    result = confirm(
+                        __(
+                            '%1$s has invited you to join a groupchat: %2$s, and left the following reason: "%3$s"',
+                            contact,
+                            room_jid,
+                            reason
+                        )
+                    );
+                }
+            }
+            if (result === true) {
+                const chatroom = await openChatRoom(room_jid, { 'password': x_el.getAttribute('password') });
+                if (chatroom.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED) {
+                    _converse.chatboxes.get(room_jid).rejoin();
+                }
+            }
+        };
+
+        if (api.settings.get('allow_muc_invitations')) {
+            const registerDirectInvitationHandler = function () {
+                _converse.connection.addHandler(
+                    message => {
+                        _converse.onDirectMUCInvitation(message);
+                        return true;
+                    },
+                    'jabber:x:conference',
+                    'message'
+                );
+            };
+            api.listen.on('connected', registerDirectInvitationHandler);
+            api.listen.on('reconnected', registerDirectInvitationHandler);
+        }
+
+        /************************ BEGIN Event Handlers ************************/
+        api.listen.on('beforeTearDown', () => {
+            const groupchats = _converse.chatboxes.where({ 'type': _converse.CHATROOMS_TYPE });
+            groupchats.forEach(muc =>
+                u.safeSave(muc.session, { 'connection_status': converse.ROOMSTATUS.DISCONNECTED })
+            );
+        });
+
+        api.listen.on('windowStateChanged', onWindowStateChanged);
+
+        api.listen.on('addClientFeatures', () => {
+            if (api.settings.get('allow_muc')) {
+                api.disco.own.features.add(Strophe.NS.MUC);
+            }
+            if (api.settings.get('allow_muc_invitations')) {
+                api.disco.own.features.add('jabber:x:conference'); // Invites
+            }
+        });
+        api.listen.on('chatBoxesFetched', autoJoinRooms);
+
+        api.listen.on('beforeResourceBinding', () => {
+            _converse.connection.addHandler(
+                stanza => {
+                    const muc_jid = Strophe.getBareJidFromJid(stanza.getAttribute('from'));
+                    if (!_converse.chatboxes.get(muc_jid)) {
+                        api.waitUntil('chatBoxesFetched').then(async () => {
+                            const muc = _converse.chatboxes.get(muc_jid);
+                            if (muc) {
+                                await muc.initialized;
+                                muc.message_handler.run(stanza);
+                            }
+                        });
+                    }
+                    return true;
+                },
+                null,
+                'message',
+                'groupchat'
+            );
+        });
+
+        api.listen.on('disconnected', disconnectChatRooms);
+
+        api.listen.on('statusInitialized', () => {
+            window.addEventListener(_converse.unloadevent, () => {
+                const using_websocket = api.connection.isType('websocket');
+                if (
+                    using_websocket &&
+                    (!api.settings.get('enable_smacks') || !_converse.session.get('smacks_stream_id'))
+                ) {
+                    // For non-SMACKS websocket connections, or non-resumeable
+                    // connections, we disconnect all chatrooms when the page unloads.
+                    // See issue #1111
+                    disconnectChatRooms();
+                }
+            });
+        });
+    }
+});

+ 100 - 0
src/headless/plugins/muc/message.js

@@ -0,0 +1,100 @@
+import log from '../../log';
+import { Strophe } from 'strophe.js/src/strophe';
+import { _converse, api } from '../../core.js';
+
+/**
+ * Mixing that turns a Message model into a ChatRoomMessage model.
+ * @class
+ * @namespace _converse.ChatRoomMessage
+ * @memberOf _converse
+ */
+const ChatRoomMessageMixin = {
+    initialize () {
+        if (!this.checkValidity()) {
+            return;
+        }
+        if (this.get('file')) {
+            this.on('change:put', this.uploadFile, this);
+        }
+        if (!this.setTimerForEphemeralMessage()) {
+            this.setOccupant();
+        }
+        /**
+         * Triggered once a {@link _converse.ChatRoomMessageInitialized} has been created and initialized.
+         * @event _converse#chatRoomMessageInitialized
+         * @type { _converse.ChatRoomMessages}
+         * @example _converse.api.listen.on('chatRoomMessageInitialized', model => { ... });
+         */
+        api.trigger('chatRoomMessageInitialized', this);
+    },
+
+    /**
+     * Determines whether this messsage may be moderated,
+     * based on configuration settings and server support.
+     * @async
+     * @private
+     * @method _converse.ChatRoomMessages#mayBeModerated
+     * @returns { Boolean }
+     */
+    mayBeModerated () {
+        return (
+            ['all', 'moderator'].includes(api.settings.get('allow_message_retraction')) &&
+            this.collection.chatbox.canModerateMessages()
+        );
+    },
+
+    checkValidity () {
+        const result = _converse.Message.prototype.checkValidity.call(this);
+        !result && this.collection.chatbox.debouncedRejoin();
+        return result;
+    },
+
+    onOccupantRemoved () {
+        this.stopListening(this.occupant);
+        delete this.occupant;
+        const chatbox = this?.collection?.chatbox;
+        if (!chatbox) {
+            return log.error(`Could not get collection.chatbox for message: ${JSON.stringify(this.toJSON())}`);
+        }
+        this.listenTo(chatbox.occupants, 'add', this.onOccupantAdded);
+    },
+
+    onOccupantAdded (occupant) {
+        if (occupant.get('nick') === Strophe.getResourceFromJid(this.get('from'))) {
+            this.occupant = occupant;
+            this.trigger('occupantAdded');
+            this.listenTo(this.occupant, 'destroy', this.onOccupantRemoved);
+            const chatbox = this?.collection?.chatbox;
+            if (!chatbox) {
+                return log.error(`Could not get collection.chatbox for message: ${JSON.stringify(this.toJSON())}`);
+            }
+            this.stopListening(chatbox.occupants, 'add', this.onOccupantAdded);
+        }
+    },
+
+    setOccupant () {
+        if (this.get('type') !== 'groupchat') {
+            return;
+        }
+        const chatbox = this?.collection?.chatbox;
+        if (!chatbox) {
+            return log.error(`Could not get collection.chatbox for message: ${JSON.stringify(this.toJSON())}`);
+        }
+        const nick = Strophe.getResourceFromJid(this.get('from'));
+        this.occupant = chatbox.occupants.findWhere({ nick });
+
+        if (!this.occupant && api.settings.get('muc_send_probes')) {
+            this.occupant = chatbox.occupants.create({ nick, 'type': 'unavailable' });
+            const jid = `${chatbox.get('jid')}/${nick}`;
+            api.user.presence.send('probe', jid);
+        }
+
+        if (this.occupant) {
+            this.listenTo(this.occupant, 'destroy', this.onOccupantRemoved);
+        } else {
+            this.listenTo(chatbox.occupants, 'add', this.onOccupantAdded);
+        }
+    }
+};
+
+export default ChatRoomMessageMixin;

+ 2223 - 0
src/headless/plugins/muc/muc.js

@@ -0,0 +1,2223 @@
+import log from '../../log';
+import { Model } from '@converse/skeletor/src/model.js';
+import muc_utils from '../../utils/muc';
+import p from '../../utils/parse-helpers';
+import sizzle from 'sizzle';
+import st from '../../utils/stanza';
+import u from '../../utils/form';
+import { Strophe, $build, $iq, $msg, $pres } from 'strophe.js/src/strophe';
+import { _converse, api, converse } from '../../core.js';
+import { debounce, intersection, invoke, isElement, pick, zipObject } from 'lodash-es';
+
+const ACTION_INFO_CODES = ['301', '303', '333', '307', '321', '322'];
+
+const MUCSession = Model.extend({
+    defaults () {
+        return {
+            'connection_status': converse.ROOMSTATUS.DISCONNECTED
+        };
+    }
+});
+
+/**
+ * Represents an open/ongoing groupchat conversation.
+ * @mixin
+ * @namespace _converse.ChatRoom
+ * @memberOf _converse
+ */
+const ChatRoomMixin = {
+    defaults () {
+        return {
+            // For group chats, we distinguish between generally unread
+            // messages and those ones that specifically mention the
+            // user.
+            //
+            // To keep things simple, we reuse `num_unread` from
+            // _converse.ChatBox to indicate unread messages which
+            // mention the user and `num_unread_general` to indicate
+            // generally unread messages (which *includes* mentions!).
+            'num_unread_general': 0,
+            'bookmarked': false,
+            'chat_state': undefined,
+            'hidden': _converse.isUniView() && !api.settings.get('singleton'),
+            'hidden_occupants': !!api.settings.get('hide_muc_participants'),
+            'message_type': 'groupchat',
+            'name': '',
+            'num_unread': 0,
+            'roomconfig': {},
+            'time_opened': this.get('time_opened') || new Date().getTime(),
+            'time_sent': new Date(0).toISOString(),
+            'type': _converse.CHATROOMS_TYPE
+        };
+    },
+
+    async initialize () {
+        this.initialized = u.getResolveablePromise();
+        this.debouncedRejoin = debounce(this.rejoin, 250);
+        this.set('box_id', `box-${this.get('jid')}`);
+        this.initNotifications();
+        this.initMessages();
+        this.initOccupants();
+        this.initDiscoModels(); // sendChatState depends on this.features
+        this.registerHandlers();
+
+        this.on('change:chat_state', this.sendChatState, this);
+        await this.restoreSession();
+        this.session.on('change:connection_status', this.onConnectionStatusChanged, this);
+
+        this.listenTo(this.occupants, 'add', this.onOccupantAdded);
+        this.listenTo(this.occupants, 'remove', this.onOccupantRemoved);
+        this.listenTo(this.occupants, 'change:show', this.onOccupantShowChanged);
+        this.listenTo(this.occupants, 'change:affiliation', this.createAffiliationChangeMessage);
+        this.listenTo(this.occupants, 'change:role', this.createRoleChangeMessage);
+
+        const restored = await this.restoreFromCache();
+        if (!restored) {
+            this.join();
+        }
+        /**
+         * Triggered once a {@link _converse.ChatRoom} has been created and initialized.
+         * @event _converse#chatRoomInitialized
+         * @type { _converse.ChatRoom }
+         * @example _converse.api.listen.on('chatRoomInitialized', model => { ... });
+         */
+        await api.trigger('chatRoomInitialized', this, { 'Synchronous': true });
+        this.initialized.resolve();
+    },
+
+    /**
+     * Checks whether we're still joined and if so, restores the MUC state from cache.
+     * @private
+     * @method _converse.ChatRoom#restoreFromCache
+     * @returns { Boolean } Returns `true` if we're still joined, otherwise returns `false`.
+     */
+    async restoreFromCache () {
+        if (this.session.get('connection_status') === converse.ROOMSTATUS.ENTERED && (await this.isJoined())) {
+            // We've restored the room from cache and we're still joined.
+            await new Promise(resolve => this.features.fetch({ 'success': resolve, 'error': resolve }));
+            await this.fetchOccupants().catch(e => log.error(e));
+            await this.fetchMessages().catch(e => log.error(e));
+            return true;
+        } else {
+            await this.clearCache();
+            return false;
+        }
+    },
+
+    /**
+     * Join the MUC
+     * @private
+     * @method _converse.ChatRoom#join
+     * @param { String } nick - The user's nickname
+     * @param { String } [password] - Optional password, if required by the groupchat.
+     *  Will fall back to the `password` value stored in the room
+     *  model (if available).
+     */
+    async join (nick, password) {
+        if (this.session.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
+            // We have restored a groupchat from session storage,
+            // so we don't send out a presence stanza again.
+            return this;
+        }
+        await this.refreshDiscoInfo();
+        nick = await this.getAndPersistNickname(nick);
+        if (!nick) {
+            u.safeSave(this.session, { 'connection_status': converse.ROOMSTATUS.NICKNAME_REQUIRED });
+            if (api.settings.get('muc_show_logs_before_join')) {
+                await this.fetchMessages();
+            }
+            return this;
+        }
+        const stanza = $pres({
+            'from': _converse.connection.jid,
+            'to': this.getRoomJIDAndNick()
+        })
+            .c('x', { 'xmlns': Strophe.NS.MUC })
+            .c('history', {
+                'maxstanzas': this.features.get('mam_enabled') ? 0 : api.settings.get('muc_history_max_stanzas')
+            })
+            .up();
+
+        password = password || this.get('password');
+        if (password) {
+            stanza.cnode(Strophe.xmlElement('password', [], password));
+        }
+        this.session.save('connection_status', converse.ROOMSTATUS.CONNECTING);
+        api.send(stanza);
+        return this;
+    },
+
+    async clearCache () {
+        this.session.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
+        if (this.occupants.length) {
+            // Remove non-members when reconnecting
+            this.occupants.filter(o => !o.isMember()).forEach(o => o.destroy());
+        } else {
+            // Looks like we haven't restored occupants from cache, so we clear it.
+            this.occupants.clearStore();
+        }
+        if (api.settings.get('clear_messages_on_reconnection')) {
+            await this.clearMessages();
+        }
+    },
+
+    onOccupantAdded (occupant) {
+        if (
+            _converse.isInfoVisible(converse.MUC_TRAFFIC_STATES.ENTERED) &&
+            this.session.get('connection_status') === converse.ROOMSTATUS.ENTERED &&
+            occupant.get('show') === 'online'
+        ) {
+            this.updateNotifications(occupant.get('nick'), converse.MUC_TRAFFIC_STATES.ENTERED);
+        }
+    },
+
+    onOccupantRemoved (occupant) {
+        if (
+            _converse.isInfoVisible(converse.MUC_TRAFFIC_STATES.EXITED) &&
+            this.session.get('connection_status') === converse.ROOMSTATUS.ENTERED &&
+            occupant.get('show') === 'online'
+        ) {
+            this.updateNotifications(occupant.get('nick'), converse.MUC_TRAFFIC_STATES.EXITED);
+        }
+    },
+
+    onOccupantShowChanged (occupant) {
+        if (occupant.get('states').includes('303')) {
+            return;
+        }
+        if (occupant.get('show') === 'offline' && _converse.isInfoVisible(converse.MUC_TRAFFIC_STATES.EXITED)) {
+            this.updateNotifications(occupant.get('nick'), converse.MUC_TRAFFIC_STATES.EXITED);
+        } else if (occupant.get('show') === 'online' && _converse.isInfoVisible(converse.MUC_TRAFFIC_STATES.ENTERED)) {
+            this.updateNotifications(occupant.get('nick'), converse.MUC_TRAFFIC_STATES.ENTERED);
+        }
+    },
+
+    /**
+     * Clear stale cache and re-join a MUC we've been in before.
+     * @private
+     * @method _converse.ChatRoom#rejoin
+     */
+    rejoin () {
+        this.clearCache();
+        return this.join();
+    },
+
+    async onConnectionStatusChanged () {
+        if (this.session.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
+            await this.occupants.fetchMembers();
+            await this.fetchMessages();
+            /**
+             * Triggered when the user has entered a new MUC
+             * @event _converse#enteredNewRoom
+             * @type { _converse.ChatRoom}
+             * @example _converse.api.listen.on('enteredNewRoom', model => { ... });
+             */
+            api.trigger('enteredNewRoom', this);
+
+            if (
+                api.settings.get('auto_register_muc_nickname') &&
+                (await api.disco.supports(Strophe.NS.MUC_REGISTER, this.get('jid')))
+            ) {
+                this.registerNickname();
+            }
+        }
+    },
+
+    async onReconnection () {
+        this.registerHandlers();
+        await this.rejoin();
+        this.announceReconnection();
+    },
+
+    getMessagesCollection () {
+        return new _converse.ChatRoomMessages();
+    },
+
+    restoreSession () {
+        const id = `muc.session-${_converse.bare_jid}-${this.get('jid')}`;
+        this.session = new MUCSession({ id });
+        this.session.browserStorage = _converse.createStore(id, 'session');
+        return new Promise(r => this.session.fetch({ 'success': r, 'error': r }));
+    },
+
+    initDiscoModels () {
+        let id = `converse.muc-features-${_converse.bare_jid}-${this.get('jid')}`;
+        this.features = new Model(
+            Object.assign(
+                { id },
+                zipObject(
+                    converse.ROOM_FEATURES,
+                    converse.ROOM_FEATURES.map(() => false)
+                )
+            )
+        );
+        this.features.browserStorage = _converse.createStore(id, 'session');
+
+        id = `converse.muc-config-{_converse.bare_jid}-${this.get('jid')}`;
+        this.config = new Model();
+        this.config.browserStorage = _converse.createStore(id, 'session');
+    },
+
+    initOccupants () {
+        this.occupants = new _converse.ChatRoomOccupants();
+        const id = `converse.occupants-${_converse.bare_jid}${this.get('jid')}`;
+        this.occupants.browserStorage = _converse.createStore(id, 'session');
+        this.occupants.chatroom = this;
+    },
+
+    fetchOccupants () {
+        this.occupants.fetched = new Promise(resolve => {
+            this.occupants.fetch({
+                'add': true,
+                'silent': true,
+                'success': resolve,
+                'error': resolve
+            });
+        });
+        return this.occupants.fetched;
+    },
+
+    handleAffiliationChangedMessage (stanza) {
+        const item = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] item`, stanza).pop();
+        if (item) {
+            const from = stanza.getAttribute('from');
+            const type = stanza.getAttribute('type');
+            const affiliation = item.getAttribute('affiliation');
+            const jid = item.getAttribute('jid');
+            const data = {
+                from,
+                type,
+                affiliation,
+                'nick': Strophe.getNodeFromJid(jid),
+                'states': [],
+                'show': type == 'unavailable' ? 'offline' : 'online',
+                'role': item.getAttribute('role'),
+                'jid': Strophe.getBareJidFromJid(jid),
+                'resource': Strophe.getResourceFromJid(jid)
+            };
+            const occupant = this.occupants.findOccupant({ 'jid': data.jid });
+            if (occupant) {
+                occupant.save(data);
+            } else {
+                this.occupants.create(data);
+            }
+        }
+    },
+
+    async handleErrorMessageStanza (stanza) {
+        const { __ } = _converse;
+        const attrs = await st.parseMUCMessage(stanza, this, _converse);
+        if (!(await this.shouldShowErrorMessage(attrs))) {
+            return;
+        }
+        const message = this.getMessageReferencedByError(attrs);
+        if (message) {
+            const new_attrs = {
+                'error': attrs.error,
+                'error_condition': attrs.error_condition,
+                'error_text': attrs.error_text,
+                'error_type': attrs.error_type,
+                'editable': false
+            };
+            if (attrs.msgid === message.get('retraction_id')) {
+                // The error message refers to a retraction
+                new_attrs.retraction_id = undefined;
+                if (!attrs.error) {
+                    if (attrs.error_condition === 'forbidden') {
+                        new_attrs.error = __("You're not allowed to retract your message.");
+                    } else if (attrs.error_condition === 'not-acceptable') {
+                        new_attrs.error = __(
+                            "Your retraction was not delivered because you're not present in the groupchat."
+                        );
+                    } else {
+                        new_attrs.error = __('Sorry, an error occurred while trying to retract your message.');
+                    }
+                }
+            } else if (!attrs.error) {
+                if (attrs.error_condition === 'forbidden') {
+                    new_attrs.error = __("Your message was not delivered because you weren't allowed to send it.");
+                } else if (attrs.error_condition === 'not-acceptable') {
+                    new_attrs.error = __("Your message was not delivered because you're not present in the groupchat.");
+                } else {
+                    new_attrs.error = __('Sorry, an error occurred while trying to send your message.');
+                }
+            }
+            message.save(new_attrs);
+        } else {
+            this.createMessage(attrs);
+        }
+    },
+
+    /**
+     * Parses an incoming message stanza and queues it for processing.
+     * @private
+     * @method _converse.ChatRoom#handleMessageStanza
+     * @param { XMLElement } stanza
+     */
+    async handleMessageStanza (stanza) {
+        if (st.isArchived(stanza)) {
+            // MAM messages are handled in converse-mam.
+            // We shouldn't get MAM messages here because
+            // they shouldn't have a `type` attribute.
+            return log.warn(`Received a MAM message with type "groupchat"`);
+        }
+        this.createInfoMessages(stanza);
+        this.fetchFeaturesIfConfigurationChanged(stanza);
+
+        /**
+         * @typedef { Object } MUCMessageData
+         * An object containing the original groupchat message stanza,
+         * as well as the parsed attributes.
+         * @property { XMLElement } stanza
+         * @property { MUCMessageAttributes } attrs
+         * @property { ChatRoom } chatbox
+         */
+        const attrs = await st.parseMUCMessage(stanza, this, _converse);
+        const data = { stanza, attrs, 'chatbox': this };
+        /**
+         * Triggered when a groupchat message stanza has been received and parsed.
+         * @event _converse#message
+         * @type { object }
+         * @property { module:converse-muc~MUCMessageData } data
+         */
+        api.trigger('message', data);
+        return attrs && this.queueMessage(attrs);
+    },
+
+    registerHandlers () {
+        // Register presence and message handlers for this groupchat
+        const room_jid = this.get('jid');
+        this.removeHandlers();
+        this.presence_handler = _converse.connection.addHandler(
+            stanza => this.onPresence(stanza) || true,
+            null,
+            'presence',
+            null,
+            null,
+            room_jid,
+            { 'ignoreNamespaceFragment': true, 'matchBareFromJid': true }
+        );
+
+        this.message_handler = _converse.connection.addHandler(
+            stanza => !!this.handleMessageStanza(stanza) || true,
+            null,
+            'message',
+            'groupchat',
+            null,
+            room_jid,
+            { 'matchBareFromJid': true }
+        );
+
+        this.affiliation_message_handler = _converse.connection.addHandler(
+            stanza => this.handleAffiliationChangedMessage(stanza) || true,
+            Strophe.NS.MUC_USER,
+            'message',
+            null,
+            null,
+            room_jid
+        );
+    },
+
+    removeHandlers () {
+        // Remove the presence and message handlers that were
+        // registered for this groupchat.
+        if (this.message_handler) {
+            _converse.connection && _converse.connection.deleteHandler(this.message_handler);
+            delete this.message_handler;
+        }
+        if (this.presence_handler) {
+            _converse.connection && _converse.connection.deleteHandler(this.presence_handler);
+            delete this.presence_handler;
+        }
+        if (this.affiliation_message_handler) {
+            _converse.connection && _converse.connection.deleteHandler(this.affiliation_message_handler);
+            delete this.affiliation_message_handler;
+        }
+        return this;
+    },
+
+    invitesAllowed () {
+        return (
+            api.settings.get('allow_muc_invitations') &&
+            (this.features.get('open') || this.getOwnAffiliation() === 'owner')
+        );
+    },
+
+    getDisplayName () {
+        const name = this.get('name');
+        if (name) {
+            return name;
+        } else if (api.settings.get('locked_muc_domain') === 'hidden') {
+            return Strophe.getNodeFromJid(this.get('jid'));
+        } else {
+            return this.get('jid');
+        }
+    },
+
+    /**
+     * Sends a message stanza to the XMPP server and expects a reflection
+     * or error message within a specific timeout period.
+     * @private
+     * @method _converse.ChatRoom#sendTimedMessage
+     * @param { _converse.Message|XMLElement } message
+     * @returns { Promise<XMLElement>|Promise<_converse.TimeoutError> } Returns a promise
+     *  which resolves with the reflected message stanza or rejects
+     *  with an error stanza or with a {@link _converse.TimeoutError}.
+     */
+    sendTimedMessage (el) {
+        if (typeof el.tree === 'function') {
+            el = el.tree();
+        }
+        let id = el.getAttribute('id');
+        if (!id) {
+            // inject id if not found
+            id = this.getUniqueId('sendIQ');
+            el.setAttribute('id', id);
+        }
+        const promise = u.getResolveablePromise();
+        const timeoutHandler = _converse.connection.addTimedHandler(_converse.STANZA_TIMEOUT, () => {
+            _converse.connection.deleteHandler(handler);
+            promise.reject(new _converse.TimeoutError('Timeout Error: No response from server'));
+            return false;
+        });
+        const handler = _converse.connection.addHandler(
+            stanza => {
+                timeoutHandler && _converse.connection.deleteTimedHandler(timeoutHandler);
+                if (stanza.getAttribute('type') === 'groupchat') {
+                    promise.resolve(stanza);
+                } else {
+                    promise.reject(stanza);
+                }
+            },
+            null,
+            'message',
+            ['error', 'groupchat'],
+            id
+        );
+        api.send(el);
+        return promise;
+    },
+
+    /**
+     * Retract one of your messages in this groupchat
+     * @private
+     * @method _converse.ChatRoom#retractOwnMessage
+     * @param { _converse.Message } message - The message which we're retracting.
+     */
+    async retractOwnMessage (message) {
+        const __ = _converse.__;
+        const origin_id = message.get('origin_id');
+        if (!origin_id) {
+            throw new Error("Can't retract message without a XEP-0359 Origin ID");
+        }
+        const editable = message.get('editable');
+        const stanza = $msg({
+            'id': u.getUniqueId(),
+            'to': this.get('jid'),
+            'type': 'groupchat'
+        })
+            .c('store', { xmlns: Strophe.NS.HINTS })
+            .up()
+            .c('apply-to', {
+                'id': origin_id,
+                'xmlns': Strophe.NS.FASTEN
+            })
+            .c('retract', { xmlns: Strophe.NS.RETRACT });
+
+        // Optimistic save
+        message.set({
+            'retracted': new Date().toISOString(),
+            'retracted_id': origin_id,
+            'retraction_id': stanza.nodeTree.getAttribute('id'),
+            'editable': false
+        });
+        try {
+            await this.sendTimedMessage(stanza);
+        } catch (e) {
+            message.save({
+                editable,
+                'error_type': 'timeout',
+                'error': __('A timeout happened while while trying to retract your message.'),
+                'retracted': undefined,
+                'retracted_id': undefined
+            });
+            throw e;
+        }
+    },
+
+    /**
+     * Retract someone else's message in this groupchat.
+     * @private
+     * @method _converse.ChatRoom#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 editable = message.get('editable');
+        // Optimistic save
+        message.save({
+            'moderated': 'retracted',
+            'moderated_by': _converse.bare_jid,
+            'moderated_id': message.get('msgid'),
+            'moderation_reason': reason,
+            'editable': false
+        });
+        const result = await this.sendRetractionIQ(message, reason);
+        if (result === null || u.isErrorStanza(result)) {
+            // Undo the save if something went wrong
+            message.save({
+                editable,
+                'moderated': undefined,
+                'moderated_by': undefined,
+                'moderated_id': undefined,
+                'moderation_reason': undefined
+            });
+        }
+        return result;
+    },
+
+    /**
+     * Sends an IQ stanza to the XMPP server to retract a message in this groupchat.
+     * @private
+     * @method _converse.ChatRoom#sendRetractionIQ
+     * @param { _converse.Message } message - The message which we're retracting.
+     * @param { string } [reason] - The reason for retracting the message.
+     */
+    sendRetractionIQ (message, reason) {
+        const iq = $iq({ 'to': this.get('jid'), 'type': 'set' })
+            .c('apply-to', {
+                'id': message.get(`stanza_id ${this.get('jid')}`),
+                'xmlns': Strophe.NS.FASTEN
+            })
+            .c('moderate', { xmlns: Strophe.NS.MODERATE })
+            .c('retract', { xmlns: Strophe.NS.RETRACT })
+            .up()
+            .c('reason')
+            .t(reason || '');
+        return api.sendIQ(iq, null, false);
+    },
+
+    /**
+     * Sends an IQ stanza to the XMPP server to destroy this groupchat. Not
+     * to be confused with the {@link _converse.ChatRoom#destroy}
+     * method, which simply removes the room from the local browser storage cache.
+     * @private
+     * @method _converse.ChatRoom#sendDestroyIQ
+     * @param { string } [reason] - The reason for destroying the groupchat.
+     * @param { string } [new_jid] - The JID of the new groupchat which replaces this one.
+     */
+    sendDestroyIQ (reason, new_jid) {
+        const destroy = $build('destroy');
+        if (new_jid) {
+            destroy.attrs({ 'jid': new_jid });
+        }
+        const iq = $iq({
+            'to': this.get('jid'),
+            'type': 'set'
+        })
+            .c('query', { 'xmlns': Strophe.NS.MUC_OWNER })
+            .cnode(destroy.node);
+        if (reason && reason.length > 0) {
+            iq.c('reason', reason);
+        }
+        return api.sendIQ(iq);
+    },
+
+    /**
+     * Leave the groupchat.
+     * @private
+     * @method _converse.ChatRoom#leave
+     * @param { string } [exit_msg] - Message to indicate your reason for leaving
+     */
+    async leave (exit_msg) {
+        this.features.destroy();
+        this.occupants.clearStore();
+        if (_converse.disco_entities) {
+            const disco_entity = _converse.disco_entities.get(this.get('jid'));
+            if (disco_entity) {
+                await new Promise((success, error) => disco_entity.destroy({ success, error }));
+            }
+        }
+        if (api.connection.connected()) {
+            api.user.presence.send('unavailable', this.getRoomJIDAndNick(), exit_msg);
+        }
+        u.safeSave(this.session, { 'connection_status': converse.ROOMSTATUS.DISCONNECTED });
+        this.removeHandlers();
+    },
+
+    async close () {
+        // Delete the session model
+        await new Promise(resolve =>
+            this.session.destroy({
+                'success': resolve,
+                'error': (m, e) => {
+                    log.error(e);
+                    resolve();
+                }
+            })
+        );
+        // Delete the features model
+        await new Promise(resolve =>
+            this.features.destroy({
+                'success': resolve,
+                'error': (m, e) => {
+                    log.error(e);
+                    resolve();
+                }
+            })
+        );
+        return _converse.ChatBox.prototype.close.call(this);
+    },
+
+    canModerateMessages () {
+        const self = this.getOwnOccupant();
+        return self && self.isModerator() && api.disco.supports(Strophe.NS.MODERATE, this.get('jid'));
+    },
+
+    /**
+     * Return an array of unique nicknames based on all occupants and messages in this MUC.
+     * @private
+     * @method _converse.ChatRoom#getAllKnownNicknames
+     * @returns { String[] }
+     */
+    getAllKnownNicknames () {
+        return [
+            ...new Set([...this.occupants.map(o => o.get('nick')), ...this.messages.map(m => m.get('nick'))])
+        ].filter(n => n);
+    },
+
+    getAllKnownNicknamesRegex () {
+        const longNickString = this.getAllKnownNicknames().join('|');
+        const escapedLongNickString = p.escapeRegexString(longNickString);
+        return RegExp(`(?:\\p{P}|\\p{Z}|^)@(${escapedLongNickString})(?![\\w@-])`, 'uig');
+    },
+
+    getOccupantByJID (jid) {
+        return this.occupants.findOccupant({ jid });
+    },
+
+    getOccupantByNickname (nick) {
+        return this.occupants.findOccupant({ nick });
+    },
+
+    /**
+     * Given a text message, look for `@` mentions and turn them into
+     * XEP-0372 references
+     * @param { String } text
+     */
+    parseTextForReferences (text) {
+        const mentions_regex = /(\p{P}|\p{Z}|^)([@][\w_-]+(?:\.\w+)*)/giu;
+        if (!text || !mentions_regex.test(text)) {
+            return [text, []];
+        }
+
+        const getMatchingNickname = p.findFirstMatchInArray(this.getAllKnownNicknames());
+
+        const uriFromNickname = nickname => {
+            const jid = this.get('jid');
+            const occupant = this.getOccupant(nickname) || this.getOccupant(jid);
+            const uri = (occupant && occupant.get('jid')) || `${jid}/${nickname}`;
+            return encodeURI(`xmpp:${uri}`);
+        };
+
+        const matchToReference = match => {
+            let at_sign_index = match[0].indexOf('@');
+            if (match[0][at_sign_index + 1] === '@') {
+                // edge-case
+                at_sign_index += 1;
+            }
+            const begin = match.index + at_sign_index;
+            const end = begin + match[0].length - at_sign_index;
+            const value = getMatchingNickname(match[1]);
+            const type = 'mention';
+            const uri = uriFromNickname(value);
+            return { begin, end, value, type, uri };
+        };
+
+        const regex = this.getAllKnownNicknamesRegex();
+        const mentions = [...text.matchAll(regex)].filter(m => !m[0].startsWith('/'));
+        const references = mentions.map(matchToReference);
+
+        const [updated_message, updated_references] = p.reduceTextFromReferences(text, references);
+        return [updated_message, updated_references];
+    },
+
+    getOutgoingMessageAttributes (original_message, spoiler_hint) {
+        const is_spoiler = this.get('composing_spoiler');
+        const [text, references] = this.parseTextForReferences(original_message);
+        const origin_id = u.getUniqueId();
+        const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined;
+        return {
+            body,
+            is_spoiler,
+            origin_id,
+            references,
+            'id': origin_id,
+            'msgid': origin_id,
+            'from': `${this.get('jid')}/${this.get('nick')}`,
+            'fullname': this.get('nick'),
+            'is_only_emojis': text ? u.isOnlyEmojis(text) : false,
+            'message': body,
+            'nick': this.get('nick'),
+            'sender': 'me',
+            'spoiler_hint': is_spoiler ? spoiler_hint : undefined,
+            'type': 'groupchat'
+        };
+    },
+
+    /**
+     * Utility method to construct the JID for the current user as occupant of the groupchat.
+     * @private
+     * @method _converse.ChatRoom#getRoomJIDAndNick
+     * @returns {string} - The groupchat JID with the user's nickname added at the end.
+     * @example groupchat@conference.example.org/nickname
+     */
+    getRoomJIDAndNick () {
+        const nick = this.get('nick');
+        const jid = Strophe.getBareJidFromJid(this.get('jid'));
+        return jid + (nick !== null ? `/${nick}` : '');
+    },
+
+    /**
+     * Sends a message with the current XEP-0085 chat state of the user
+     * as taken from the `chat_state` attribute of the {@link _converse.ChatRoom}.
+     * @private
+     * @method _converse.ChatRoom#sendChatState
+     */
+    sendChatState () {
+        if (
+            !api.settings.get('send_chat_state_notifications') ||
+            !this.get('chat_state') ||
+            this.session.get('connection_status') !== converse.ROOMSTATUS.ENTERED ||
+            (this.features.get('moderated') && this.getOwnRole() === 'visitor')
+        ) {
+            return;
+        }
+        const allowed = api.settings.get('send_chat_state_notifications');
+        if (Array.isArray(allowed) && !allowed.includes(this.get('chat_state'))) {
+            return;
+        }
+        const chat_state = this.get('chat_state');
+        if (chat_state === _converse.GONE) {
+            // <gone/> is not applicable within MUC context
+            return;
+        }
+        api.send(
+            $msg({ 'to': this.get('jid'), 'type': 'groupchat' })
+                .c(chat_state, { 'xmlns': Strophe.NS.CHATSTATES })
+                .up()
+                .c('no-store', { 'xmlns': Strophe.NS.HINTS })
+                .up()
+                .c('no-permanent-store', { 'xmlns': Strophe.NS.HINTS })
+        );
+    },
+
+    /**
+     * Send a direct invitation as per XEP-0249
+     * @private
+     * @method _converse.ChatRoom#directInvite
+     * @param { String } recipient - JID of the person being invited
+     * @param { String } [reason] - Reason for the invitation
+     */
+    directInvite (recipient, reason) {
+        if (this.features.get('membersonly')) {
+            // When inviting to a members-only groupchat, we first add
+            // the person to the member list by giving them an
+            // affiliation of 'member' otherwise they won't be able to join.
+            this.updateMemberLists([{ 'jid': recipient, 'affiliation': 'member', 'reason': reason }]);
+        }
+        const attrs = {
+            'xmlns': 'jabber:x:conference',
+            'jid': this.get('jid')
+        };
+        if (reason !== null) {
+            attrs.reason = reason;
+        }
+        if (this.get('password')) {
+            attrs.password = this.get('password');
+        }
+        const invitation = $msg({
+            'from': _converse.connection.jid,
+            'to': recipient,
+            'id': u.getUniqueId()
+        }).c('x', attrs);
+        api.send(invitation);
+        /**
+         * After the user has sent out a direct invitation (as per XEP-0249),
+         * to a roster contact, asking them to join a room.
+         * @event _converse#chatBoxMaximized
+         * @type {object}
+         * @property {_converse.ChatRoom} room
+         * @property {string} recipient - The JID of the person being invited
+         * @property {string} reason - The original reason for the invitation
+         * @example _converse.api.listen.on('chatBoxMaximized', view => { ... });
+         */
+        api.trigger('roomInviteSent', {
+            'room': this,
+            'recipient': recipient,
+            'reason': reason
+        });
+    },
+
+    /**
+     * Refresh the disco identity, features and fields for this {@link _converse.ChatRoom}.
+     * *features* are stored on the features {@link Model} attribute on this {@link _converse.ChatRoom}.
+     * *fields* are stored on the config {@link Model} attribute on this {@link _converse.ChatRoom}.
+     * @private
+     * @returns {Promise}
+     */
+    refreshDiscoInfo () {
+        return api.disco
+            .refresh(this.get('jid'))
+            .then(() => this.getDiscoInfo())
+            .catch(e => log.error(e));
+    },
+
+    /**
+     * Fetch the *extended* MUC info from the server and cache it locally
+     * https://xmpp.org/extensions/xep-0045.html#disco-roominfo
+     * @private
+     * @method _converse.ChatRoom#getDiscoInfo
+     * @returns {Promise}
+     */
+    getDiscoInfo () {
+        return api.disco
+            .getIdentity('conference', 'text', this.get('jid'))
+            .then(identity => this.save({ 'name': identity?.get('name') }))
+            .then(() => this.getDiscoInfoFields())
+            .then(() => this.getDiscoInfoFeatures())
+            .catch(e => log.error(e));
+    },
+
+    /**
+     * Fetch the *extended* MUC info fields from the server and store them locally
+     * in the `config` {@link Model} attribute.
+     * See: https://xmpp.org/extensions/xep-0045.html#disco-roominfo
+     * @private
+     * @method _converse.ChatRoom#getDiscoInfoFields
+     * @returns {Promise}
+     */
+    async getDiscoInfoFields () {
+        const fields = await api.disco.getFields(this.get('jid'));
+        const config = fields.reduce((config, f) => {
+            const name = f.get('var');
+            if (name && name.startsWith('muc#roominfo_')) {
+                config[name.replace('muc#roominfo_', '')] = f.get('value');
+            }
+            return config;
+        }, {});
+        this.config.save(config);
+    },
+
+    /**
+     * Use converse-disco to populate the features {@link Model} which
+     * is stored as an attibute on this {@link _converse.ChatRoom}.
+     * The results may be cached. If you want to force fetching the features from the
+     * server, call {@link _converse.ChatRoom#refreshDiscoInfo} instead.
+     * @private
+     * @returns {Promise}
+     */
+    async getDiscoInfoFeatures () {
+        const features = await api.disco.getFeatures(this.get('jid'));
+        const attrs = Object.assign(
+            zipObject(
+                converse.ROOM_FEATURES,
+                converse.ROOM_FEATURES.map(() => false)
+            ),
+            { 'fetched': new Date().toISOString() }
+        );
+        features.each(feature => {
+            const fieldname = feature.get('var');
+            if (!fieldname.startsWith('muc_')) {
+                if (fieldname === Strophe.NS.MAM) {
+                    attrs.mam_enabled = true;
+                }
+                return;
+            }
+            attrs[fieldname.replace('muc_', '')] = true;
+        });
+        this.features.save(attrs);
+    },
+
+    /**
+     * Send IQ stanzas to the server to set an affiliation for
+     * the provided JIDs.
+     * See: https://xmpp.org/extensions/xep-0045.html#modifymember
+     *
+     * Prosody doesn't accept multiple JIDs' affiliations
+     * being set in one IQ stanza, so as a workaround we send
+     * a separate stanza for each JID.
+     * Related ticket: https://issues.prosody.im/345
+     *
+     * @private
+     * @method _converse.ChatRoom#setAffiliation
+     * @param { string } affiliation - The affiliation
+     * @param { object } members - A map of jids, affiliations and
+     *      optionally reasons. Only those entries with the
+     *      same affiliation as being currently set will be considered.
+     * @returns { Promise } A promise which resolves and fails depending on the XMPP server response.
+     */
+    setAffiliation (affiliation, members) {
+        members = members.filter(m => m.affiliation === undefined || m.affiliation === affiliation);
+        return Promise.all(members.map(m => this.sendAffiliationIQ(affiliation, m)));
+    },
+
+    /**
+     * Given a <field> element, return a copy with a <value> child if
+     * we can find a value for it in this rooms config.
+     * @private
+     * @method _converse.ChatRoom#addFieldValue
+     * @returns { Element }
+     */
+    addFieldValue (field) {
+        const type = field.getAttribute('type');
+        if (type === 'fixed') {
+            return field;
+        }
+        const fieldname = field.getAttribute('var').replace('muc#roomconfig_', '');
+        const config = this.get('roomconfig');
+        if (fieldname in config) {
+            let values;
+            switch (type) {
+                case 'boolean':
+                    values = [config[fieldname] ? 1 : 0];
+                    break;
+                case 'list-multi':
+                    values = config[fieldname];
+                    break;
+                default:
+                    values = [config[fieldname]];
+            }
+            field.innerHTML = values.map(v => $build('value').t(v)).join('');
+        }
+        return field;
+    },
+
+    /**
+     * Automatically configure the groupchat based on this model's
+     * 'roomconfig' data.
+     * @private
+     * @method _converse.ChatRoom#autoConfigureChatRoom
+     * @returns { Promise<XMLElement> }
+     * Returns a promise which resolves once a response IQ has
+     * been received.
+     */
+    async autoConfigureChatRoom () {
+        const stanza = await this.fetchRoomConfiguration();
+        const fields = sizzle('field', stanza);
+        const configArray = fields.map(f => this.addFieldValue(f));
+        if (configArray.length) {
+            return this.sendConfiguration(configArray);
+        }
+    },
+
+    /**
+     * Send an IQ stanza to fetch the groupchat configuration data.
+     * Returns a promise which resolves once the response IQ
+     * has been received.
+     * @private
+     * @method _converse.ChatRoom#fetchRoomConfiguration
+     * @returns { Promise<XMLElement> }
+     */
+    fetchRoomConfiguration () {
+        return api.sendIQ($iq({ 'to': this.get('jid'), 'type': 'get' }).c('query', { xmlns: Strophe.NS.MUC_OWNER }));
+    },
+
+    /**
+     * Sends an IQ stanza with the groupchat configuration.
+     * @private
+     * @method _converse.ChatRoom#sendConfiguration
+     * @param { Array } config - The groupchat configuration
+     * @returns { Promise<XMLElement> } - A promise which resolves with
+     * the `result` stanza received from the XMPP server.
+     */
+    sendConfiguration (config = []) {
+        const iq = $iq({ to: this.get('jid'), type: 'set' })
+            .c('query', { xmlns: Strophe.NS.MUC_OWNER })
+            .c('x', { xmlns: Strophe.NS.XFORM, type: 'submit' });
+        config.forEach(node => iq.cnode(node).up());
+        return api.sendIQ(iq);
+    },
+
+    /**
+     * Returns the `role` which the current user has in this MUC
+     * @private
+     * @method _converse.ChatRoom#getOwnRole
+     * @returns { ('none'|'visitor'|'participant'|'moderator') }
+     */
+    getOwnRole () {
+        return this.getOwnOccupant()?.attributes?.role;
+    },
+
+    /**
+     * Returns the `affiliation` which the current user has in this MUC
+     * @private
+     * @method _converse.ChatRoom#getOwnAffiliation
+     * @returns { ('none'|'outcast'|'member'|'admin'|'owner') }
+     */
+    getOwnAffiliation () {
+        return this.getOwnOccupant()?.attributes?.affiliation;
+    },
+
+    /**
+     * Get the {@link _converse.ChatRoomOccupant} instance which
+     * represents the current user.
+     * @private
+     * @method _converse.ChatRoom#getOwnOccupant
+     * @returns { _converse.ChatRoomOccupant }
+     */
+    getOwnOccupant () {
+        return this.occupants.findWhere({ 'jid': _converse.bare_jid });
+    },
+
+    /**
+     * Send an IQ stanza specifying an affiliation change.
+     * @private
+     * @method _converse.ChatRoom#
+     * @param { String } affiliation: affiliation
+     *     (could also be stored on the member object).
+     * @param { Object } member: Map containing the member's jid and
+     *     optionally a reason and affiliation.
+     */
+    sendAffiliationIQ (affiliation, member) {
+        const iq = $iq({ to: this.get('jid'), type: 'set' })
+            .c('query', { xmlns: Strophe.NS.MUC_ADMIN })
+            .c('item', {
+                'affiliation': member.affiliation || affiliation,
+                'nick': member.nick,
+                'jid': member.jid
+            });
+        if (member.reason !== undefined) {
+            iq.c('reason', member.reason);
+        }
+        return api.sendIQ(iq);
+    },
+
+    /**
+     * Send IQ stanzas to the server to modify affiliations for users in this groupchat.
+     *
+     * See: https://xmpp.org/extensions/xep-0045.html#modifymember
+     * @private
+     * @method _converse.ChatRoom#setAffiliations
+     * @param { Object[] } members
+     * @param { string } members[].jid - The JID of the user whose affiliation will change
+     * @param { Array } members[].affiliation - The new affiliation for this user
+     * @param { string } [members[].reason] - An optional reason for the affiliation change
+     * @returns { Promise }
+     */
+    setAffiliations (members) {
+        const affiliations = [...new Set(members.map(m => m.affiliation))];
+        return Promise.all(affiliations.map(a => this.setAffiliation(a, members)));
+    },
+
+    /**
+     * Send an IQ stanza to modify an occupant's role
+     * @private
+     * @method _converse.ChatRoom#setRole
+     * @param { _converse.ChatRoomOccupant } occupant
+     * @param { String } role
+     * @param { String } reason
+     * @param { function } onSuccess - callback for a succesful response
+     * @param { function } onError - callback for an error response
+     */
+    setRole (occupant, role, reason, onSuccess, onError) {
+        const item = $build('item', {
+            'nick': occupant.get('nick'),
+            role
+        });
+        const iq = $iq({
+            'to': this.get('jid'),
+            'type': 'set'
+        })
+            .c('query', { xmlns: Strophe.NS.MUC_ADMIN })
+            .cnode(item.node);
+        if (reason !== null) {
+            iq.c('reason', reason);
+        }
+        return api
+            .sendIQ(iq)
+            .then(onSuccess)
+            .catch(onError);
+    },
+
+    /**
+     * @private
+     * @method _converse.ChatRoom#getOccupant
+     * @param { String } nickname_or_jid - The nickname or JID of the occupant to be returned
+     * @returns { _converse.ChatRoomOccupant }
+     */
+    getOccupant (nickname_or_jid) {
+        return u.isValidJID(nickname_or_jid)
+            ? this.getOccupantByJID(nickname_or_jid)
+            : this.getOccupantByNickname(nickname_or_jid);
+    },
+
+    /**
+     * Return an array of occupant models that have the required role
+     * @private
+     * @method _converse.ChatRoom#getOccupantsWithRole
+     * @param { String } role
+     * @returns { _converse.ChatRoomOccupant[] }
+     */
+    getOccupantsWithRole (role) {
+        return this.getOccupantsSortedBy('nick')
+            .filter(o => o.get('role') === role)
+            .map(item => {
+                return {
+                    'jid': item.get('jid'),
+                    'nick': item.get('nick'),
+                    'role': item.get('role')
+                };
+            });
+    },
+
+    /**
+     * Return an array of occupant models that have the required affiliation
+     * @private
+     * @method _converse.ChatRoom#getOccupantsWithAffiliation
+     * @param { String } affiliation
+     * @returns { _converse.ChatRoomOccupant[] }
+     */
+    getOccupantsWithAffiliation (affiliation) {
+        return this.getOccupantsSortedBy('nick')
+            .filter(o => o.get('affiliation') === affiliation)
+            .map(item => {
+                return {
+                    'jid': item.get('jid'),
+                    'nick': item.get('nick'),
+                    'affiliation': item.get('affiliation')
+                };
+            });
+    },
+
+    /**
+     * Return an array of occupant models, sorted according to the passed-in attribute.
+     * @private
+     * @method _converse.ChatRoom#getOccupantsSortedBy
+     * @param { String } attr - The attribute to sort the returned array by
+     * @returns { _converse.ChatRoomOccupant[] }
+     */
+    getOccupantsSortedBy (attr) {
+        return Array.from(this.occupants.models).sort((a, b) =>
+            a.get(attr) < b.get(attr) ? -1 : a.get(attr) > b.get(attr) ? 1 : 0
+        );
+    },
+
+    /**
+     * Sends an IQ stanza to the server, asking it for the relevant affiliation list .
+     * Returns an array of {@link MemberListItem} objects, representing occupants
+     * that have the given affiliation.
+     * See: https://xmpp.org/extensions/xep-0045.html#modifymember
+     * @private
+     * @method _converse.ChatRoom#getAffiliationList
+     * @param { ("admin"|"owner"|"member") } affiliation
+     * @returns { Promise<MemberListItem[]> }
+     */
+    async getAffiliationList (affiliation) {
+        const iq = $iq({ to: this.get('jid'), type: 'get' })
+            .c('query', { xmlns: Strophe.NS.MUC_ADMIN })
+            .c('item', { 'affiliation': affiliation });
+        const result = await api.sendIQ(iq, null, false);
+        if (result === null) {
+            const err_msg = `Error: timeout while fetching ${affiliation} list for MUC ${this.get('jid')}`;
+            const err = new Error(err_msg);
+            log.warn(err_msg);
+            log.warn(result);
+            return err;
+        }
+        if (u.isErrorStanza(result)) {
+            const err_msg = `Error: not allowed to fetch ${affiliation} list for MUC ${this.get('jid')}`;
+            const err = new Error(err_msg);
+            log.warn(err_msg);
+            log.warn(result);
+            return err;
+        }
+        return muc_utils
+            .parseMemberListIQ(result)
+            .filter(p => p)
+            .sort((a, b) => (a.nick < b.nick ? -1 : a.nick > b.nick ? 1 : 0));
+    },
+
+    /**
+     * Fetch the lists of users with the given affiliations.
+     * Then compute the delta between those users and
+     * the passed in members, and if it exists, send the delta
+     * to the XMPP server to update the member list.
+     * @private
+     * @method _converse.ChatRoom#updateMemberLists
+     * @param { object } members - Map of member jids and affiliations.
+     * @returns { Promise }
+     *  A promise which is resolved once the list has been
+     *  updated or once it's been established there's no need
+     *  to update the list.
+     */
+    async updateMemberLists (members) {
+        const all_affiliations = ['member', 'admin', 'owner'];
+        const aff_lists = await Promise.all(all_affiliations.map(a => this.getAffiliationList(a)));
+        const old_members = aff_lists.reduce((acc, val) => (u.isErrorObject(val) ? acc : [...val, ...acc]), []);
+        await this.setAffiliations(muc_utils.computeAffiliationsDelta(true, false, members, old_members));
+        await this.occupants.fetchMembers();
+    },
+
+    /**
+     * Given a nick name, save it to the model state, otherwise, look
+     * for a server-side reserved nickname or default configured
+     * nickname and if found, persist that to the model state.
+     * @private
+     * @method _converse.ChatRoom#getAndPersistNickname
+     * @returns { Promise<string> } A promise which resolves with the nickname
+     */
+    async getAndPersistNickname (nick) {
+        nick = nick || this.get('nick') || (await this.getReservedNick()) || _converse.getDefaultMUCNickname();
+
+        if (nick) {
+            this.save({ nick }, { 'silent': true });
+        }
+        return nick;
+    },
+
+    /**
+     * Use service-discovery to ask the XMPP server whether
+     * this user has a reserved nickname for this groupchat.
+     * If so, we'll use that, otherwise we render the nickname form.
+     * @private
+     * @method _converse.ChatRoom#getReservedNick
+     * @returns { Promise<string> } A promise which resolves with the reserved nick or null
+     */
+    async getReservedNick () {
+        const stanza = $iq({
+            'to': this.get('jid'),
+            'from': _converse.connection.jid,
+            'type': 'get'
+        }).c('query', {
+            'xmlns': Strophe.NS.DISCO_INFO,
+            'node': 'x-roomuser-item'
+        });
+        const result = await api.sendIQ(stanza, null, false);
+        if (u.isErrorObject(result)) {
+            throw result;
+        }
+        const identity_el = result.querySelector('query[node="x-roomuser-item"] identity');
+        return identity_el ? identity_el.getAttribute('name') : null;
+    },
+
+    async registerNickname () {
+        // See https://xmpp.org/extensions/xep-0045.html#register
+        const __ = _converse.__;
+        const nick = this.get('nick');
+        const jid = this.get('jid');
+        let iq, err_msg;
+        try {
+            iq = await api.sendIQ(
+                $iq({
+                    'to': jid,
+                    'from': _converse.connection.jid,
+                    'type': 'get'
+                }).c('query', { 'xmlns': Strophe.NS.MUC_REGISTER })
+            );
+        } catch (e) {
+            if (sizzle(`not-allowed[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
+                err_msg = __("You're not allowed to register yourself in this groupchat.");
+            } else if (sizzle(`registration-required[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
+                err_msg = __("You're not allowed to register in this groupchat because it's members-only.");
+            }
+            log.error(e);
+            return err_msg;
+        }
+        const required_fields = sizzle('field required', iq).map(f => f.parentElement);
+        if (required_fields.length > 1 && required_fields[0].getAttribute('var') !== 'muc#register_roomnick') {
+            return log.error(`Can't register the user register in the groupchat ${jid} due to the required fields`);
+        }
+        try {
+            await api.sendIQ(
+                $iq({
+                    'to': jid,
+                    'from': _converse.connection.jid,
+                    'type': 'set'
+                })
+                    .c('query', { 'xmlns': Strophe.NS.MUC_REGISTER })
+                    .c('x', { 'xmlns': Strophe.NS.XFORM, 'type': 'submit' })
+                    .c('field', { 'var': 'FORM_TYPE' })
+                    .c('value')
+                    .t('http://jabber.org/protocol/muc#register')
+                    .up()
+                    .up()
+                    .c('field', { 'var': 'muc#register_roomnick' })
+                    .c('value')
+                    .t(nick)
+            );
+        } catch (e) {
+            if (sizzle(`service-unavailable[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
+                err_msg = __("Can't register your nickname in this groupchat, it doesn't support registration.");
+            } else if (sizzle(`bad-request[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
+                err_msg = __("Can't register your nickname in this groupchat, invalid data form supplied.");
+            }
+            log.error(err_msg);
+            log.error(e);
+            return err_msg;
+        }
+    },
+
+    /**
+     * Given a presence stanza, update the occupant model based on its contents.
+     * @private
+     * @method _converse.ChatRoom#updateOccupantsOnPresence
+     * @param { XMLElement } pres - The presence stanza
+     */
+    updateOccupantsOnPresence (pres) {
+        const data = st.parseMUCPresence(pres);
+        if (data.type === 'error' || (!data.jid && !data.nick)) {
+            return true;
+        }
+        const occupant = this.occupants.findOccupant(data);
+        // Destroy an unavailable occupant if this isn't a nick change operation and if they're not affiliated
+        if (
+            data.type === 'unavailable' &&
+            occupant &&
+            !data.states.includes(converse.MUC_NICK_CHANGED_CODE) &&
+            !['admin', 'owner', 'member'].includes(data['affiliation'])
+        ) {
+            // Before destroying we set the new data, so that we can show the disconnection message
+            occupant.set(data);
+            occupant.destroy();
+            return;
+        }
+        const jid = data.jid || '';
+        const attributes = Object.assign(data, {
+            'jid': Strophe.getBareJidFromJid(jid) || occupant?.attributes?.jid,
+            'resource': Strophe.getResourceFromJid(jid) || occupant?.attributes?.resource
+        });
+        if (occupant) {
+            occupant.save(attributes);
+        } else {
+            this.occupants.create(attributes);
+        }
+    },
+
+    fetchFeaturesIfConfigurationChanged (stanza) {
+        // 104: configuration change
+        // 170: logging enabled
+        // 171: logging disabled
+        // 172: room no longer anonymous
+        // 173: room now semi-anonymous
+        // 174: room now fully anonymous
+        const codes = ['104', '170', '171', '172', '173', '174'];
+        if (sizzle('status', stanza).filter(e => codes.includes(e.getAttribute('status'))).length) {
+            this.refreshDiscoInfo();
+        }
+    },
+
+    /**
+     * Given two JIDs, which can be either user JIDs or MUC occupant JIDs,
+     * determine whether they belong to the same user.
+     * @private
+     * @method _converse.ChatRoom#isSameUser
+     * @param { String } jid1
+     * @param { String } jid2
+     * @returns { Boolean }
+     */
+    isSameUser (jid1, jid2) {
+        const bare_jid1 = Strophe.getBareJidFromJid(jid1);
+        const bare_jid2 = Strophe.getBareJidFromJid(jid2);
+        const resource1 = Strophe.getResourceFromJid(jid1);
+        const resource2 = Strophe.getResourceFromJid(jid2);
+        if (u.isSameBareJID(jid1, jid2)) {
+            if (bare_jid1 === this.get('jid')) {
+                // MUC JIDs
+                return resource1 === resource2;
+            } else {
+                return true;
+            }
+        } else {
+            const occupant1 =
+                bare_jid1 === this.get('jid')
+                    ? this.occupants.findOccupant({ 'nick': resource1 })
+                    : this.occupants.findOccupant({ 'jid': bare_jid1 });
+
+            const occupant2 =
+                bare_jid2 === this.get('jid')
+                    ? this.occupants.findOccupant({ 'nick': resource2 })
+                    : this.occupants.findOccupant({ 'jid': bare_jid2 });
+            return occupant1 === occupant2;
+        }
+    },
+
+    async isSubjectHidden () {
+        const jids = await api.user.settings.get('mucs_with_hidden_subject', []);
+        return jids.includes(this.get('jid'));
+    },
+
+    async toggleSubjectHiddenState () {
+        const muc_jid = this.get('jid');
+        const jids = await api.user.settings.get('mucs_with_hidden_subject', []);
+        if (jids.includes(this.get('jid'))) {
+            api.user.settings.set(
+                'mucs_with_hidden_subject',
+                jids.filter(jid => jid !== muc_jid)
+            );
+        } else {
+            api.user.settings.set('mucs_with_hidden_subject', [...jids, muc_jid]);
+        }
+    },
+
+    /**
+     * Handle a possible subject change and return `true` if so.
+     * @private
+     * @method _converse.ChatRoom#handleSubjectChange
+     * @param { object } attrs - Attributes representing a received
+     *  message, as returned by {@link st.parseMUCMessage}
+     */
+    async handleSubjectChange (attrs) {
+        const __ = _converse.__;
+        if (typeof attrs.subject === 'string' && !attrs.thread && !attrs.message) {
+            // https://xmpp.org/extensions/xep-0045.html#subject-mod
+            // -----------------------------------------------------
+            // The subject is changed by sending a message of type "groupchat" to the <room@service>,
+            // where the <message/> MUST contain a <subject/> element that specifies the new subject but
+            // MUST NOT contain a <body/> element (or a <thread/> element).
+            const subject = attrs.subject;
+            const author = attrs.nick;
+            u.safeSave(this, { 'subject': { author, 'text': attrs.subject || '' } });
+            if (!attrs.is_delayed && author) {
+                const message = subject ? __('Topic set by %1$s', author) : __('Topic cleared by %1$s', author);
+                const prev_msg = this.messages.last();
+                if (
+                    prev_msg?.get('nick') !== attrs.nick ||
+                    prev_msg?.get('type') !== 'info' ||
+                    prev_msg?.get('message') !== message
+                ) {
+                    this.createMessage({ message, 'nick': attrs.nick, 'type': 'info' });
+                }
+                if (await this.isSubjectHidden()) {
+                    this.toggleSubjectHiddenState();
+                }
+            }
+            return true;
+        }
+        return false;
+    },
+
+    /**
+     * Set the subject for this {@link _converse.ChatRoom}
+     * @private
+     * @method _converse.ChatRoom#setSubject
+     * @param { String } value
+     */
+    setSubject (value = '') {
+        api.send(
+            $msg({
+                to: this.get('jid'),
+                from: _converse.connection.jid,
+                type: 'groupchat'
+            })
+                .c('subject', { xmlns: 'jabber:client' })
+                .t(value)
+                .tree()
+        );
+    },
+
+    /**
+     * Is this a chat state notification that can be ignored,
+     * because it's old or because it's from us.
+     * @private
+     * @method _converse.ChatRoom#ignorableCSN
+     * @param { Object } attrs - The message attributes
+     */
+    ignorableCSN (attrs) {
+        return attrs.chat_state && !attrs.body && (attrs.is_delayed || this.isOwnMessage(attrs));
+    },
+
+    /**
+     * Determines whether the message is from ourselves by checking
+     * the `from` attribute. Doesn't check the `type` attribute.
+     * @private
+     * @method _converse.ChatRoom#isOwnMessage
+     * @param { Object|XMLElement|_converse.Message } msg
+     * @returns { boolean }
+     */
+    isOwnMessage (msg) {
+        let from;
+        if (isElement(msg)) {
+            from = msg.getAttribute('from');
+        } else if (msg instanceof _converse.Message) {
+            from = msg.get('from');
+        } else {
+            from = msg.from;
+        }
+        return Strophe.getResourceFromJid(from) == this.get('nick');
+    },
+
+    getUpdatedMessageAttributes (message, attrs) {
+        const new_attrs = _converse.ChatBox.prototype.getUpdatedMessageAttributes.call(this, message, attrs);
+        if (this.isOwnMessage(attrs)) {
+            const stanza_id_keys = Object.keys(attrs).filter(k => k.startsWith('stanza_id'));
+            Object.assign(new_attrs, pick(attrs, stanza_id_keys));
+            if (!message.get('received')) {
+                new_attrs.received = new Date().toISOString();
+            }
+        }
+        return new_attrs;
+    },
+
+    /**
+     * Send a MUC-0410 MUC Self-Ping stanza to room to determine
+     * whether we're still joined.
+     * @async
+     * @private
+     * @method _converse.ChatRoom#isJoined
+     * @returns {Promise<boolean>}
+     */
+    async isJoined () {
+        const jid = this.get('jid');
+        const ping = $iq({
+            'to': `${jid}/${this.get('nick')}`,
+            'type': 'get'
+        }).c('ping', { 'xmlns': Strophe.NS.PING });
+        try {
+            await api.sendIQ(ping);
+        } catch (e) {
+            if (e === null) {
+                log.warn(`isJoined: Timeout error while checking whether we're joined to MUC: ${jid}`);
+            } else {
+                log.warn(`isJoined: Apparently we're no longer connected to MUC: ${jid}`);
+            }
+            return false;
+        }
+        return true;
+    },
+
+    /**
+     * Check whether we're still joined and re-join if not
+     * @async
+     * @private
+     * @method _converse.ChatRoom#rejoinIfNecessary
+     */
+    async rejoinIfNecessary () {
+        if (!(await this.isJoined())) {
+            this.rejoin();
+            return true;
+        }
+    },
+
+    /**
+     * @private
+     * @method _converse.ChatRoom#shouldShowErrorMessage
+     * @returns {Promise<boolean>}
+     */
+    async shouldShowErrorMessage (attrs) {
+        if (attrs['error_condition'] === 'not-acceptable' && (await this.rejoinIfNecessary())) {
+            return false;
+        }
+        return _converse.ChatBox.prototype.shouldShowErrorMessage.call(this, attrs);
+    },
+
+    /**
+     * Looks whether we already have a moderation message for this
+     * incoming message. If so, it's considered "dangling" because
+     * it probably hasn't been applied to anything yet, given that
+     * the relevant message is only coming in now.
+     * @private
+     * @method _converse.ChatRoom#findDanglingModeration
+     * @param { object } attrs - Attributes representing a received
+     *  message, as returned by {@link st.parseMUCMessage}
+     * @returns { _converse.ChatRoomMessage }
+     */
+    findDanglingModeration (attrs) {
+        if (!this.messages.length) {
+            return null;
+        }
+        // Only look for dangling moderation if there are newer
+        // messages than this one, since moderation come after.
+        if (this.messages.last().get('time') > attrs.time) {
+            // Search from latest backwards
+            const messages = Array.from(this.messages.models);
+            const stanza_id = attrs[`stanza_id ${this.get('jid')}`];
+            if (!stanza_id) {
+                return null;
+            }
+            messages.reverse();
+            return messages.find(
+                ({ attributes }) =>
+                    attributes.moderated === 'retracted' &&
+                    attributes.moderated_id === stanza_id &&
+                    attributes.moderated_by
+            );
+        }
+    },
+
+    /**
+     * Handles message moderation based on the passed in attributes.
+     * @private
+     * @method _converse.ChatRoom#handleModeration
+     * @param { object } attrs - Attributes representing a received
+     *  message, as returned by {@link st.parseMUCMessage}
+     * @returns { Boolean } Returns `true` or `false` depending on
+     *  whether a message was moderated or not.
+     */
+    async handleModeration (attrs) {
+        const MODERATION_ATTRIBUTES = ['editable', 'moderated', 'moderated_by', 'moderated_id', 'moderation_reason'];
+        if (attrs.moderated === 'retracted') {
+            const query = {};
+            const key = `stanza_id ${this.get('jid')}`;
+            query[key] = attrs.moderated_id;
+            const message = this.messages.findWhere(query);
+            if (!message) {
+                attrs['dangling_moderation'] = true;
+                await this.createMessage(attrs);
+                return true;
+            }
+            message.save(pick(attrs, MODERATION_ATTRIBUTES));
+            return true;
+        } else {
+            // Check if we have dangling moderation message
+            const message = this.findDanglingModeration(attrs);
+            if (message) {
+                const moderation_attrs = pick(message.attributes, MODERATION_ATTRIBUTES);
+                const new_attrs = Object.assign({ 'dangling_moderation': false }, attrs, moderation_attrs);
+                delete new_attrs['id']; // Delete id, otherwise a new cache entry gets created
+                message.save(new_attrs);
+                return true;
+            }
+        }
+        return false;
+    },
+
+    /**
+     * @param {String} actor - The nickname of the actor that caused the notification
+     * @param {String|Array<String>} states - The state or states representing the type of notificcation
+     */
+    removeNotification (actor, states) {
+        const actors_per_state = this.notifications.toJSON();
+        states = Array.isArray(states) ? states : [states];
+        states.forEach(state => {
+            const existing_actors = Array.from(actors_per_state[state] || []);
+            if (existing_actors.includes(actor)) {
+                const idx = existing_actors.indexOf(actor);
+                existing_actors.splice(idx, 1);
+                this.notifications.set(state, Array.from(existing_actors));
+            }
+        });
+    },
+
+    /**
+     * Update the notifications model by adding the passed in nickname
+     * to the array of nicknames that all match a particular state.
+     *
+     * Removes the nickname from any other states it might be associated with.
+     *
+     * The state can be a XEP-0085 Chat State or a XEP-0045 join/leave
+     * state.
+     * @param {String} actor - The nickname of the actor that causes the notification
+     * @param {String} state - The state representing the type of notificcation
+     */
+    updateNotifications (actor, state) {
+        const actors_per_state = this.notifications.toJSON();
+        const existing_actors = actors_per_state[state] || [];
+        if (existing_actors.includes(actor)) {
+            return;
+        }
+        const reducer = (out, s) => {
+            if (s === state) {
+                out[s] = [...existing_actors, actor];
+            } else {
+                out[s] = (actors_per_state[s] || []).filter(a => a !== actor);
+            }
+            return out;
+        };
+        const actors_per_chat_state = converse.CHAT_STATES.reduce(reducer, {});
+        const actors_per_traffic_state = converse.MUC_TRAFFIC_STATES_LIST.reduce(reducer, {});
+        const actors_per_role_change = converse.MUC_ROLE_CHANGES_LIST.reduce(reducer, {});
+        this.notifications.set(Object.assign(actors_per_chat_state, actors_per_traffic_state, actors_per_role_change));
+        window.setTimeout(() => this.removeNotification(actor, state), 10000);
+    },
+
+    /**
+     * Handler for all MUC messages sent to this groupchat. This method
+     * shouldn't be called directly, instead {@link _converse.ChatRoom#queueMessage}
+     * should be called.
+     * @private
+     * @method _converse.ChatRoom#onMessage
+     * @param { MessageAttributes } attrs - A promise which resolves to the message attributes.
+     */
+    async onMessage (attrs) {
+        attrs = await attrs;
+        if (u.isErrorObject(attrs)) {
+            attrs.stanza && log.error(attrs.stanza);
+            return log.error(attrs.message);
+        }
+        const message = this.getDuplicateMessage(attrs);
+        if (message) {
+            return this.updateMessage(message, attrs);
+        } else if (attrs.is_valid_receipt_request || attrs.is_marker || this.ignorableCSN(attrs)) {
+            return;
+        }
+        if (
+            (await this.handleRetraction(attrs)) ||
+            (await this.handleModeration(attrs)) ||
+            (await this.handleSubjectChange(attrs))
+        ) {
+            return this.removeNotification(attrs.nick, ['composing', 'paused']);
+        }
+        this.setEditable(attrs, attrs.time);
+
+        if (attrs['chat_state']) {
+            this.updateNotifications(attrs.nick, attrs.chat_state);
+        }
+        if (u.shouldCreateGroupchatMessage(attrs)) {
+            const msg = this.handleCorrection(attrs) || (await this.createMessage(attrs));
+            this.removeNotification(attrs.nick, ['composing', 'paused']);
+            this.handleUnreadMessage(msg);
+        }
+    },
+
+    handleModifyError (pres) {
+        const text = pres.querySelector('error text')?.textContent;
+        if (text) {
+            if (this.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING) {
+                this.setDisconnectionMessage(text);
+            } else {
+                const attrs = {
+                    'type': 'error',
+                    'message': text,
+                    'is_ephemeral': true
+                };
+                this.createMessage(attrs);
+            }
+        }
+    },
+
+    /**
+     * Handle a presence stanza that disconnects the user from the MUC
+     * @param { XMLElement } stanza
+     */
+    handleDisconnection (stanza) {
+        const is_self = stanza.querySelector("status[code='110']") !== null;
+        const x = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"]`, stanza).pop();
+        if (!x) {
+            return;
+        }
+        const codes = sizzle('status', x).map(s => s.getAttribute('code'));
+        const disconnection_codes = intersection(codes, Object.keys(_converse.muc.disconnect_messages));
+        const disconnected = is_self && disconnection_codes.length > 0;
+        if (!disconnected) {
+            return;
+        }
+        // By using querySelector we assume here there is
+        // one <item> per <x xmlns='http://jabber.org/protocol/muc#user'>
+        // element. This appears to be a safe assumption, since
+        // each <x/> element pertains to a single user.
+        const item = x.querySelector('item');
+        const reason = item ? item.querySelector('reason')?.textContent : undefined;
+        const actor = item ? invoke(item.querySelector('actor'), 'getAttribute', 'nick') : undefined;
+        const message = _converse.muc.disconnect_messages[disconnection_codes[0]];
+        this.setDisconnectionMessage(message, reason, actor);
+    },
+
+    getActionInfoMessage (code, nick, actor) {
+        const __ = _converse.__;
+        if (code === '301') {
+            return actor ? __('%1$s has been banned by %2$s', nick, actor) : __('%1$s has been banned', nick);
+        } else if (code === '303') {
+            return __("%1$s's nickname has changed", nick);
+        } else if (code === '307') {
+            return actor ? __('%1$s has been kicked out by %2$s', nick, actor) : __('%1$s has been kicked out', nick);
+        } else if (code === '321') {
+            return __('%1$s has been removed because of an affiliation change', nick);
+        } else if (code === '322') {
+            return __('%1$s has been removed for not being a member', nick);
+        }
+    },
+
+    createAffiliationChangeMessage (occupant) {
+        const __ = _converse.__;
+        const previous_affiliation = occupant._previousAttributes.affiliation;
+
+        if (!previous_affiliation) {
+            // If no previous affiliation was set, then we don't
+            // interpret this as an affiliation change.
+            // For example, if muc_send_probes is true, then occupants
+            // are created based on incoming messages, in which case
+            // we don't yet know the affiliation
+            return;
+        }
+
+        const current_affiliation = occupant.get('affiliation');
+        if (previous_affiliation === 'admin' && _converse.isInfoVisible(converse.AFFILIATION_CHANGES.EXADMIN)) {
+            this.createMessage({
+                'type': 'info',
+                'message': __('%1$s is no longer an admin of this groupchat', occupant.get('nick'))
+            });
+        } else if (previous_affiliation === 'owner' && _converse.isInfoVisible(converse.AFFILIATION_CHANGES.EXOWNER)) {
+            this.createMessage({
+                'type': 'info',
+                'message': __('%1$s is no longer an owner of this groupchat', occupant.get('nick'))
+            });
+        } else if (
+            previous_affiliation === 'outcast' &&
+            _converse.isInfoVisible(converse.AFFILIATION_CHANGES.EXOUTCAST)
+        ) {
+            this.createMessage({
+                'type': 'info',
+                'message': __('%1$s is no longer banned from this groupchat', occupant.get('nick'))
+            });
+        }
+
+        if (
+            current_affiliation === 'none' &&
+            previous_affiliation === 'member' &&
+            _converse.isInfoVisible(converse.AFFILIATION_CHANGES.EXMEMBER)
+        ) {
+            this.createMessage({
+                'type': 'info',
+                'message': __('%1$s is no longer a member of this groupchat', occupant.get('nick'))
+            });
+        }
+
+        if (current_affiliation === 'member' && _converse.isInfoVisible(converse.AFFILIATION_CHANGES.MEMBER)) {
+            this.createMessage({
+                'type': 'info',
+                'message': __('%1$s is now a member of this groupchat', occupant.get('nick'))
+            });
+        } else if (
+            (current_affiliation === 'admin' && _converse.isInfoVisible(converse.AFFILIATION_CHANGES.ADMIN)) ||
+            (current_affiliation == 'owner' && _converse.isInfoVisible(converse.AFFILIATION_CHANGES.OWNER))
+        ) {
+            // For example: AppleJack is now an (admin|owner) of this groupchat
+            this.createMessage({
+                'type': 'info',
+                'message': __('%1$s is now an %2$s of this groupchat', occupant.get('nick'), current_affiliation)
+            });
+        }
+    },
+
+    createRoleChangeMessage (occupant, changed) {
+        if (changed === 'none' || occupant.changed.affiliation) {
+            // We don't inform of role changes if they accompany affiliation changes.
+            return;
+        }
+        const previous_role = occupant._previousAttributes.role;
+        if (previous_role === 'moderator' && _converse.isInfoVisible(converse.MUC_ROLE_CHANGES.DEOP)) {
+            this.updateNotifications(occupant.get('nick'), converse.MUC_ROLE_CHANGES.DEOP);
+        } else if (previous_role === 'visitor' && _converse.isInfoVisible(converse.MUC_ROLE_CHANGES.VOICE)) {
+            this.updateNotifications(occupant.get('nick'), converse.MUC_ROLE_CHANGES.VOICE);
+        }
+        if (occupant.get('role') === 'visitor' && _converse.isInfoVisible(converse.MUC_ROLE_CHANGES.MUTE)) {
+            this.updateNotifications(occupant.get('nick'), converse.MUC_ROLE_CHANGES.MUTE);
+        } else if (occupant.get('role') === 'moderator') {
+            if (
+                !['owner', 'admin'].includes(occupant.get('affiliation')) &&
+                _converse.isInfoVisible(converse.MUC_ROLE_CHANGES.OP)
+            ) {
+                // Oly show this message if the user isn't already
+                // an admin or owner, otherwise this isn't new information.
+                this.updateNotifications(occupant.get('nick'), converse.MUC_ROLE_CHANGES.OP);
+            }
+        }
+    },
+
+    /**
+     * Create an info message based on a received MUC status code
+     * @private
+     * @method _converse.ChatRoom#createInfoMessage
+     * @param { string } code - The MUC status code
+     * @param { XMLElement } stanza - The original stanza that contains the code
+     * @param { Boolean } is_self - Whether this stanza refers to our own presence
+     */
+    createInfoMessage (code, stanza, is_self) {
+        const __ = _converse.__;
+        const data = { 'type': 'info' };
+        if (!_converse.isInfoVisible(code)) {
+            return;
+        }
+        if (code === '110' || (code === '100' && !is_self)) {
+            return;
+        } else if (code in _converse.muc.info_messages) {
+            data.message = _converse.muc.info_messages[code];
+        } else if (!is_self && ACTION_INFO_CODES.includes(code)) {
+            const nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
+            const item = stanza.querySelector(`x[xmlns="${Strophe.NS.MUC_USER}"] item`);
+            data.actor = item ? item.querySelector('actor')?.getAttribute('nick') : undefined;
+            data.reason = item ? item.querySelector('reason')?.textContent : undefined;
+            data.message = this.getActionInfoMessage(code, nick, data.actor);
+        } else if (is_self && code in _converse.muc.new_nickname_messages) {
+            // XXX: Side-effect of setting the nick. Should ideally be refactored out of this method
+            let nick;
+            if (is_self && code === '210') {
+                nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
+            } else if (is_self && code === '303') {
+                nick = stanza.querySelector(`x[xmlns="${Strophe.NS.MUC_USER}"] item`).getAttribute('nick');
+            }
+            this.save('nick', nick);
+            data.message = __(_converse.muc.new_nickname_messages[code], nick);
+        }
+        if (data.message) {
+            if (code === '201' && this.messages.findWhere(data)) {
+                return;
+            } else if (
+                code in _converse.muc.info_messages &&
+                this.messages.length &&
+                this.messages.pop().get('message') === data.message
+            ) {
+                // XXX: very naive duplication checking
+                return;
+            }
+            this.createMessage(data);
+        }
+    },
+
+    /**
+     * Create info messages based on a received presence or message stanza
+     * @private
+     * @method _converse.ChatRoom#createInfoMessages
+     * @param { XMLElement } stanza
+     */
+    createInfoMessages (stanza) {
+        const codes = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] status`, stanza).map(s => s.getAttribute('code'));
+        if (codes.includes('333') && codes.includes('307')) {
+            // See: https://github.com/xsf/xeps/pull/969/files#diff-ac5113766e59219806793c1f7d967f1bR4966
+            codes.splice(codes.indexOf('307'), 1);
+        }
+        const is_self = codes.includes('110');
+        codes.forEach(code => this.createInfoMessage(code, stanza, is_self));
+    },
+
+    setDisconnectionMessage (message, reason, actor) {
+        this.save({
+            'disconnection_message': message,
+            'disconnection_reason': reason,
+            'disconnection_actor': actor
+        });
+        this.session.save({ 'connection_status': converse.ROOMSTATUS.DISCONNECTED });
+    },
+
+    onNicknameClash (presence) {
+        const __ = _converse.__;
+        if (api.settings.get('muc_nickname_from_jid')) {
+            const nick = presence.getAttribute('from').split('/')[1];
+            if (nick === _converse.getDefaultMUCNickname()) {
+                this.join(nick + '-2');
+            } else {
+                const del = nick.lastIndexOf('-');
+                const num = nick.substring(del + 1, nick.length);
+                this.join(nick.substring(0, del + 1) + String(Number(num) + 1));
+            }
+        } else {
+            this.save({
+                'nickname_validation_message': __(
+                    'The nickname you chose is reserved or ' + 'currently in use, please choose a different one.'
+                )
+            });
+            this.session.save({ 'connection_status': converse.ROOMSTATUS.NICKNAME_REQUIRED });
+        }
+    },
+
+    /**
+     * Parses a <presence> stanza with type "error" and sets the proper
+     * `connection_status` value for this {@link _converse.ChatRoom} as
+     * well as any additional output that can be shown to the user.
+     * @private
+     * @param { XMLElement } stanza - The presence stanza
+     */
+    onErrorPresence (stanza) {
+        const __ = _converse.__;
+        const error = stanza.querySelector('error');
+        const error_type = error.getAttribute('type');
+        const reason = sizzle(`text[xmlns="${Strophe.NS.STANZAS}"]`, error).pop()?.textContent;
+
+        if (error_type === 'modify') {
+            this.handleModifyError(stanza);
+        } else if (error_type === 'auth') {
+            if (sizzle(`not-authorized[xmlns="${Strophe.NS.STANZAS}"]`, error).length) {
+                this.save({ 'password_validation_message': reason || __('Password incorrect') });
+                this.session.save({ 'connection_status': converse.ROOMSTATUS.PASSWORD_REQUIRED });
+            }
+            if (error.querySelector('registration-required')) {
+                const message = __('You are not on the member list of this groupchat.');
+                this.setDisconnectionMessage(message, reason);
+            } else if (error.querySelector('forbidden')) {
+                const message = __('You have been banned from this groupchat.');
+                this.setDisconnectionMessage(message, reason);
+            }
+        } else if (error_type === 'cancel') {
+            if (error.querySelector('not-allowed')) {
+                const message = __('You are not allowed to create new groupchats.');
+                this.setDisconnectionMessage(message, reason);
+            } else if (error.querySelector('not-acceptable')) {
+                const message = __("Your nickname doesn't conform to this groupchat's policies.");
+                this.setDisconnectionMessage(message, reason);
+            } else if (sizzle(`gone[xmlns="${Strophe.NS.STANZAS}"]`, error).length) {
+                const moved_jid = sizzle(`gone[xmlns="${Strophe.NS.STANZAS}"]`, error)
+                    .pop()
+                    ?.textContent.replace(/^xmpp:/, '')
+                    .replace(/\?join$/, '');
+                this.save({ moved_jid, 'destroyed_reason': reason });
+                this.session.save({ 'connection_status': converse.ROOMSTATUS.DESTROYED });
+            } else if (error.querySelector('conflict')) {
+                this.onNicknameClash(stanza);
+            } else if (error.querySelector('item-not-found')) {
+                const message = __('This groupchat does not (yet) exist.');
+                this.setDisconnectionMessage(message, reason);
+            } else if (error.querySelector('service-unavailable')) {
+                const message = __('This groupchat has reached its maximum number of participants.');
+                this.setDisconnectionMessage(message, reason);
+            } else if (error.querySelector('remote-server-not-found')) {
+                const message = __('Remote server not found');
+                const feedback = reason ? __('The explanation given is: "%1$s".', reason) : undefined;
+                this.setDisconnectionMessage(message, feedback);
+            }
+        }
+    },
+
+    /**
+     * Handles all MUC presence stanzas.
+     * @private
+     * @method _converse.ChatRoom#onPresence
+     * @param { XMLElement } stanza
+     */
+    onPresence (stanza) {
+        if (stanza.getAttribute('type') === 'error') {
+            return this.onErrorPresence(stanza);
+        }
+        this.createInfoMessages(stanza);
+        if (stanza.querySelector("status[code='110']")) {
+            this.onOwnPresence(stanza);
+            if (
+                this.getOwnRole() !== 'none' &&
+                this.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING
+            ) {
+                this.session.save('connection_status', converse.ROOMSTATUS.CONNECTED);
+            }
+        } else {
+            this.updateOccupantsOnPresence(stanza);
+        }
+    },
+
+    /**
+     * Handles a received presence relating to the current user.
+     *
+     * For locked groupchats (which are by definition "new"), the
+     * groupchat will either be auto-configured or created instantly
+     * (with default config) or a configuration groupchat will be
+     * rendered.
+     *
+     * If the groupchat is not locked, then the groupchat will be
+     * auto-configured only if applicable and if the current
+     * user is the groupchat's owner.
+     * @private
+     * @method _converse.ChatRoom#onOwnPresence
+     * @param { XMLElement } pres - The stanza
+     */
+    onOwnPresence (stanza) {
+        if (stanza.getAttribute('type') !== 'unavailable') {
+            const old_status = this.session.get('connection_status');
+            if (old_status !== converse.ROOMSTATUS.ENTERED) {
+                // Set connection_status before creating the occupant, but
+                // only trigger afterwards, so that plugins can access the
+                // occupant in their event handlers.
+                this.session.save('connection_status', converse.ROOMSTATUS.ENTERED, { 'silent': true });
+                this.updateOccupantsOnPresence(stanza);
+                this.session.trigger('change:connection_status', this.session, old_status);
+            } else {
+                this.updateOccupantsOnPresence(stanza);
+            }
+        } else {
+            this.updateOccupantsOnPresence(stanza);
+        }
+
+        if (stanza.getAttribute('type') === 'unavailable') {
+            this.handleDisconnection(stanza);
+        } else {
+            const locked_room = stanza.querySelector("status[code='201']");
+            if (locked_room) {
+                if (this.get('auto_configure')) {
+                    this.autoConfigureChatRoom().then(() => this.refreshDiscoInfo());
+                } else if (api.settings.get('muc_instant_rooms')) {
+                    // Accept default configuration
+                    this.sendConfiguration().then(() => this.refreshDiscoInfo());
+                } else {
+                    /**
+                     * Triggered when a new room has been created which first needs to be configured
+                     * and when `auto_configure` is set to `false`.
+                     * Used by `_converse.ChatRoomView` in order to know when to render the
+                     * configuration form for a new room.
+                     * @event _converse.ChatRoom#configurationNeeded
+                     * @example _converse.api.listen.on('configurationNeeded', () => { ... });
+                     */
+                    this.trigger('configurationNeeded');
+                    return; // We haven't yet entered the groupchat, so bail here.
+                }
+            } else if (!this.features.get('fetched')) {
+                // The features for this groupchat weren't fetched.
+                // That must mean it's a new groupchat without locking
+                // (in which case Prosody doesn't send a 201 status),
+                // otherwise the features would have been fetched in
+                // the "initialize" method already.
+                if (this.getOwnAffiliation() === 'owner' && this.get('auto_configure')) {
+                    this.autoConfigureChatRoom().then(() => this.refreshDiscoInfo());
+                } else {
+                    this.getDiscoInfo();
+                }
+            }
+        }
+        this.session.save({ 'connection_status': converse.ROOMSTATUS.ENTERED });
+    },
+
+    /**
+     * Returns a boolean to indicate whether the current user
+     * was mentioned in a message.
+     * @private
+     * @method _converse.ChatRoom#isUserMentioned
+     * @param { String } - The text message
+     */
+    isUserMentioned (message) {
+        const nick = this.get('nick');
+        if (message.get('references').length) {
+            const mentions = message
+                .get('references')
+                .filter(ref => ref.type === 'mention')
+                .map(ref => ref.value);
+            return mentions.includes(nick);
+        } else {
+            return new RegExp(`\\b${nick}\\b`).test(message.get('message'));
+        }
+    },
+
+    /* Given a newly received message, update the unread counter if necessary.
+     * @private
+     * @method _converse.ChatRoom#handleUnreadMessage
+     * @param { XMLElement } - The <messsage> stanza
+     */
+    handleUnreadMessage (message) {
+        if (!message?.get('body')) {
+            return;
+        }
+        if (u.isNewMessage(message)) {
+            if (this.isHidden()) {
+                const settings = {
+                    'num_unread_general': this.get('num_unread_general') + 1
+                };
+                if (this.get('num_unread_general') === 0) {
+                    settings['first_unread_id'] = message.get('id');
+                }
+                if (this.isUserMentioned(message)) {
+                    settings.num_unread = this.get('num_unread') + 1;
+                }
+                this.save(settings);
+            } else {
+                this.sendMarkerForMessage(message);
+            }
+        }
+    },
+
+    clearUnreadMsgCounter () {
+        if (this.get('num_unread_general') > 0 || this.get('num_unread') > 0) {
+            this.sendMarkerForMessage(this.messages.last());
+        }
+        u.safeSave(this, {
+            'num_unread': 0,
+            'num_unread_general': 0
+        });
+    }
+};
+
+export default ChatRoomMixin;

+ 57 - 0
src/headless/plugins/muc/occupant.js

@@ -0,0 +1,57 @@
+import u from '../../utils/form';
+import { Model } from '@converse/skeletor/src/model.js';
+import { _converse, api } from '../../core.js';
+
+/**
+ * Represents a participant in a MUC
+ * @class
+ * @namespace _converse.ChatRoomOccupant
+ * @memberOf _converse
+ */
+const ChatRoomOccupant = Model.extend({
+    defaults: {
+        'hats': [],
+        'show': 'offline',
+        'states': []
+    },
+
+    initialize (attributes) {
+        this.set(Object.assign({ 'id': u.getUniqueId() }, attributes));
+        this.on('change:image_hash', this.onAvatarChanged, this);
+    },
+
+    onAvatarChanged () {
+        const hash = this.get('image_hash');
+        const vcards = [];
+        if (this.get('jid')) {
+            vcards.push(_converse.vcards.findWhere({ 'jid': this.get('jid') }));
+        }
+        vcards.push(_converse.vcards.findWhere({ 'jid': this.get('from') }));
+
+        vcards
+            .filter(v => v)
+            .forEach(vcard => {
+                if (hash && vcard.get('image_hash') !== hash) {
+                    api.vcard.update(vcard, true);
+                }
+            });
+    },
+
+    getDisplayName () {
+        return this.get('nick') || this.get('jid');
+    },
+
+    isMember () {
+        return ['admin', 'owner', 'member'].includes(this.get('affiliation'));
+    },
+
+    isModerator () {
+        return ['admin', 'owner'].includes(this.get('affiliation')) || this.get('role') === 'moderator';
+    },
+
+    isSelf () {
+        return this.get('states').includes('110');
+    }
+});
+
+export default ChatRoomOccupant;

+ 112 - 0
src/headless/plugins/muc/occupants.js

@@ -0,0 +1,112 @@
+import ChatRoomOccupant from './occupant.js';
+import u from '../../utils/form';
+import { Collection } from '@converse/skeletor/src/collection';
+import { Strophe } from 'strophe.js/src/strophe';
+import { _converse, api } from '../../core.js';
+
+const MUC_ROLE_WEIGHTS = {
+    'moderator': 1,
+    'participant': 2,
+    'visitor': 3,
+    'none': 2
+};
+
+
+/**
+ * A list of {@link _converse.ChatRoomOccupant} instances, representing participants in a MUC.
+ * @class
+ * @namespace _converse.ChatRoomOccupants
+ * @memberOf _converse
+ */
+const ChatRoomOccupants = Collection.extend({
+    model: ChatRoomOccupant,
+
+    comparator (occupant1, occupant2) {
+        const role1 = occupant1.get('role') || 'none';
+        const role2 = occupant2.get('role') || 'none';
+        if (MUC_ROLE_WEIGHTS[role1] === MUC_ROLE_WEIGHTS[role2]) {
+            const nick1 = occupant1.getDisplayName().toLowerCase();
+            const nick2 = occupant2.getDisplayName().toLowerCase();
+            return nick1 < nick2 ? -1 : nick1 > nick2 ? 1 : 0;
+        } else {
+            return MUC_ROLE_WEIGHTS[role1] < MUC_ROLE_WEIGHTS[role2] ? -1 : 1;
+        }
+    },
+
+    getAutoFetchedAffiliationLists () {
+        const affs = api.settings.get('muc_fetch_members');
+        return Array.isArray(affs) ? affs : affs ? ['member', 'admin', 'owner'] : [];
+    },
+
+    async fetchMembers () {
+        const affiliations = this.getAutoFetchedAffiliationLists();
+        if (affiliations.length === 0) {
+            return;
+        }
+        const aff_lists = await Promise.all(affiliations.map(a => this.chatroom.getAffiliationList(a)));
+        const new_members = aff_lists.reduce((acc, val) => (u.isErrorObject(val) ? acc : [...val, ...acc]), []);
+        const known_affiliations = affiliations.filter(
+            a => !u.isErrorObject(aff_lists[affiliations.indexOf(a)])
+        );
+        const new_jids = new_members.map(m => m.jid).filter(m => m !== undefined);
+        const new_nicks = new_members.map(m => (!m.jid && m.nick) || undefined).filter(m => m !== undefined);
+        const removed_members = this.filter(m => {
+            return (
+                known_affiliations.includes(m.get('affiliation')) &&
+                !new_nicks.includes(m.get('nick')) &&
+                !new_jids.includes(m.get('jid'))
+            );
+        });
+
+        removed_members.forEach(occupant => {
+            if (occupant.get('jid') === _converse.bare_jid) {
+                return;
+            }
+            if (occupant.get('show') === 'offline') {
+                occupant.destroy();
+            } else {
+                occupant.save('affiliation', null);
+            }
+        });
+        new_members.forEach(attrs => {
+            const occupant = attrs.jid
+                ? this.findOccupant({ 'jid': attrs.jid })
+                : this.findOccupant({ 'nick': attrs.nick });
+            if (occupant) {
+                occupant.save(attrs);
+            } else {
+                this.create(attrs);
+            }
+        });
+        /**
+         * Triggered once the member lists for this MUC have been fetched and processed.
+         * @event _converse#membersFetched
+         * @example _converse.api.listen.on('membersFetched', () => { ... });
+         */
+        api.trigger('membersFetched');
+    },
+
+    /**
+     * @typedef { Object} OccupantData
+     * @property { String } [jid]
+     * @property { String } [nick]
+     */
+    /**
+     * Try to find an existing occupant based on the passed in
+     * data object.
+     *
+     * If we have a JID, we use that as lookup variable,
+     * otherwise we use the nick. We don't always have both,
+     * but should have at least one or the other.
+     * @private
+     * @method _converse.ChatRoomOccupants#findOccupant
+     * @param { OccupantData } data
+     */
+    findOccupant (data) {
+        const jid = Strophe.getBareJidFromJid(data.jid);
+        return (jid && this.findWhere({ jid })) || this.findWhere({ 'nick': data.nick });
+    }
+});
+
+
+export default ChatRoomOccupants;

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

@@ -2,7 +2,7 @@ import BootstrapModal from "./base.js";
 import log from "@converse/headless/log";
 import sizzle from "sizzle";
 import tpl_moderator_tools_modal from "./templates/moderator-tools.js";
-import { AFFILIATIONS, ROLES } from "@converse/headless/plugins/muc.js";
+import { AFFILIATIONS, ROLES } from "@converse/headless/plugins/muc/index.js";
 import { __ } from '../i18n';
 import { api, converse } from "@converse/headless/core";
 

+ 1 - 1
src/plugins/bookmark-views.js

@@ -4,7 +4,7 @@
  * @copyright 2020, the Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  */
-import "@converse/headless/plugins/muc";
+import "@converse/headless/plugins/muc/index.js";
 import { _converse, api, converse } from "@converse/headless/core";
 import tpl_bookmarks_list from "../templates/bookmarks_list.js"
 import tpl_muc_bookmark_form from "../templates/muc_bookmark_form.js";

+ 1 - 1
src/plugins/fullscreen.js

@@ -6,7 +6,7 @@
 import "./chatview/index.js";
 import "./controlbox/index.js";
 import "./singleton.js";
-import "@converse/headless/plugins/muc";
+import "@converse/headless/plugins/muc/index.js";
 import { api, converse } from "@converse/headless/core";
 
 

+ 1 - 1
src/plugins/roomslist.js

@@ -6,7 +6,7 @@
  * @copyright 2020, the Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  */
-import "@converse/headless/plugins/muc";
+import "@converse/headless/plugins/muc/index.js";
 import RoomDetailsModal from 'modals/muc-details.js';
 import { _converse, api, converse } from "@converse/headless/core";
 import tpl_rooms_list from "../templates/rooms_list.js";