Parcourir la source

Split MUC views into separate file/plugin

JC Brand il y a 7 ans
Parent
commit
0013ee5506
8 fichiers modifiés avec 2836 ajouts et 2620 suppressions
  1. 1 0
      src/config.js
  2. 1 1
      src/converse-bookmarks.js
  3. 1 0
      src/converse-core.js
  4. 1 1
      src/converse-mam.js
  5. 1 1
      src/converse-minimize.js
  6. 2661 0
      src/converse-muc-views.js
  7. 169 2617
      src/converse-muc.js
  8. 1 0
      src/converse.js

+ 1 - 0
src/config.js

@@ -76,6 +76,7 @@ require.config({
         "converse-minimize":        "src/converse-minimize",
         "converse-minimize":        "src/converse-minimize",
         "converse-muc":             "src/converse-muc",
         "converse-muc":             "src/converse-muc",
         "converse-muc-embedded":    "src/converse-muc-embedded",
         "converse-muc-embedded":    "src/converse-muc-embedded",
+        "converse-muc-views":       "src/converse-muc-views",
         "converse-notification":    "src/converse-notification",
         "converse-notification":    "src/converse-notification",
         "converse-otr":             "src/converse-otr",
         "converse-otr":             "src/converse-otr",
         "converse-ping":            "src/converse-ping",
         "converse-ping":            "src/converse-ping",

+ 1 - 1
src/converse-bookmarks.js

@@ -42,7 +42,7 @@
          *
          *
          * NB: These plugins need to have already been loaded via require.js.
          * NB: These plugins need to have already been loaded via require.js.
          */
          */
-        dependencies: ["converse-chatboxes", "converse-muc"],
+        dependencies: ["converse-chatboxes", "converse-muc", "converse-muc-views"],
 
 
         overrides: {
         overrides: {
             // Overrides mentioned here will be picked up by converse.js's
             // Overrides mentioned here will be picked up by converse.js's

+ 1 - 0
src/converse-core.js

@@ -80,6 +80,7 @@
         'converse-minimize',
         'converse-minimize',
         'converse-muc',
         'converse-muc',
         'converse-muc-embedded',
         'converse-muc-embedded',
+        'converse-muc-views',
         'converse-notification',
         'converse-notification',
         'converse-otr',
         'converse-otr',
         'converse-ping',
         'converse-ping',

+ 1 - 1
src/converse-mam.js

@@ -119,7 +119,7 @@
 
 
     converse.plugins.add('converse-mam', {
     converse.plugins.add('converse-mam', {
 
 
-        dependencies: ['converse-chatview', 'converse-muc'],
+        dependencies: ['converse-chatview', 'converse-muc', 'converse-muc-views'],
 
 
         overrides: {
         overrides: {
             // Overrides mentioned here will be picked up by converse.js's
             // Overrides mentioned here will be picked up by converse.js's

+ 1 - 1
src/converse-minimize.js

@@ -39,7 +39,7 @@
          *
          *
          * NB: These plugins need to have already been loaded via require.js.
          * NB: These plugins need to have already been loaded via require.js.
          */
          */
-        dependencies: ["converse-chatview", "converse-controlbox", "converse-muc", "converse-headline"],
+        dependencies: ["converse-chatview", "converse-controlbox", "converse-muc", "converse-muc-views", "converse-headline"],
 
 
         enabled (_converse) {
         enabled (_converse) {
             return _converse.view_mode == 'overlayed';
             return _converse.view_mode == 'overlayed';

+ 2661 - 0
src/converse-muc-views.js

@@ -0,0 +1,2661 @@
+// Converse.js (A browser based XMPP chat client)
+// http://conversejs.org
+//
+// Copyright (c) 2012-2018, Jan-Carel Brand <jc@opkode.com>
+// Licensed under the Mozilla Public License (MPLv2)
+//
+
+/* This is a Converse.js plugin which add support for multi-user chat rooms, as
+ * specified in XEP-0045 Multi-user chat.
+ */
+(function (root, factory) {
+    define([
+        "converse-core",
+        "emojione",
+        "tpl!add_chatroom_modal",
+        "tpl!chatarea",
+        "tpl!chatroom",
+        "tpl!chatroom_disconnect",
+        "tpl!chatroom_features",
+        "tpl!chatroom_form",
+        "tpl!chatroom_head",
+        "tpl!chatroom_invite",
+        "tpl!chatroom_join_form",
+        "tpl!chatroom_nickname_form",
+        "tpl!chatroom_password_form",
+        "tpl!chatroom_sidebar",
+        "tpl!chatroom_toolbar",
+        "tpl!chatrooms_tab",
+        "tpl!info",
+        "tpl!list_chatrooms_modal",
+        "tpl!occupant",
+        "tpl!room_description",
+        "tpl!room_item",
+        "tpl!room_panel",
+        "tpl!rooms_results",
+        "tpl!spinner",
+        "awesomplete",
+        "converse-chatview"
+    ], factory);
+}(this, function (
+    converse,
+    emojione,
+    tpl_add_chatroom_modal,
+    tpl_chatarea,
+    tpl_chatroom,
+    tpl_chatroom_disconnect,
+    tpl_chatroom_features,
+    tpl_chatroom_form,
+    tpl_chatroom_head,
+    tpl_chatroom_invite,
+    tpl_chatroom_join_form,
+    tpl_chatroom_nickname_form,
+    tpl_chatroom_password_form,
+    tpl_chatroom_sidebar,
+    tpl_chatroom_toolbar,
+    tpl_chatrooms_tab,
+    tpl_info,
+    tpl_list_chatrooms_modal,
+    tpl_occupant,
+    tpl_room_description,
+    tpl_room_item,
+    tpl_room_panel,
+    tpl_rooms_results,
+    tpl_spinner,
+    Awesomplete
+) {
+    "use strict";
+
+    const { Backbone, Promise, Strophe, b64_sha1, f, moment, sizzle, _, $build, $iq, $msg, $pres } = converse.env;
+    const u = converse.env.utils;
+
+    const ROOMS_PANEL_ID = 'chatrooms';
+    const CHATROOMS_TYPE = 'chatroom';
+
+    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.plugins.add('converse-muc-views', {
+        /* Dependencies are other plugins which might be
+         * overridden or relied upon, and therefore need to be loaded before
+         * this plugin. They are "optional" because they might not be
+         * available, in which case any overrides applicable to them will be
+         * ignored.
+         *
+         * NB: These plugins need to have already been loaded via require.js.
+         *
+         * It's possible to make these dependencies "non-optional".
+         * If the setting "strict_plugin_dependencies" is set to true,
+         * an error will be raised if the plugin is not found.
+         */
+        dependencies: ["converse-controlbox", "converse-chatview"],
+
+        overrides: {
+
+            ControlBoxView: {
+                renderRoomsPanel () {
+                    const { _converse } = this.__super__;
+                    this.roomspanel = new _converse.RoomsPanel({
+                        'parent': this.el.querySelector('.controlbox-panes'),
+                        'model': new (_converse.RoomsPanelModel.extend({
+                            id: b64_sha1(`converse.roomspanel${_converse.bare_jid}`), // Required by sessionStorage
+                            browserStorage: new Backbone.BrowserStorage[_converse.storage](
+                                b64_sha1(`converse.roomspanel${_converse.bare_jid}`))
+                        }))()
+                    });
+                    this.roomspanel.insertIntoDOM().model.fetch();
+                    if (!this.roomspanel.model.get('nick')) {
+                        this.roomspanel.model.save({
+                            nick: Strophe.getNodeFromJid(_converse.bare_jid)
+                        });
+                    }
+                    _converse.emit('roomsPanelRendered');
+                },
+
+                renderContactsPanel () {
+                    const { _converse } = this.__super__;
+                    this.__super__.renderContactsPanel.apply(this, arguments);
+                    if (_converse.allow_muc) {
+                        this.renderRoomsPanel();
+                    }
+                },
+            },
+
+            ChatBoxViews: {
+                onChatBoxAdded (item) {
+                    const { _converse } = this.__super__;
+                    let view = this.get(item.get('id'));
+                    if (!view && item.get('type') === converse.CHATROOMS_TYPE) {
+                        view = new _converse.ChatRoomView({'model': item});
+                        return this.add(item.get('id'), view);
+                    } else {
+                        return this.__super__.onChatBoxAdded.apply(this, arguments);
+                    }
+                }
+            }
+        },
+
+        initialize () {
+            const { _converse } = this,
+                  { __ } = _converse;
+
+            _converse.api.promises.add(['roomsPanelRendered']);
+
+
+            function insertRoomInfo (el, stanza) {
+                /* Insert room info (based on returned #disco IQ stanza)
+                 *
+                 * Parameters:
+                 *  (HTMLElement) el: The HTML DOM element that should
+                 *      contain the info.
+                 *  (XMLElement) stanza: The IQ stanza containing the room
+                 *      info.
+                 */
+                // All MUC features found here: http://xmpp.org/registrar/disco-features.html
+                el.querySelector('span.spinner').remove();
+                el.querySelector('a.room-info').classList.add('selected');
+                el.insertAdjacentHTML(
+                    'beforeEnd', 
+                    tpl_room_description({
+                        'jid': stanza.getAttribute('from'),
+                        'desc': _.get(_.head(sizzle('field[var="muc#roominfo_description"] value', stanza)), 'textContent'),
+                        'occ': _.get(_.head(sizzle('field[var="muc#roominfo_occupants"] value', stanza)), 'textContent'),
+                        'hidden': sizzle('feature[var="muc_hidden"]', stanza).length,
+                        'membersonly': sizzle('feature[var="muc_membersonly"]', stanza).length,
+                        'moderated': sizzle('feature[var="muc_moderated"]', stanza).length,
+                        'nonanonymous': sizzle('feature[var="muc_nonanonymous"]', stanza).length,
+                        'open': sizzle('feature[var="muc_open"]', stanza).length,
+                        'passwordprotected': sizzle('feature[var="muc_passwordprotected"]', stanza).length,
+                        'persistent': sizzle('feature[var="muc_persistent"]', stanza).length,
+                        'publicroom': sizzle('feature[var="muc_publicroom"]', stanza).length,
+                        'semianonymous': sizzle('feature[var="muc_semianonymous"]', stanza).length,
+                        'temporary': sizzle('feature[var="muc_temporary"]', stanza).length,
+                        'unmoderated': sizzle('feature[var="muc_unmoderated"]', stanza).length,
+                        'label_desc': __('Description:'),
+                        'label_jid': __('Room Address (JID):'),
+                        'label_occ': __('Occupants:'),
+                        'label_features': __('Features:'),
+                        'label_requires_auth': __('Requires authentication'),
+                        'label_hidden': __('Hidden'),
+                        'label_requires_invite': __('Requires an invitation'),
+                        'label_moderated': __('Moderated'),
+                        'label_non_anon': __('Non-anonymous'),
+                        'label_open_room': __('Open room'),
+                        'label_permanent_room': __('Permanent room'),
+                        'label_public': __('Public'),
+                        'label_semi_anon':  __('Semi-anonymous'),
+                        'label_temp_room':  __('Temporary room'),
+                        'label_unmoderated': __('Unmoderated')
+                    }));
+            }
+
+            function toggleRoomInfo (ev) {
+                /* Show/hide extra information about a room in a listing. */
+                const parent_el = u.ancestor(ev.target, '.room-item'),
+                        div_el = parent_el.querySelector('div.room-info');
+                if (div_el) {
+                    u.slideIn(div_el).then(u.removeElement)
+                    parent_el.querySelector('a.room-info').classList.remove('selected');
+                } else {
+                    parent_el.insertAdjacentHTML('beforeend', tpl_spinner());
+                    _converse.connection.disco.info(
+                        ev.target.getAttribute('data-room-jid'),
+                        null,
+                        _.partial(insertRoomInfo, parent_el)
+                    );
+                }
+            }
+
+
+            _converse.ChatRoomView = _converse.ChatBoxView.extend({
+                /* Backbone.NativeView which renders a chat room, based upon the view
+                 * for normal one-on-one chat boxes.
+                 */
+                length: 300,
+                tagName: 'div',
+                className: 'chatbox chatroom hidden',
+                is_chatroom: true,
+                events: {
+                    'click .close-chatbox-button': 'close',
+                    'click .configure-chatroom-button': 'getAndRenderConfigurationForm',
+                    'click .toggle-smiley': 'toggleEmojiMenu',
+                    'click .toggle-smiley ul.emoji-picker li': 'insertEmoji',
+                    'click .toggle-clear': 'clearChatRoomMessages',
+                    'click .toggle-call': 'toggleCall',
+                    'click .toggle-occupants a': 'toggleOccupants',
+                    'click .new-msgs-indicator': 'viewUnreadMessages',
+                    'click .occupant': 'onOccupantClicked',
+                    'keypress .chat-textarea': 'keyPressed',
+                    'click .send-button': 'onFormSubmitted'
+                },
+
+                initialize () {
+                    this.scrollDown = _.debounce(this._scrollDown, 250);
+                    this.markScrolled = _.debounce(this._markScrolled, 100);
+
+                    this.model.messages.on('add', this.onMessageAdded, this);
+                    this.model.on('show', this.show, this);
+                    this.model.on('destroy', this.hide, this);
+                    this.model.on('change:connection_status', this.afterConnected, this);
+                    this.model.on('change:affiliation', this.renderHeading, this);
+                    this.model.on('change:chat_state', this.sendChatState, this);
+                    this.model.on('change:description', this.renderHeading, this);
+                    this.model.on('change:name', this.renderHeading, this);
+
+                    this.createEmojiPicker();
+                    this.createOccupantsView();
+                    this.render().insertIntoDOM();
+                    this.registerHandlers();
+
+                    if (this.model.get('connection_status') !==  converse.ROOMSTATUS.ENTERED) {
+                        const handler = () => {
+                            this.join();
+                            this.fetchMessages();
+                            _converse.emit('chatRoomOpened', this);
+                        }
+                        this.getRoomFeatures().then(handler, handler);
+                    } else {
+                        this.fetchMessages();
+                        _converse.emit('chatRoomOpened', this);
+                    }
+                },
+
+                render () {
+                    this.el.setAttribute('id', this.model.get('box_id'));
+                    this.el.innerHTML = tpl_chatroom();
+                    this.renderHeading();
+                    this.renderChatArea();
+                    if (this.model.get('connection_status') !== converse.ROOMSTATUS.ENTERED) {
+                        this.showSpinner();
+                    }
+                    return this;
+                },
+
+                renderHeading () {
+                    /* Render the heading UI of the chat room. */
+                    this.el.querySelector('.chat-head-chatroom').innerHTML = this.generateHeadingHTML();
+                },
+
+                renderChatArea () {
+                    /* Render the UI container in which chat room messages will appear.
+                     */
+                    if (_.isNull(this.el.querySelector('.chat-area'))) {
+                        const container_el = this.el.querySelector('.chatroom-body');
+                        container_el.innerHTML = tpl_chatarea({
+                            'label_message': __('Message'),
+                            'label_send': __('Send'),
+                            'show_send_button': _converse.show_send_button,
+                            'show_toolbar': _converse.show_toolbar,
+                            'unread_msgs': __('You have unread messages')
+                        });
+                        container_el.insertAdjacentElement('beforeend', this.occupantsview.el);
+                        this.renderToolbar(tpl_chatroom_toolbar);
+                        this.content = this.el.querySelector('.chat-content');
+                        this.toggleOccupants(null, true);
+                    }
+                    return this;
+                },
+
+                createOccupantsView () {
+                    /* Create the ChatRoomOccupantsView Backbone.NativeView
+                     */
+                    const model = new _converse.ChatRoomOccupants();
+                    model.chatroomview = this;
+                    this.occupantsview = new _converse.ChatRoomOccupantsView({'model': model});
+                    this.occupantsview.model.on('change:role', this.informOfOccupantsRoleChange, this);
+                    return this;
+                },
+
+                informOfOccupantsRoleChange (occupant, changed) {
+                    const previous_role = occupant._previousAttributes.role;
+                    if (previous_role === 'moderator') {
+                        this.showStatusNotification(
+                            __("%1$s is no longer a moderator.", occupant.get('nick')),
+                            false, true)
+                    }
+                    if (previous_role === 'visitor') {
+                        this.showStatusNotification(
+                            __("%1$s has been given a voice again.", occupant.get('nick')),
+                            false, true)
+                    }
+
+                    if (occupant.get('role') === 'visitor') {
+                        this.showStatusNotification(
+                            __("%1$s has been muted.", occupant.get('nick')),
+                            false, true)
+                    }
+                    if (occupant.get('role') === 'moderator') {
+                        this.showStatusNotification(
+                            __("%1$s is now a moderator.", occupant.get('nick')),
+                            false, true)
+                    }
+                },
+
+                generateHeadingHTML () {
+                    /* Returns the heading HTML to be rendered.
+                     */
+                    return tpl_chatroom_head(
+                        _.extend(this.model.toJSON(), {
+                            Strophe: Strophe,
+                            info_close: __('Close and leave this room'),
+                            info_configure: __('Configure this room'),
+                            description: this.model.get('description') || ''
+                    }));
+                },
+
+                afterShown (focus) {
+                    /* Override from converse-chatview, specifically to avoid
+                     * the 'active' chat state from being sent out prematurely.
+                     *
+                     * This is instead done in `afterConnected` below.
+                     */
+                    if (u.isPersistableModel(this.model)) {
+                        this.model.clearUnreadMsgCounter();
+                        this.model.save();
+                    }
+                    this.occupantsview.setOccupantsHeight();
+                    this.scrollDown();
+                    if (focus) { this.focus(); }
+                },
+
+                show (focus) {
+                    if (u.isVisible(this.el)) {
+                        if (focus) { this.focus(); }
+                        return;
+                    }
+                    // Override from converse-chatview in order to not use
+                    // "fadeIn", which causes flashing.
+                    u.showElement(this.el);
+                    this.afterShown(focus);
+                },
+
+                afterConnected () {
+                    if (this.model.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
+                        this.setChatState(_converse.ACTIVE);
+                        this.scrollDown();
+                        this.renderEmojiPicker();
+                        this.focus();
+                    }
+                },
+
+                getExtraMessageClasses (attrs) {
+                    let extra_classes = _converse.ChatBoxView.prototype
+                            .getExtraMessageClasses.apply(this, arguments);
+
+                    if (this.is_chatroom && attrs.sender === 'them' &&
+                            this.model.isUserMentioned(attrs.message)) {
+                        // Add special class to mark groupchat messages
+                        // in which we are mentioned.
+                        extra_classes += ' mentioned';
+                    }
+                    return extra_classes;
+                },
+
+                getToolbarOptions () {
+                    return _.extend(
+                        _converse.ChatBoxView.prototype.getToolbarOptions.apply(this, arguments),
+                        {
+                          label_hide_occupants: __('Hide the list of occupants'),
+                          show_occupants_toggle: this.is_chatroom && _converse.visible_toolbar_buttons.toggle_occupants
+                        }
+                    );
+                },
+
+                close (ev) {
+                    /* Close this chat box, which implies leaving the room as
+                     * well.
+                     */
+                    this.leave();
+                },
+
+                setOccupantsVisibility () {
+                    if (this.model.get('hidden_occupants')) {
+                        const icon_el = this.el.querySelector('.icon-hide-users');
+                        if (!_.isNull(icon_el)) {
+                            icon_el.classList.remove('icon-hide-users');
+                            icon_el.classList.add('icon-show-users');
+                        }
+                        this.el.querySelector('.chat-area').classList.add('full');
+                        u.hideElement(this.el.querySelector('.occupants'));
+                    } else {
+                        const icon_el = this.el.querySelector('.icon-show-users');
+                        if (!_.isNull(icon_el)) {
+                            icon_el.classList.remove('icon-show-users');
+                            icon_el.classList.add('icon-hide-users');
+                        }
+                        this.el.querySelector('.chat-area').classList.remove('full');
+                        this.el.querySelector('.occupants').classList.remove('hidden');
+                    }
+                    this.occupantsview.setOccupantsHeight();
+                },
+
+                toggleOccupants (ev, preserve_state) {
+                    /* Show or hide the right sidebar containing the chat
+                     * occupants (and the invite widget).
+                     */
+                    if (ev) {
+                        ev.preventDefault();
+                        ev.stopPropagation();
+                    }
+                    if (!preserve_state) {
+                        this.model.set({'hidden_occupants': !this.model.get('hidden_occupants')});
+                    }
+                    this.setOccupantsVisibility();
+                    this.scrollDown();
+                },
+
+                onOccupantClicked (ev) {
+                    /* When an occupant is clicked, insert their nickname into
+                     * the chat textarea input.
+                     */
+                    this.insertIntoTextArea(ev.target.textContent);
+                },
+
+                requestMemberList (chatroom_jid, affiliation) {
+                    /* Send an IQ stanza to the server, asking it for the
+                     * member-list of this room.
+                     *
+                     * See: http://xmpp.org/extensions/xep-0045.html#modifymember
+                     *
+                     * Parameters:
+                     *  (String) chatroom_jid: The JID of the chatroom for
+                     *      which the member-list is being requested
+                     *  (String) affiliation: The specific member list to
+                     *      fetch. 'admin', 'owner' or 'member'.
+                     *
+                     * Returns:
+                     *  A promise which resolves once the list has been
+                     *  retrieved.
+                     */
+                    return new Promise((resolve, reject) => {
+                        affiliation = affiliation || 'member';
+                        const iq = $iq({to: chatroom_jid, type: "get"})
+                            .c("query", {xmlns: Strophe.NS.MUC_ADMIN})
+                                .c("item", {'affiliation': affiliation});
+                        _converse.connection.sendIQ(iq, resolve, reject);
+                    });
+                },
+
+                parseMemberListIQ (iq) {
+                    /* Given an IQ stanza with a member list, create an array of member
+                     * objects.
+                     */
+                    return _.map(
+                        sizzle(`query[xmlns="${Strophe.NS.MUC_ADMIN}"] item`, iq),
+                        (item) => ({
+                            'jid': item.getAttribute('jid'),
+                            'affiliation': item.getAttribute('affiliation'),
+                        })
+                    );
+                },
+
+                computeAffiliationsDelta (exclude_existing, remove_absentees, new_list, old_list) {
+                    /* Given two lists of objects with 'jid', 'affiliation' and
+                     * 'reason' properties, return a new list containing
+                     * those objects that are new, changed or removed
+                     * (depending on the 'remove_absentees' boolean).
+                     *
+                     * The affiliations for new and changed members stay the
+                     * same, for removed members, the affiliation is set to 'none'.
+                     *
+                     * The 'reason' property is not taken into account when
+                     * comparing whether affiliations have been changed.
+                     *
+                     * Parameters:
+                     *  (Boolean) exclude_existing: Indicates whether JIDs from
+                     *      the new list which are also in the old list
+                     *      (regardless of affiliation) should be excluded
+                     *      from the delta. One reason to do this
+                     *      would be when you want to add a JID only if it
+                     *      doesn't have *any* existing affiliation at all.
+                     *  (Boolean) remove_absentees: Indicates whether JIDs
+                     *      from the old list which are not in the new list
+                     *      should be considered removed and therefore be
+                     *      included in the delta with affiliation set
+                     *      to 'none'.
+                     *  (Array) new_list: Array containing the new affiliations
+                     *  (Array) old_list: Array containing the old affiliations
+                     */
+                    const new_jids = _.map(new_list, 'jid');
+                    const old_jids = _.map(old_list, 'jid');
+
+                    // Get the new affiliations
+                    let delta = _.map(
+                        _.difference(new_jids, old_jids),
+                        (jid) => new_list[_.indexOf(new_jids, jid)]
+                    );
+                    if (!exclude_existing) {
+                        // Get the changed affiliations
+                        delta = delta.concat(_.filter(new_list, function (item) {
+                            const idx = _.indexOf(old_jids, item.jid);
+                            if (idx >= 0) {
+                                return item.affiliation !== old_list[idx].affiliation;
+                            }
+                            return false;
+                        }));
+                    }
+                    if (remove_absentees) {
+                        // Get the removed affiliations
+                        delta = delta.concat(
+                            _.map(
+                                _.difference(old_jids, new_jids),
+                                (jid) => ({'jid': jid, 'affiliation': 'none'})
+                            )
+                        );
+                    }
+                    return delta;
+                },
+
+                sendAffiliationIQ (chatroom_jid, affiliation, member) {
+                    /* Send an IQ stanza specifying an affiliation change.
+                     *
+                     * Paremeters:
+                     *  (String) chatroom_jid: JID of the relevant room
+                     *  (String) affiliation: affiliation (could also be stored
+                     *      on the member object).
+                     *  (Object) member: Map containing the member's jid and
+                     *      optionally a reason and affiliation.
+                     */
+                    return new Promise((resolve, reject) => {
+                        const iq = $iq({to: chatroom_jid, type: "set"})
+                            .c("query", {xmlns: Strophe.NS.MUC_ADMIN})
+                            .c("item", {
+                                'affiliation': member.affiliation || affiliation,
+                                'jid': member.jid
+                            });
+                        if (!_.isUndefined(member.reason)) {
+                            iq.c("reason", member.reason);
+                        }
+                        _converse.connection.sendIQ(iq, resolve, reject);
+                    });
+                },
+
+                setAffiliation (affiliation, members) {
+                    /* Send IQ stanzas to the server to set an affiliation for
+                     * the provided JIDs.
+                     *
+                     * See: http://xmpp.org/extensions/xep-0045.html#modifymember
+                     *
+                     * XXX: 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://prosody.im/issues/issue/795
+                     *
+                     * Parameters:
+                     *  (String) affiliation: The affiliation
+                     *  (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:
+                     *  A promise which resolves and fails depending on the
+                     *  XMPP server response.
+                     */
+                    members = _.filter(members, (member) =>
+                        // We only want those members who have the right
+                        // affiliation (or none, which implies the provided
+                        // one).
+                        _.isUndefined(member.affiliation) ||
+                                member.affiliation === affiliation
+                    );
+                    const promises = _.map(
+                        members,
+                        _.partial(this.sendAffiliationIQ, this.model.get('jid'), affiliation)
+                    );
+                    return Promise.all(promises);
+                },
+
+                setAffiliations (members) {
+                    /* Send IQ stanzas to the server to modify the
+                     * affiliations in this room.
+                     *
+                     * See: http://xmpp.org/extensions/xep-0045.html#modifymember
+                     *
+                     * Parameters:
+                     *  (Object) members: A map of jids, affiliations and optionally reasons
+                     *  (Function) onSuccess: callback for a succesful response
+                     *  (Function) onError: callback for an error response
+                     */
+                    const affiliations = _.uniq(_.map(members, 'affiliation'));
+                    _.each(affiliations, _.partial(this.setAffiliation.bind(this), _, members));
+                },
+
+                marshallAffiliationIQs () {
+                    /* Marshall a list of IQ stanzas into a map of JIDs and
+                     * affiliations.
+                     *
+                     * Parameters:
+                     *  Any amount of XMLElement objects, representing the IQ
+                     *  stanzas.
+                     */
+                    return _.flatMap(arguments[0], this.parseMemberListIQ);
+                },
+
+                getJidsWithAffiliations (affiliations) {
+                    /* Returns a map of JIDs that have the affiliations
+                     * as provided.
+                     */
+                    if (_.isString(affiliations)) {
+                        affiliations = [affiliations];
+                    }
+                    return new Promise((resolve, reject) => {
+                        const promises = _.map(
+                            affiliations,
+                            _.partial(this.requestMemberList, this.model.get('jid'))
+                        );
+
+                        Promise.all(promises).then(
+                            _.flow(this.marshallAffiliationIQs.bind(this), resolve),
+                            _.flow(this.marshallAffiliationIQs.bind(this), resolve)
+                        );
+                    });
+                },
+
+                updateMemberLists (members, affiliations, deltaFunc) {
+                    /* 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.
+                     *
+                     * Parameters:
+                     *  (Object) members: Map of member jids and affiliations.
+                     *  (String|Array) affiliation: An array of affiliations or
+                     *      a string if only one affiliation.
+                     *  (Function) deltaFunc: The function to compute the delta
+                     *      between old and new member lists.
+                     *
+                     * Returns:
+                     *  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.
+                     */
+                    this.getJidsWithAffiliations(affiliations).then((old_members) => {
+                        this.setAffiliations(deltaFunc(members, old_members));
+                    });
+                },
+
+                directInvite (recipient, reason) {
+                    /* Send a direct invitation as per XEP-0249
+                     *
+                     * Parameters:
+                     *    (String) recipient - JID of the person being invited
+                     *    (String) reason - Optional reason for the invitation
+                     */
+                    if (this.model.get('membersonly')) {
+                        // When inviting to a members-only room, we first add
+                        // the person to the member list by giving them an
+                        // affiliation of 'member' (if they're not affiliated
+                        // already), otherwise they won't be able to join.
+                        const map = {}; map[recipient] = 'member';
+                        const deltaFunc = _.partial(this.computeAffiliationsDelta, true, false);
+                        this.updateMemberLists(
+                            [{'jid': recipient, 'affiliation': 'member', 'reason': reason}],
+                            ['member', 'owner', 'admin'],
+                            deltaFunc
+                        );
+                    }
+                    const attrs = {
+                        'xmlns': 'jabber:x:conference',
+                        'jid': this.model.get('jid')
+                    };
+                    if (reason !== null) { attrs.reason = reason; }
+                    if (this.model.get('password')) { attrs.password = this.model.get('password'); }
+                    const invitation = $msg({
+                        from: _converse.connection.jid,
+                        to: recipient,
+                        id: _converse.connection.getUniqueId()
+                    }).c('x', attrs);
+                    _converse.connection.send(invitation);
+                    _converse.emit('roomInviteSent', {
+                        'room': this,
+                        'recipient': recipient,
+                        'reason': reason
+                    });
+                },
+
+                handleChatStateMessage (message) {
+                    /* Override the method on the ChatBoxView base class to
+                     * ignore <gone/> notifications in groupchats.
+                     *
+                     * As laid out in the business rules in XEP-0085
+                     * http://xmpp.org/extensions/xep-0085.html#bizrules-groupchat
+                     */
+                    if (message.get('fullname') === this.model.get('nick')) {
+                        // Don't know about other servers, but OpenFire sends
+                        // back to you your own chat state notifications.
+                        // We ignore them here...
+                        return;
+                    }
+                    if (message.get('chat_state') !== _converse.GONE) {
+                        _converse.ChatBoxView.prototype.handleChatStateMessage.apply(this, arguments);
+                    }
+                },
+
+                sendChatState () {
+                    /* Sends a message with the status of the user in this chat session
+                     * as taken from the 'chat_state' attribute of the chat box.
+                     * See XEP-0085 Chat State Notifications.
+                     */
+                    if (this.model.get('connection_status') !==  converse.ROOMSTATUS.ENTERED) {
+                        return;
+                    }
+                    const chat_state = this.model.get('chat_state');
+                    if (chat_state === _converse.GONE) {
+                        // <gone/> is not applicable within MUC context
+                        return;
+                    }
+                    _converse.connection.send(
+                        $msg({'to':this.model.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})
+                    );
+                },
+
+                sendChatRoomMessage (text) {
+                    /* Constuct a message stanza to be sent to this chat room,
+                     * and send it to the server.
+                     *
+                     * Parameters:
+                     *  (String) text: The message text to be sent.
+                     */
+                    text = emojione.shortnameToUnicode(text)
+                    const msgid = _converse.connection.getUniqueId();
+                    const msg = $msg({
+                        to: this.model.get('jid'),
+                        from: _converse.connection.jid,
+                        type: 'groupchat',
+                        id: msgid
+                    }).c("body").t(text).up()
+                    .c("x", {xmlns: "jabber:x:event"}).c(_converse.COMPOSING);
+                    _converse.connection.send(msg);
+                    this.model.messages.create({
+                        fullname: this.model.get('nick'),
+                        sender: 'me',
+                        time: moment().format(),
+                        message: text,
+                        msgid
+                    });
+                },
+
+                modifyRole(room, nick, role, reason, onSuccess, onError) {
+                    const item = $build("item", {nick, role});
+                    const iq = $iq({to: room, type: "set"}).c("query", {xmlns: Strophe.NS.MUC_ADMIN}).cnode(item.node);
+                    if (reason !== null) { iq.c("reason", reason); }
+                    return _converse.connection.sendIQ(iq, onSuccess, onError);
+                },
+
+                validateRoleChangeCommand (command, args) {
+                    /* Check that a command to change a chat room user's role or
+                     * affiliation has anough arguments.
+                     */
+                    // TODO check if first argument is valid
+                    if (args.length < 1 || args.length > 2) {
+                        this.showStatusNotification(
+                            __('Error: the "%1$s" command takes two arguments, the user\'s nickname and optionally a reason.',
+                                command),
+                            true
+                        );
+                        return false;
+                    }
+                    return true;
+                },
+
+                clearChatRoomMessages (ev) {
+                    /* Remove all messages from the chat room UI.
+                     */
+                    if (!_.isUndefined(ev)) { ev.stopPropagation(); }
+                    const result = confirm(__("Are you sure you want to clear the messages from this room?"));
+                    if (result === true) {
+                        this.content.innerHTML = '';
+                    }
+                    return this;
+                },
+
+                onCommandError () {
+                    this.showStatusNotification(__("Error: could not execute the command"), true);
+                },
+
+                onMessageSubmitted (text) {
+                    /* Gets called when the user presses enter to send off a
+                     * message in a chat room.
+                     *
+                     * Parameters:
+                     *    (String) text - The message text.
+                     */
+                    if (_converse.muc_disable_moderator_commands) {
+                        return this.sendChatRoomMessage(text);
+                    }
+                    const match = text.replace(/^\s*/, "").match(/^\/(.*?)(?: (.*))?$/) || [false, '', ''],
+                        args = match[2] && match[2].splitOnce(' ') || [],
+                        command = match[1].toLowerCase();
+                    switch (command) {
+                        case 'admin':
+                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                            this.setAffiliation('admin',
+                                    [{ 'jid': args[0],
+                                       'reason': args[1]
+                                    }]).then(null, this.onCommandError.bind(this));
+                            break;
+                        case 'ban':
+                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                            this.setAffiliation('outcast',
+                                    [{ 'jid': args[0],
+                                       'reason': args[1]
+                                    }]).then(null, this.onCommandError.bind(this));
+                            break;
+                        case 'clear':
+                            this.clearChatRoomMessages();
+                            break;
+                        case 'deop':
+                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                            this.modifyRole(
+                                    this.model.get('jid'), args[0], 'participant', args[1],
+                                    undefined, this.onCommandError.bind(this));
+                            break;
+                        case 'help':
+                            this.showHelpMessages([
+                                `<strong>/admin</strong>: ${__("Change user's affiliation to admin")}`,
+                                `<strong>/ban</strong>: ${__('Ban user from room')}`,
+                                `<strong>/clear</strong>: ${__('Remove messages')}`,
+                                `<strong>/deop</strong>: ${__('Change user role to participant')}`,
+                                `<strong>/help</strong>: ${__('Show this menu')}`,
+                                `<strong>/kick</strong>: ${__('Kick user from room')}`,
+                                `<strong>/me</strong>: ${__('Write in 3rd person')}`,
+                                `<strong>/member</strong>: ${__('Grant membership to a user')}`,
+                                `<strong>/mute</strong>: ${__("Remove user's ability to post messages")}`,
+                                `<strong>/nick</strong>: ${__('Change your nickname')}`,
+                                `<strong>/op</strong>: ${__('Grant moderator role to user')}`,
+                                `<strong>/owner</strong>: ${__('Grant ownership of this room')}`,
+                                `<strong>/revoke</strong>: ${__("Revoke user's membership")}`,
+                                `<strong>/subject</strong>: ${__('Set room subject')}`,
+                                `<strong>/topic</strong>: ${__('Set room subject (alias for /subject)')}`,
+                                `<strong>/voice</strong>: ${__('Allow muted user to post messages')}`
+                            ]);
+                            break;
+                        case 'kick':
+                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                            this.modifyRole(
+                                    this.model.get('jid'), args[0], 'none', args[1],
+                                    undefined, this.onCommandError.bind(this));
+                            break;
+                        case 'mute':
+                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                            this.modifyRole(
+                                    this.model.get('jid'), args[0], 'visitor', args[1],
+                                    undefined, this.onCommandError.bind(this));
+                            break;
+                        case 'member':
+                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                            this.setAffiliation('member',
+                                    [{ 'jid': args[0],
+                                       'reason': args[1]
+                                    }]).then(null, this.onCommandError.bind(this));
+                            break;
+                        case 'nick':
+                            _converse.connection.send($pres({
+                                from: _converse.connection.jid,
+                                to: this.getRoomJIDAndNick(match[2]),
+                                id: _converse.connection.getUniqueId()
+                            }).tree());
+                            break;
+                        case 'owner':
+                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                            this.setAffiliation('owner',
+                                    [{ 'jid': args[0],
+                                       'reason': args[1]
+                                    }]).then(null, this.onCommandError.bind(this));
+                            break;
+                        case 'op':
+                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                            this.modifyRole(
+                                    this.model.get('jid'), args[0], 'moderator', args[1],
+                                    undefined, this.onCommandError.bind(this));
+                            break;
+                        case 'revoke':
+                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                            this.setAffiliation('none',
+                                    [{ 'jid': args[0],
+                                       'reason': args[1]
+                                    }]).then(null, this.onCommandError.bind(this));
+                            break;
+                        case 'topic':
+                        case 'subject':
+                            _converse.connection.send(
+                                $msg({
+                                    to: this.model.get('jid'),
+                                    from: _converse.connection.jid,
+                                    type: "groupchat"
+                                }).c("subject", {xmlns: "jabber:client"}).t(match[2]).tree()
+                            );
+                            break;
+                        case 'voice':
+                            if (!this.validateRoleChangeCommand(command, args)) { break; }
+                            this.modifyRole(
+                                    this.model.get('jid'), args[0], 'participant', args[1],
+                                    undefined, this.onCommandError.bind(this));
+                            break;
+                        default:
+                            this.sendChatRoomMessage(text);
+                        break;
+                    }
+                },
+
+                handleMUCMessage (stanza) {
+                    /* Handler for all MUC messages sent to this chat room.
+                     *
+                     * Parameters:
+                     *  (XMLElement) stanza: The message stanza.
+                     */
+                    const configuration_changed = stanza.querySelector("status[code='104']");
+                    const logging_enabled = stanza.querySelector("status[code='170']");
+                    const logging_disabled = stanza.querySelector("status[code='171']");
+                    const room_no_longer_anon = stanza.querySelector("status[code='172']");
+                    const room_now_semi_anon = stanza.querySelector("status[code='173']");
+                    const room_now_fully_anon = stanza.querySelector("status[code='173']");
+                    if (configuration_changed || logging_enabled || logging_disabled ||
+                            room_no_longer_anon || room_now_semi_anon || room_now_fully_anon) {
+                        this.getRoomFeatures();
+                    }
+                    _.flow(this.showStatusMessages.bind(this), this.onChatRoomMessage.bind(this))(stanza);
+                    return true;
+                },
+
+                getRoomJIDAndNick (nick) {
+                    /* Utility method to construct the JID for the current user
+                     * as occupant of the room.
+                     *
+                     * This is the room JID, with the user's nick added at the
+                     * end.
+                     *
+                     * For example: room@conference.example.org/nickname
+                     */
+                    if (nick) {
+                        this.model.save({'nick': nick});
+                    } else {
+                        nick = this.model.get('nick');
+                    }
+                    const room = this.model.get('jid');
+                    const jid = Strophe.getBareJidFromJid(room);
+                    return jid + (nick !== null ? `/${nick}` : "");
+                },
+
+                registerHandlers () {
+                    /* Register presence and message handlers for this chat
+                     * room
+                     */
+                    const room_jid = this.model.get('jid');
+                    this.removeHandlers();
+                    this.presence_handler = _converse.connection.addHandler(
+                        this.onChatRoomPresence.bind(this),
+                        Strophe.NS.MUC, 'presence', null, null, room_jid,
+                        {'ignoreNamespaceFragment': true, 'matchBareFromJid': true}
+                    );
+                    this.message_handler = _converse.connection.addHandler(
+                        this.handleMUCMessage.bind(this),
+                        null, 'message', 'groupchat', null, room_jid,
+                        {'matchBareFromJid': true}
+                    );
+                },
+
+                removeHandlers () {
+                    /* Remove the presence and message handlers that were
+                     * registered for this chat room.
+                     */
+                    if (this.message_handler) {
+                        _converse.connection.deleteHandler(this.message_handler);
+                        delete this.message_handler;
+                    }
+                    if (this.presence_handler) {
+                        _converse.connection.deleteHandler(this.presence_handler);
+                        delete this.presence_handler;
+                    }
+                    return this;
+                },
+
+                join (nick, password) {
+                    /* Join the chat room.
+                     *
+                     * Parameters:
+                     *  (String) nick: The user's nickname
+                     *  (String) password: Optional password, if required by
+                     *      the room.
+                     */
+                    nick = nick ? nick : this.model.get('nick');
+                    if (!nick) {
+                        return this.checkForReservedNick();
+                    }
+                    if (this.model.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
+                        // We have restored a chat room from session storage,
+                        // so we don't send out a presence stanza again.
+                        return this;
+                    }
+
+                    const stanza = $pres({
+                        'from': _converse.connection.jid,
+                        'to': this.getRoomJIDAndNick(nick)
+                    }).c("x", {'xmlns': Strophe.NS.MUC})
+                      .c("history", {'maxstanzas': _converse.muc_history_max_stanzas}).up();
+                    if (password) {
+                        stanza.cnode(Strophe.xmlElement("password", [], password));
+                    }
+                    this.model.save('connection_status', converse.ROOMSTATUS.CONNECTING);
+                    _converse.connection.send(stanza);
+                    return this;
+                },
+
+                sendUnavailablePresence (exit_msg) {
+                    const presence = $pres({
+                        type: "unavailable",
+                        from: _converse.connection.jid,
+                        to: this.getRoomJIDAndNick()
+                    });
+                    if (exit_msg !== null) {
+                        presence.c("status", exit_msg);
+                    }
+                    _converse.connection.sendPresence(presence);
+                },
+
+                leave(exit_msg) {
+                    /* Leave the chat room.
+                     *
+                     * Parameters:
+                     *  (String) exit_msg: Optional message to indicate your
+                     *      reason for leaving.
+                     */
+                    this.hide();
+                    if (Backbone.history.getFragment() === "converse/room?jid="+this.model.get('jid')) {
+                        _converse.router.navigate('');
+                    }
+                    this.occupantsview.model.reset();
+                    this.occupantsview.model.browserStorage._clear();
+                    if (_converse.connection.connected) {
+                        this.sendUnavailablePresence(exit_msg);
+                    }
+                    u.safeSave(
+                        this.model,
+                        {'connection_status': converse.ROOMSTATUS.DISCONNECTED}
+                    );
+                    this.removeHandlers();
+                    _converse.ChatBoxView.prototype.close.apply(this, arguments);
+                },
+
+                renderConfigurationForm (stanza) {
+                    /* Renders a form given an IQ stanza containing the current
+                     * room configuration.
+                     *
+                     * Returns a promise which resolves once the user has
+                     * either submitted the form, or canceled it.
+                     *
+                     * Parameters:
+                     *  (XMLElement) stanza: The IQ stanza containing the room
+                     *      config.
+                     */
+                    const container_el = this.el.querySelector('.chatroom-body');
+                    _.each(container_el.querySelectorAll('.chatroom-form-container'), u.removeElement);
+                    _.each(container_el.children, u.hideElement);
+                    container_el.insertAdjacentHTML('beforeend', tpl_chatroom_form());
+
+                    const form_el = container_el.querySelector('form.chatroom-form'),
+                          fieldset_el = form_el.querySelector('fieldset'),
+                          fields = stanza.querySelectorAll('field'),
+                          title = _.get(stanza.querySelector('title'), 'textContent'),
+                          instructions = _.get(stanza.querySelector('instructions'), 'textContent');
+
+                    u.removeElement(fieldset_el.querySelector('span.spinner'));
+                    fieldset_el.insertAdjacentHTML('beforeend', `<legend>${title}</legend>`);
+
+                    if (instructions && instructions !== title) {
+                        fieldset_el.insertAdjacentHTML('beforeend', `<p class="instructions">${instructions}</p>`);
+                    }
+                    _.each(fields, function (field) {
+                        fieldset_el.insertAdjacentHTML('beforeend', u.xForm2webForm(field, stanza));
+                    });
+
+                    // Render save/cancel buttons
+                    const last_fieldset_el = document.createElement('fieldset');
+                    last_fieldset_el.insertAdjacentHTML(
+                        'beforeend',
+                        `<input type="submit" class="pure-button button-primary" value="${__('Save')}"/>`);
+                    last_fieldset_el.insertAdjacentHTML(
+                        'beforeend',
+                        `<input type="button" class="pure-button button-cancel" value="${__('Cancel')}"/>`);
+                    form_el.insertAdjacentElement('beforeend', last_fieldset_el);
+
+                    last_fieldset_el.querySelector('input[type=button]').addEventListener('click', (ev) => {
+                        ev.preventDefault();
+                        this.closeForm();
+                    });
+
+                    form_el.addEventListener('submit', (ev) => {
+                            ev.preventDefault();
+                            this.saveConfiguration(ev.target).then(
+                                this.getRoomFeatures.bind(this)
+                            );
+                        },
+                        false
+                    );
+                },
+
+                sendConfiguration(config, onSuccess, onError) {
+                    /* Send an IQ stanza with the room configuration.
+                     *
+                     * Parameters:
+                     *  (Array) config: The room configuration
+                     *  (Function) onSuccess: Callback upon succesful IQ response
+                     *      The first parameter passed in is IQ containing the
+                     *      room configuration.
+                     *      The second is the response IQ from the server.
+                     *  (Function) onError: Callback upon error IQ response
+                     *      The first parameter passed in is IQ containing the
+                     *      room configuration.
+                     *      The second is the response IQ from the server.
+                     */
+                    const iq = $iq({to: this.model.get('jid'), type: "set"})
+                        .c("query", {xmlns: Strophe.NS.MUC_OWNER})
+                        .c("x", {xmlns: Strophe.NS.XFORM, type: "submit"});
+                    _.each(config || [], function (node) { iq.cnode(node).up(); });
+                    onSuccess = _.isUndefined(onSuccess) ? _.noop : _.partial(onSuccess, iq.nodeTree);
+                    onError = _.isUndefined(onError) ? _.noop : _.partial(onError, iq.nodeTree);
+                    return _converse.connection.sendIQ(iq, onSuccess, onError);
+                },
+
+                saveConfiguration (form) {
+                    /* Submit the room configuration form by sending an IQ
+                     * stanza to the server.
+                     *
+                     * Returns a promise which resolves once the XMPP server
+                     * has return a response IQ.
+                     *
+                     * Parameters:
+                     *  (HTMLElement) form: The configuration form DOM element.
+                     */
+                    return new Promise((resolve, reject) => {
+                        const inputs = form ? sizzle(':input:not([type=button]):not([type=submit])', form) : [],
+                              configArray = _.map(inputs, u.webForm2xForm);
+                        this.sendConfiguration(configArray, resolve, reject);
+                        this.closeForm();
+                    });
+                },
+
+                autoConfigureChatRoom () {
+                    /* Automatically configure room based on the
+                     * 'roomconfig' data on this view's model.
+                     *
+                     * Returns a promise which resolves once a response IQ has
+                     * been received.
+                     *
+                     * Parameters:
+                     *  (XMLElement) stanza: IQ stanza from the server,
+                     *       containing the configuration.
+                     */
+                    const that = this;
+                    return new Promise((resolve, reject) => {
+                        this.fetchRoomConfiguration().then(function (stanza) {
+                            const configArray = [],
+                                fields = stanza.querySelectorAll('field'),
+                                config = that.model.get('roomconfig');
+                            let count = fields.length;
+
+                            _.each(fields, function (field) {
+                                const fieldname = field.getAttribute('var').replace('muc#roomconfig_', ''),
+                                    type = field.getAttribute('type');
+                                let value;
+                                if (fieldname in config) {
+                                    switch (type) {
+                                        case 'boolean':
+                                            value = config[fieldname] ? 1 : 0;
+                                            break;
+                                        case 'list-multi':
+                                            // TODO: we don't yet handle "list-multi" types
+                                            value = field.innerHTML;
+                                            break;
+                                        default:
+                                            value = config[fieldname];
+                                    }
+                                    field.innerHTML = $build('value').t(value);
+                                }
+                                configArray.push(field);
+                                if (!--count) {
+                                    that.sendConfiguration(configArray, resolve, reject);
+                                }
+                            });
+                        });
+                    });
+                },
+
+                closeForm () {
+                    /* Remove the configuration form without submitting and
+                     * return to the chat view.
+                     */
+                    u.removeElement(this.el.querySelector('.chatroom-form-container'));
+                    this.renderAfterTransition();
+                },
+
+                fetchRoomConfiguration (handler) {
+                    /* Send an IQ stanza to fetch the room configuration data.
+                     * Returns a promise which resolves once the response IQ
+                     * has been received.
+                     *
+                     * Parameters:
+                     *  (Function) handler: The handler for the response IQ
+                     */
+                    return new Promise((resolve, reject) => {
+                        _converse.connection.sendIQ(
+                            $iq({
+                                'to': this.model.get('jid'),
+                                'type': "get"
+                            }).c("query", {xmlns: Strophe.NS.MUC_OWNER}),
+                            (iq) => {
+                                if (handler) {
+                                    handler.apply(this, arguments);
+                                }
+                                resolve(iq);
+                            },
+                            reject // errback
+                        );
+                    });
+                },
+
+                parseRoomFeatures (iq) {
+                    /* See http://xmpp.org/extensions/xep-0045.html#disco-roominfo
+                     *
+                     *  <identity
+                     *      category='conference'
+                     *      name='A Dark Cave'
+                     *      type='text'/>
+                     *  <feature var='http://jabber.org/protocol/muc'/>
+                     *  <feature var='muc_passwordprotected'/>
+                     *  <feature var='muc_hidden'/>
+                     *  <feature var='muc_temporary'/>
+                     *  <feature var='muc_open'/>
+                     *  <feature var='muc_unmoderated'/>
+                     *  <feature var='muc_nonanonymous'/>
+                     *  <feature var='urn:xmpp:mam:0'/>
+                     */
+                    const features = {
+                        'features_fetched': true,
+                        'name': iq.querySelector('identity').getAttribute('name')
+                    }
+                    _.each(iq.querySelectorAll('feature'), function (field) {
+                        const fieldname = field.getAttribute('var');
+                        if (!fieldname.startsWith('muc_')) {
+                            if (fieldname === Strophe.NS.MAM) {
+                                features.mam_enabled = true;
+                            }
+                            return;
+                        }
+                        features[fieldname.replace('muc_', '')] = true;
+                    });
+                    const desc_field = iq.querySelector('field[var="muc#roominfo_description"] value');
+                    if (!_.isNull(desc_field)) {
+                        features.description = desc_field.textContent;
+                    }
+                    this.model.save(features);
+                },
+
+                getRoomFeatures () {
+                    /* Fetch the room disco info, parse it and then
+                     * save it on the Backbone.Model of this chat rooms.
+                     */
+                    return new Promise((resolve, reject) => {
+                        _converse.connection.disco.info(
+                            this.model.get('jid'),
+                            null,
+                            _.flow(this.parseRoomFeatures.bind(this), resolve),
+                            () => { reject(new Error("Could not parse the room features")) },
+                            5000
+                        );
+                    });
+                },
+
+                getAndRenderConfigurationForm (ev) {
+                    /* Start the process of configuring a chat room, either by
+                     * rendering a configuration form, or by auto-configuring
+                     * based on the "roomconfig" data stored on the
+                     * Backbone.Model.
+                     *
+                     * Stores the new configuration on the Backbone.Model once
+                     * completed.
+                     *
+                     * Paremeters:
+                     *  (Event) ev: DOM event that might be passed in if this
+                     *      method is called due to a user action. In this
+                     *      case, auto-configure won't happen, regardless of
+                     *      the settings.
+                     */
+                    this.showSpinner();
+                    this.fetchRoomConfiguration()
+                        .then(this.renderConfigurationForm.bind(this))
+                        .catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
+                },
+
+                submitNickname (ev) {
+                    /* Get the nickname value from the form and then join the
+                     * chat room with it.
+                     */
+                    ev.preventDefault();
+                    const nick_el = ev.target.nick;
+                    const nick = nick_el.value;
+                    if (!nick) {
+                        nick_el.classList.add('error');
+                        return;
+                    }
+                    else {
+                        nick_el.classList.remove('error');
+                    }
+                    this.el.querySelector('.chatroom-form-container').outerHTML = tpl_spinner();
+                    this.join(nick);
+                },
+
+                checkForReservedNick () {
+                    /* User service-discovery to ask the XMPP server whether
+                     * this user has a reserved nickname for this room.
+                     * If so, we'll use that, otherwise we render the nickname
+                     * form.
+                     */
+                    this.showSpinner();
+                    _converse.connection.sendIQ(
+                        $iq({
+                            'to': this.model.get('jid'),
+                            'from': _converse.connection.jid,
+                            'type': "get"
+                        }).c("query", {
+                            'xmlns': Strophe.NS.DISCO_INFO,
+                            'node': 'x-roomuser-item'
+                        }),
+                        this.onNickNameFound.bind(this),
+                        this.onNickNameNotFound.bind(this)
+                    );
+                    return this;
+                },
+
+                onNickNameFound (iq) {
+                    /* We've received an IQ response from the server which
+                     * might contain the user's reserved nickname.
+                     * If no nickname is found we either render a form for
+                     * them to specify one, or we try to join the room with the
+                     * node of the user's JID.
+                     *
+                     * Parameters:
+                     *  (XMLElement) iq: The received IQ stanza
+                     */
+                    const identity_el = iq.querySelector('query[node="x-roomuser-item"] identity'),
+                          nick = identity_el ? identity_el.getAttribute('name') : null;
+                    if (!nick) {
+                        this.onNickNameNotFound();
+                    } else {
+                        this.join(nick);
+                    }
+                },
+
+                onNickNameNotFound (message) {
+                    if (_converse.muc_nickname_from_jid) {
+                        // We try to enter the room with the node part of
+                        // the user's JID.
+                        this.join(this.getDefaultNickName());
+                    } else {
+                        this.renderNicknameForm(message);
+                    }
+                },
+
+                getDefaultNickName () {
+                    /* The default nickname (used when muc_nickname_from_jid is true)
+                     * is the node part of the user's JID.
+                     * We put this in a separate method so that it can be
+                     * overridden by plugins.
+                     */
+                    return Strophe.unescapeNode(Strophe.getNodeFromJid(_converse.bare_jid));
+                },
+
+                onNicknameClash (presence) {
+                    /* When the nickname is already taken, we either render a
+                     * form for the user to choose a new nickname, or we
+                     * try to make the nickname unique by adding an integer to
+                     * it. So john will become john-2, and then john-3 and so on.
+                     *
+                     * Which option is take depends on the value of
+                     * muc_nickname_from_jid.
+                     */
+                    if (_converse.muc_nickname_from_jid) {
+                        const nick = presence.getAttribute('from').split('/')[1];
+                        if (nick === this.getDefaultNickName()) {
+                            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.renderNicknameForm(
+                            __("The nickname you chose is reserved or "+
+                               "currently in use, please choose a different one.")
+                        );
+                    }
+                },
+
+                hideChatRoomContents () {
+                    const container_el = this.el.querySelector('.chatroom-body');
+                    if (!_.isNull(container_el)) {
+                        _.each(container_el.children, (child) => { child.classList.add('hidden'); });
+                    }
+                },
+
+                renderNicknameForm (message) {
+                    /* Render a form which allows the user to choose their
+                     * nickname.
+                     */
+                    this.hideChatRoomContents();
+                    _.each(this.el.querySelectorAll('span.centered.spinner'), u.removeElement);
+                    if (!_.isString(message)) {
+                        message = '';
+                    }
+                    const container_el = this.el.querySelector('.chatroom-body');
+                    container_el.insertAdjacentHTML(
+                        'beforeend',
+                        tpl_chatroom_nickname_form({
+                            heading: __('Please choose your nickname'),
+                            label_nickname: __('Nickname'),
+                            label_join: __('Enter room'),
+                            validation_message: message
+                        }));
+                    this.model.save('connection_status', converse.ROOMSTATUS.NICKNAME_REQUIRED);
+
+                    const form_el = this.el.querySelector('.chatroom-form');
+                    form_el.addEventListener('submit', this.submitNickname.bind(this), false);
+                },
+
+                submitPassword (ev) {
+                    ev.preventDefault();
+                    const password = this.el.querySelector('.chatroom-form input[type=password]').value;
+                    this.showSpinner();
+                    this.join(this.model.get('nick'), password);
+                },
+
+                renderPasswordForm () {
+                    const container_el = this.el.querySelector('.chatroom-body');
+                    _.each(container_el.children, u.hideElement);
+                    _.each(this.el.querySelectorAll('.spinner'), u.removeElement);
+
+                    container_el.insertAdjacentHTML('beforeend',
+                        tpl_chatroom_password_form({
+                            heading: __('This chatroom requires a password'),
+                            label_password: __('Password: '),
+                            label_submit: __('Submit')
+                        }));
+
+                    this.model.save('connection_status', converse.ROOMSTATUS.PASSWORD_REQUIRED);
+                    this.el.querySelector('.chatroom-form').addEventListener(
+                        'submit', this.submitPassword.bind(this), false);
+                },
+
+                showDisconnectMessage (msg) {
+                    u.hideElement(this.el.querySelector('.chat-area'));
+                    u.hideElement(this.el.querySelector('.occupants'));
+                    _.each(this.el.querySelectorAll('.spinner'), u.removeElement);
+                    this.el.querySelector('.chatroom-body').insertAdjacentHTML(
+                        'beforeend',
+                        tpl_chatroom_disconnect({
+                            'disconnect_message': msg
+                        })
+                    );
+                },
+
+                getMessageFromStatus (stat, stanza, is_self) {
+                    /* Parameters:
+                     *  (XMLElement) stat: A <status> element.
+                     *  (Boolean) is_self: Whether the element refers to the
+                     *                     current user.
+                     *  (XMLElement) stanza: The original stanza received.
+                     */
+                    const code = stat.getAttribute('code');
+                    if (code === '110') { return; }
+                    if (code in _converse.muc.info_messages) {
+                        return _converse.muc.info_messages[code];
+                    }
+                    let nick;
+                    if (!is_self) {
+                        if (code in _converse.muc.action_info_messages) {
+                            nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
+                            return __(_converse.muc.action_info_messages[code], nick);
+                        }
+                    } else if (code in _converse.muc.new_nickname_messages) {
+                        if (is_self && code === "210") {
+                            nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
+                        } else if (is_self && code === "303") {
+                            nick = stanza.querySelector('x item').getAttribute('nick');
+                        }
+                        return __(_converse.muc.new_nickname_messages[code], nick);
+                    }
+                    return;
+                },
+
+                saveAffiliationAndRole (pres) {
+                    /* Parse the presence stanza for the current user's
+                     * affiliation.
+                     *
+                     * Parameters:
+                     *  (XMLElement) pres: A <presence> stanza.
+                     */
+                    const item = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] item`, pres).pop();
+                    const is_self = pres.querySelector("status[code='110']");
+                    if (is_self && !_.isNil(item)) {
+                        const affiliation = item.getAttribute('affiliation');
+                        const role = item.getAttribute('role');
+                        if (affiliation) {
+                            this.model.save({'affiliation': affiliation});
+                        }
+                        if (role) {
+                            this.model.save({'role': role});
+                        }
+                    }
+                },
+
+                parseXUserElement (x, stanza, is_self) {
+                    /* Parse the passed-in <x xmlns='http://jabber.org/protocol/muc#user'>
+                     * element and construct a map containing relevant
+                     * information.
+                     */
+                    // 1. Get notification messages based on the <status> elements.
+                    const statuses = x.querySelectorAll('status');
+                    const mapper = _.partial(this.getMessageFromStatus, _, stanza, is_self);
+                    const notification = {};
+                    const messages = _.reject(_.map(statuses, mapper), _.isUndefined);
+                    if (messages.length) {
+                        notification.messages = messages;
+                    }
+                    // 2. Get disconnection messages based on the <status> elements
+                    const codes = _.invokeMap(statuses, Element.prototype.getAttribute, 'code');
+                    const disconnection_codes = _.intersection(codes, _.keys(_converse.muc.disconnect_messages));
+                    const disconnected = is_self && disconnection_codes.length > 0;
+                    if (disconnected) {
+                        notification.disconnected = true;
+                        notification.disconnection_message = _converse.muc.disconnect_messages[disconnection_codes[0]];
+                    }
+                    // 3. Find the reason and actor from the <item> element
+                    const item = x.querySelector('item');
+                    // By using querySelector above, 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.
+                    if (!_.isNull(item)) {
+                        const reason = item.querySelector('reason');
+                        if (reason) {
+                            notification.reason = reason ? reason.textContent : undefined;
+                        }
+                        const actor = item.querySelector('actor');
+                        if (actor) {
+                            notification.actor = actor ? actor.getAttribute('nick') : undefined;
+                        }
+                    }
+                    return notification;
+                },
+
+                displayNotificationsforUser (notification) {
+                    /* Given the notification object generated by
+                     * parseXUserElement, display any relevant messages and
+                     * information to the user.
+                     */
+                    if (notification.disconnected) {
+                        this.showDisconnectMessage(notification.disconnection_message);
+                        if (notification.actor) {
+                            this.showDisconnectMessage(__('This action was done by %1$s.', notification.actor));
+                        }
+                        if (notification.reason) {
+                            this.showDisconnectMessage(__('The reason given is: "%1$s".', notification.reason));
+                        }
+                        this.model.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
+                        return;
+                    }
+                    _.each(notification.messages, (message) => {
+                        this.content.insertAdjacentHTML(
+                            'beforeend',
+                            tpl_info({
+                                'data': '',
+                                'isodate': moment().format(),
+                                'message': message
+                            }));
+                    });
+                    if (notification.reason) {
+                        this.showStatusNotification(__('The reason given is: "%1$s".', notification.reason), true);
+                    }
+                    if (_.get(notification.messages, 'length')) {
+                        this.scrollDown();
+                    }
+                },
+
+                displayJoinNotification (stanza) {
+                    const nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
+                    const stat = stanza.querySelector('status');
+                    const last_el = this.content.lastElementChild;
+
+                    if (_.includes(_.get(last_el, 'classList', []), 'chat-info') &&
+                            _.get(last_el, 'dataset', {}).leave === `"${nick}"`) {
+                        last_el.outerHTML =
+                            tpl_info({
+                                'data': `data-leavejoin="${nick}"`,
+                                'isodate': moment().format(),
+                                'message': __('%1$s has left and re-entered the room.', nick)
+                            });
+                    } else {
+                        let  message;
+                        if (_.get(stat, 'textContent')) {
+                            message = __('%1$s has entered the room. "%2$s"', nick, stat.textContent);
+                        } else {
+                            message = __('%1$s has entered the room.', nick);
+                        }
+                        const data = {
+                            'data': `data-join="${nick}"`,
+                            'isodate': moment().format(),
+                            'message': message
+                        };
+                        if (_.includes(_.get(last_el, 'classList', []), 'chat-info') &&
+                            _.get(last_el, 'dataset', {}).joinleave === `"${nick}"`) {
+
+                            last_el.outerHTML = tpl_info(data);
+                        } else {
+                            const el = u.stringToElement(tpl_info(data));
+                            this.content.insertAdjacentElement('beforeend', el);
+                            this.insertDayIndicator(el);
+                        }
+                    }
+                    this.scrollDown();
+                },
+
+                displayLeaveNotification (stanza) {
+                    const nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
+                    const stat = stanza.querySelector('status');
+                    const last_el = this.content.lastElementChild;
+                    if (_.includes(_.get(last_el, 'classList', []), 'chat-info') &&
+                            _.get(last_el, 'dataset', {}).join === `"${nick}"`) {
+
+                        let message;
+                        if (_.get(stat, 'textContent')) {
+                            message = __('%1$s has entered and left the room. "%2$s"', nick, stat.textContent);
+                        } else {
+                            message = __('%1$s has entered and left the room.', nick);
+                        }
+                        last_el.outerHTML =
+                            tpl_info({
+                                'data': `data-joinleave="${nick}"`,
+                                'isodate': moment().format(),
+                                'message': message
+                            });
+                    } else {
+                        let message;
+                        if (_.get(stat, 'textContent')) {
+                            message = __('%1$s has left the room. "%2$s"', nick, stat.textContent);
+                        } else {
+                            message = __('%1$s has left the room.', nick);
+                        }
+                        const data = {
+                            'message': message,
+                            'isodate': moment().format(),
+                            'data': `data-leave="${nick}"`
+                        }
+                        if (_.includes(_.get(last_el, 'classList', []), 'chat-info') &&
+                            _.get(last_el, 'dataset', {}).leavejoin === `"${nick}"`) {
+
+                            last_el.outerHTML = tpl_info(data);
+                        } else {
+                            const el = u.stringToElement(tpl_info(data));
+                            this.content.insertAdjacentElement('beforeend', el);
+                            this.insertDayIndicator(el);
+                        }
+                    }
+                    this.scrollDown();
+                },
+
+                displayJoinOrLeaveNotification (stanza) {
+                    if (stanza.getAttribute('type') === 'unavailable') {
+                        this.displayLeaveNotification(stanza);
+                    } else {
+                        const nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
+                        if (!this.occupantsview.model.find({'nick': nick})) {
+                            // Only show join message if we don't already have the
+                            // occupant model. Doing so avoids showing duplicate
+                            // join messages.
+                            this.displayJoinNotification(stanza);
+                        }
+                    }
+                },
+
+                showStatusMessages (stanza) {
+                    /* Check for status codes and communicate their purpose to the user.
+                     * See: http://xmpp.org/registrar/mucstatus.html
+                     *
+                     * Parameters:
+                     *  (XMLElement) stanza: The message or presence stanza
+                     *      containing the status codes.
+                     */
+                    const elements = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"]`, stanza);
+                    const is_self = stanza.querySelectorAll("status[code='110']").length;
+                    const iteratee = _.partial(this.parseXUserElement.bind(this), _, stanza, is_self);
+                    const notifications = _.reject(_.map(elements, iteratee), _.isEmpty);
+                    if (_.isEmpty(notifications)) {
+                        if (_converse.muc_show_join_leave &&
+                                stanza.nodeName === 'presence' &&
+                                this.model.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
+                            this.displayJoinOrLeaveNotification(stanza);
+                        }
+                    } else {
+                        _.each(notifications, this.displayNotificationsforUser.bind(this));
+                    }
+                    return stanza;
+                },
+
+                showErrorMessage (presence) {
+                    // We didn't enter the room, so we must remove it from the MUC add-on
+                    const error = presence.querySelector('error');
+                    if (error.getAttribute('type') === 'auth') {
+                        if (!_.isNull(error.querySelector('not-authorized'))) {
+                            this.renderPasswordForm();
+                        } else if (!_.isNull(error.querySelector('registration-required'))) {
+                            this.showDisconnectMessage(__('You are not on the member list of this room.'));
+                        } else if (!_.isNull(error.querySelector('forbidden'))) {
+                            this.showDisconnectMessage(__('You have been banned from this room.'));
+                        }
+                    } else if (error.getAttribute('type') === 'modify') {
+                        if (!_.isNull(error.querySelector('jid-malformed'))) {
+                            this.showDisconnectMessage(__('No nickname was specified.'));
+                        }
+                    } else if (error.getAttribute('type') === 'cancel') {
+                        if (!_.isNull(error.querySelector('not-allowed'))) {
+                            this.showDisconnectMessage(__('You are not allowed to create new rooms.'));
+                        } else if (!_.isNull(error.querySelector('not-acceptable'))) {
+                            this.showDisconnectMessage(__("Your nickname doesn't conform to this room's policies."));
+                        } else if (!_.isNull(error.querySelector('conflict'))) {
+                            this.onNicknameClash(presence);
+                        } else if (!_.isNull(error.querySelector('item-not-found'))) {
+                            this.showDisconnectMessage(__("This room does not (yet) exist."));
+                        } else if (!_.isNull(error.querySelector('service-unavailable'))) {
+                            this.showDisconnectMessage(__("This room has reached its maximum number of occupants."));
+                        }
+                    }
+                },
+
+                renderAfterTransition () {
+                    /* Rerender the room after some kind of transition. For
+                     * example after the spinner has been removed or after a
+                     * form has been submitted and removed.
+                     */
+                    if (this.model.get('connection_status') == converse.ROOMSTATUS.NICKNAME_REQUIRED) {
+                        this.renderNicknameForm();
+                    } else if (this.model.get('connection_status') == converse.ROOMSTATUS.PASSWORD_REQUIRED) {
+                        this.renderPasswordForm();
+                    } else {
+                        this.el.querySelector('.chat-area').classList.remove('hidden');
+                        this.setOccupantsVisibility();
+                        this.scrollDown();
+                    }
+                },
+
+                showSpinner () {
+                    u.removeElement(this.el.querySelector('.spinner'));
+
+                    const container_el = this.el.querySelector('.chatroom-body');
+                    const children = Array.prototype.slice.call(container_el.children, 0);
+                    container_el.insertAdjacentHTML('afterbegin', tpl_spinner());
+                    _.each(children, u.hideElement);
+
+                },
+
+                hideSpinner () {
+                    /* Check if the spinner is being shown and if so, hide it.
+                     * Also make sure then that the chat area and occupants
+                     * list are both visible.
+                     */
+                    const spinner = this.el.querySelector('.spinner');
+                    if (!_.isNull(spinner)) {
+                        u.removeElement(spinner);
+                        this.renderAfterTransition();
+                    }
+                    return this;
+                },
+
+                onOwnChatRoomPresence (pres) {
+                    /* Handles a received presence relating to the current
+                     * user.
+                     *
+                     * For locked rooms (which are by definition "new"), the
+                     * room will either be auto-configured or created instantly
+                     * (with default config) or a configuration room will be
+                     * rendered.
+                     *
+                     * If the room is not locked, then the room will be
+                     * auto-configured only if applicable and if the current
+                     * user is the room's owner.
+                     *
+                     * Parameters:
+                     *  (XMLElement) pres: The stanza
+                     */
+                    this.saveAffiliationAndRole(pres);
+
+                    const locked_room = pres.querySelector("status[code='201']");
+                    if (locked_room) {
+                        if (this.model.get('auto_configure')) {
+                            this.autoConfigureChatRoom().then(this.getRoomFeatures.bind(this));
+                        } else if (_converse.muc_instant_rooms) {
+                            // Accept default configuration
+                            this.saveConfiguration().then(this.getRoomFeatures.bind(this));
+                        } else {
+                            this.getAndRenderConfigurationForm();
+                            return; // We haven't yet entered the room, so bail here.
+                        }
+                    } else if (!this.model.get('features_fetched')) {
+                        // The features for this room weren't fetched.
+                        // That must mean it's a new room 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.model.get('affiliation') === 'owner' && this.model.get('auto_configure')) {
+                            this.autoConfigureChatRoom().then(this.getRoomFeatures.bind(this));
+                        } else {
+                            this.getRoomFeatures();
+                        }
+                    }
+                    this.model.save('connection_status', converse.ROOMSTATUS.ENTERED);
+                },
+
+                onChatRoomPresence (pres) {
+                    /* Handles all MUC presence stanzas.
+                     *
+                     * Parameters:
+                     *  (XMLElement) pres: The stanza
+                     */
+                    if (pres.getAttribute('type') === 'error') {
+                        this.model.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
+                        this.showErrorMessage(pres);
+                        return true;
+                    }
+                    const is_self = pres.querySelector("status[code='110']");
+                    if (is_self && pres.getAttribute('type') !== 'unavailable') {
+                        this.onOwnChatRoomPresence(pres);
+                    }
+                    this.hideSpinner().showStatusMessages(pres);
+                    // This must be called after showStatusMessages so that
+                    // "join" messages are correctly shown.
+                    this.occupantsview.updateOccupantsOnPresence(pres);
+                    if (this.model.get('role') !== 'none' &&
+                            this.model.get('connection_status') === converse.ROOMSTATUS.CONNECTING) {
+                        this.model.save('connection_status', converse.ROOMSTATUS.CONNECTED);
+                    }
+                    return true;
+                },
+
+                setChatRoomSubject (sender, subject) {
+                    // For translators: the %1$s and %2$s parts will get
+                    // replaced by the user and topic text respectively
+                    // Example: Topic set by JC Brand to: Hello World!
+                    this.content.insertAdjacentHTML(
+                        'beforeend',
+                        tpl_info({
+                            'data': '',
+                            'isodate': moment().format(),
+                            'message': __('Topic set by %1$s to: %2$s', sender, subject)
+                        }));
+                    this.scrollDown();
+                },
+
+                isDuplicateBasedOnTime (message) {
+                    /* Checks whether a received messages is actually a
+                     * duplicate based on whether it has a "ts" attribute
+                     * with a unix timestamp.
+                     *
+                     * This is used for better integration with Slack's XMPP
+                     * gateway, which doesn't use message IDs but instead the
+                     * aforementioned "ts" attributes.
+                     */
+                    const entity = _converse.disco_entities.get(_converse.domain);
+                    if (entity.identities.where({'name': "Slack-XMPP"})) {
+                        const ts = message.getAttribute('ts');
+                        if (_.isNull(ts)) {
+                            return false;
+                        } else {
+                            return this.model.messages.where({
+                                'sender': 'me',
+                                'message': this.model.getMessageBody(message)
+                            }).filter(
+                                (msg) => Math.abs(moment(msg.get('time')).diff(moment.unix(ts))) < 5000
+                            ).length > 0;
+                        }
+                    }
+                    return false;
+                },
+
+                isDuplicate (message, original_stanza) {
+                    const msgid = message.getAttribute('id'),
+                          jid = message.getAttribute('from'),
+                          resource = Strophe.getResourceFromJid(jid),
+                          sender = resource && Strophe.unescapeNode(resource) || '';
+                    if (msgid) {
+                        return this.model.messages.filter(
+                            // Some bots (like HAL in the prosody chatroom)
+                            // respond to commands with the same ID as the
+                            // original message. So we also check the sender.
+                            (msg) => msg.get('msgid') === msgid && msg.get('fullname') === sender
+                        ).length > 0;
+                    }
+                    return this.isDuplicateBasedOnTime(message);
+                },
+
+                onChatRoomMessage (message) {
+                    /* Given a <message> stanza, create a message
+                     * Backbone.Model if appropriate.
+                     *
+                     * Parameters:
+                     *  (XMLElement) msg: The received message stanza
+                     */
+                    const original_stanza = message,
+                        forwarded = message.querySelector('forwarded');
+                    let delay;
+                    if (!_.isNull(forwarded)) {
+                        message = forwarded.querySelector('message');
+                        delay = forwarded.querySelector('delay');
+                    }
+                    const jid = message.getAttribute('from'),
+                        resource = Strophe.getResourceFromJid(jid),
+                        sender = resource && Strophe.unescapeNode(resource) || '',
+                        subject = _.propertyOf(message.querySelector('subject'))('textContent');
+
+                    if (this.isDuplicate(message, original_stanza)) {
+                        return true;
+                    }
+                    if (subject) {
+                        this.setChatRoomSubject(sender, subject);
+                    }
+                    if (sender === '') {
+                        return true;
+                    }
+                    this.model.incrementUnreadMsgCounter(original_stanza);
+                    this.model.createMessage(message, delay, original_stanza);
+                    if (sender !== this.model.get('nick')) {
+                        // We only emit an event if it's not our own message
+                        _converse.emit(
+                            'message',
+                            {'stanza': original_stanza, 'chatbox': this.model}
+                        );
+                    }
+                    return true;
+                }
+            });
+
+
+            _converse.MUCJoinForm = Backbone.VDOMView.extend({
+
+                initialize () {
+                    this.model.on('change:muc_domain', this.render, this);
+                },
+
+                toHTML () {
+                    return tpl_chatroom_join_form(_.assign(this.model.toJSON(), {
+                        'server_input_type': _converse.hide_muc_server && 'hidden' || 'text',
+                        'server_label_global_attr': _converse.hide_muc_server && ' hidden' || '',
+                        'label_room_name': __('Room name'),
+                        'label_nickname': __('Nickname'),
+                        'label_server': __('Server'),
+                        'label_join': __('Join Room'),
+                        'label_show_rooms': __('Show rooms')
+                    }));
+                }
+            });
+
+
+            _converse.RoomsPanel = Backbone.NativeView.extend({
+                /* Backbone.NativeView which renders the "Rooms" tab and accompanying
+                 * panel in the control box.
+                 *
+                 * In this panel, chat rooms can be listed, joined and new rooms
+                 * can be created.
+                 */
+                tagName: 'div',
+                className: 'controlbox-pane',
+                id: 'chatrooms',
+                events: {
+                    'submit form.add-chatroom': 'openChatRoom',
+                    'click input#show-rooms': 'showRooms',
+                    'click a.open-room': 'openChatRoom',
+                    'click a.room-info': 'toggleRoomInfo',
+                    'change input[name=server]': 'setDomain',
+                    'change input[name=nick]': 'setNick'
+                },
+
+                initialize (cfg) {
+                    this.join_form = new _converse.MUCJoinForm({'model': this.model});
+                    this.parent_el = cfg.parent;
+                    this.tab_el = document.createElement('li');
+                    this.model.on('change:muc_domain', this.onDomainChange, this);
+                    this.model.on('change:nick', this.onNickChange, this);
+                    _converse.chatboxes.on('change:num_unread', this.renderTab, this);
+                    _converse.chatboxes.on('add', _.debounce(this.renderTab, 100), this);
+                },
+
+                render () {
+                    this.el.innerHTML = tpl_room_panel();
+                    this.join_form.setElement(this.el.querySelector('.add-chatroom'));
+                    this.join_form.render();
+
+                    this.renderTab();
+                    const controlbox = _converse.chatboxes.get('controlbox');
+                    if (controlbox.get('active-panel') !== ROOMS_PANEL_ID) {
+                        this.el.classList.add('hidden');
+                    } else {
+                        this.el.classList.remove('hidden');
+                    }
+                    return this;
+                },
+
+                renderTab () {
+                    const controlbox = _converse.chatboxes.get('controlbox');
+                    const chatrooms = f.filter(
+                        _.partial(u.isOfType, CHATROOMS_TYPE),
+                        _converse.chatboxes.models
+                    );
+                    this.tab_el.innerHTML = tpl_chatrooms_tab({
+                        'label_rooms': __('Rooms'),
+                        'is_current': controlbox.get('active-panel') === ROOMS_PANEL_ID,
+                        'num_unread': f.sum(f.map(f.curry(u.getAttribute)('num_unread'), chatrooms))
+                    });
+                },
+
+                insertIntoDOM () {
+                    this.parent_el.appendChild(this.render().el);
+                    this.tabs = this.parent_el.parentNode.querySelector('#controlbox-tabs');
+                    this.tabs.appendChild(this.tab_el);
+                    return this;
+                },
+
+                onDomainChange (model) {
+                    if (_converse.auto_list_rooms) {
+                        this.updateRoomsList();
+                    }
+                },
+
+                onNickChange (model) {
+                    const nick = this.el.querySelector('input.new-chatroom-nick');
+                    if (!_.isNull(nick)) {
+                        nick.value = model.get('nick');
+                    }
+                },
+
+                removeSpinner () {
+                    _.each(this.el.querySelectorAll('span.spinner'),
+                        (el) => el.parentNode.removeChild(el)
+                    );
+                },
+
+                informNoRoomsFound () {
+                    const chatrooms_el = this.el.querySelector('#available-chatrooms');
+                    chatrooms_el.innerHTML = tpl_rooms_results({
+                        'feedback_text': __('No rooms found')
+                    });
+                    const input_el = this.el.querySelector('input#show-rooms');
+                    input_el.classList.remove('hidden')
+                    this.removeSpinner();
+                },
+
+                roomStanzaItemToHTMLElement (room) {
+                    const name = Strophe.unescapeNode(
+                        room.getAttribute('name') ||
+                            room.getAttribute('jid')
+                    );
+                    const div = document.createElement('div');
+                    div.innerHTML = tpl_room_item({
+                        'name': name,
+                        'jid': room.getAttribute('jid'),
+                        'open_title': __('Click to open this room'),
+                        'info_title': __('Show more information on this room')
+                    });
+                    return div.firstChild;
+                },
+
+                onRoomsFound (iq) {
+                    /* Handle the IQ stanza returned from the server, containing
+                     * all its public rooms.
+                     */
+                    const available_chatrooms = this.el.querySelector('#available-chatrooms');
+                    this.rooms = iq.querySelectorAll('query item');
+                    if (this.rooms.length) {
+                        // For translators: %1$s is a variable and will be
+                        // replaced with the XMPP server name
+                        available_chatrooms.innerHTML = tpl_rooms_results({
+                            'feedback_text': __('Rooms found')
+                        });
+                        const fragment = document.createDocumentFragment();
+                        const children = _.reject(_.map(this.rooms, this.roomStanzaItemToHTMLElement), _.isNil)
+                        _.each(children, (child) => fragment.appendChild(child));
+                        available_chatrooms.appendChild(fragment);
+                        const input_el = this.el.querySelector('input#show-rooms');
+                        input_el.classList.remove('hidden')
+                        this.removeSpinner();
+                    } else {
+                        this.informNoRoomsFound();
+                    }
+                    return true;
+                },
+
+                updateRoomsList () {
+                    /* Send an IQ stanza to the server asking for all rooms
+                     */
+                    _converse.connection.sendIQ(
+                        $iq({
+                            to: this.model.get('muc_domain'),
+                            from: _converse.connection.jid,
+                            type: "get"
+                        }).c("query", {xmlns: Strophe.NS.DISCO_ITEMS}),
+                        this.onRoomsFound.bind(this),
+                        this.informNoRoomsFound.bind(this),
+                        5000
+                    );
+                },
+
+                showRooms () {
+                    const chatrooms_el = this.el.querySelector('#available-chatrooms');
+                    const server_el = this.el.querySelector('input.new-chatroom-server');
+                    const server = server_el.value;
+                    if (!server) {
+                        server_el.classList.add('error');
+                        return;
+                    }
+                    this.el.querySelector('input.new-chatroom-name').classList.remove('error');
+                    server_el.classList.remove('error');
+                    chatrooms_el.innerHTML = '';
+
+                    const input_el = this.el.querySelector('input#show-rooms');
+                    input_el.classList.add('hidden')
+                    input_el.insertAdjacentHTML('afterend', tpl_spinner());
+
+                    this.model.save({muc_domain: server});
+                    this.updateRoomsList();
+                },
+
+                insertRoomInfo (el, stanza) {
+                    /* Insert room info (based on returned #disco IQ stanza)
+                     *
+                     * Parameters:
+                     *  (HTMLElement) el: The HTML DOM element that should
+                     *      contain the info.
+                     *  (XMLElement) stanza: The IQ stanza containing the room
+                     *      info.
+                     */
+                    // All MUC features found here: http://xmpp.org/registrar/disco-features.html
+                    el.querySelector('span.spinner').outerHTML =
+                        tpl_room_description({
+                            'jid': stanza.getAttribute('from'),
+                            'desc': _.get(_.head(sizzle('field[var="muc#roominfo_description"] value', stanza)), 'textContent'),
+                            'occ': _.get(_.head(sizzle('field[var="muc#roominfo_occupants"] value', stanza)), 'textContent'),
+                            'hidden': sizzle('feature[var="muc_hidden"]', stanza).length,
+                            'membersonly': sizzle('feature[var="muc_membersonly"]', stanza).length,
+                            'moderated': sizzle('feature[var="muc_moderated"]', stanza).length,
+                            'nonanonymous': sizzle('feature[var="muc_nonanonymous"]', stanza).length,
+                            'open': sizzle('feature[var="muc_open"]', stanza).length,
+                            'passwordprotected': sizzle('feature[var="muc_passwordprotected"]', stanza).length,
+                            'persistent': sizzle('feature[var="muc_persistent"]', stanza).length,
+                            'publicroom': sizzle('feature[var="muc_publicroom"]', stanza).length,
+                            'semianonymous': sizzle('feature[var="muc_semianonymous"]', stanza).length,
+                            'temporary': sizzle('feature[var="muc_temporary"]', stanza).length,
+                            'unmoderated': sizzle('feature[var="muc_unmoderated"]', stanza).length,
+                            'label_desc': __('Description:'),
+                            'label_jid': __('Room Address (JID):'),
+                            'label_occ': __('Occupants:'),
+                            'label_features': __('Features:'),
+                            'label_requires_auth': __('Requires authentication'),
+                            'label_hidden': __('Hidden'),
+                            'label_requires_invite': __('Requires an invitation'),
+                            'label_moderated': __('Moderated'),
+                            'label_non_anon': __('Non-anonymous'),
+                            'label_open_room': __('Open room'),
+                            'label_permanent_room': __('Permanent room'),
+                            'label_public': __('Public'),
+                            'label_semi_anon':  __('Semi-anonymous'),
+                            'label_temp_room':  __('Temporary room'),
+                            'label_unmoderated': __('Unmoderated')
+                        })
+                },
+
+                toggleRoomInfo (ev) {
+                    /* Show/hide extra information about a room in the listing.
+                     */
+                    const parent_el = ev.target.parentElement,
+                        div_el = parent_el.querySelector('div.room-info');
+                    if (div_el) {
+                        u.slideIn(div_el).then(u.removeElement)
+                    } else {
+                        parent_el.insertAdjacentHTML('beforeend', tpl_spinner());
+                        _converse.connection.disco.info(
+                            ev.target.getAttribute('data-room-jid'),
+                            null,
+                            _.partial(this.insertRoomInfo, parent_el)
+                        );
+                    }
+                },
+
+                parseRoomDataFromEvent (ev) {
+                    let name, jid;
+                    if (ev.type === 'click') {
+                        name = ev.target.textContent;
+                        jid = ev.target.getAttribute('data-room-jid');
+                    } else {
+                        const name_el = this.el.querySelector('input.new-chatroom-name');
+                        const server_el= this.el.querySelector('input.new-chatroom-server');
+                        const server = server_el.value;
+                        name = name_el.value.trim();
+                        name_el.value = ''; // Clear the input
+                        if (name && server) {
+                            jid = Strophe.escapeNode(name.toLowerCase()) + '@' + server.toLowerCase();
+                            name_el.classList.remove('error');
+                            server_el.classList.remove('error');
+                            this.model.save({muc_domain: server});
+                        } else {
+                            if (!name) { name_el.classList.add('error'); }
+                            if (!server) { server_el.classList.add('error'); }
+                            return;
+                        }
+                    }
+                    return {
+                        'jid': jid,
+                        'name': name || Strophe.unescapeNode(Strophe.getNodeFromJid(jid)) || jid
+                    }
+                },
+
+                openChatRoom (ev) {
+                    ev.preventDefault();
+                    const data = this.parseRoomDataFromEvent(ev);
+                    if (!_.isUndefined(data)) {
+                        _converse.openChatRoom(data);
+                    }
+                },
+
+                setDomain (ev) {
+                    this.model.save({muc_domain: ev.target.value});
+                },
+
+                setNick (ev) {
+                    this.model.save({nick: ev.target.value});
+                }
+            });
+
+
+            _converse.ChatRoomOccupantView = Backbone.VDOMView.extend({
+                tagName: 'li',
+                initialize () {
+                    this.model.on('change', this.render, this);
+                },
+
+                toHTML () {
+                    const show = this.model.get('show') || 'online';
+                    return tpl_occupant(
+                        _.extend(
+                            { 'jid': '',
+                              'show': show,
+                              'hint_show': _converse.PRETTY_CHAT_STATUS[show],
+                              'hint_occupant': __('Click to mention %1$s in your message.', this.model.get('nick')),
+                              'desc_moderator': __('This user is a moderator.'),
+                              'desc_occupant': __('This user can send messages in this room.'),
+                              'desc_visitor': __('This user can NOT send messages in this room.')
+                            }, this.model.toJSON())
+                    );
+                },
+
+                destroy () {
+                    this.el.parentElement.removeChild(this.el);
+                }
+            });
+
+
+            _converse.ChatRoomOccupantsView = Backbone.OrderedListView.extend({
+                tagName: 'div',
+                className: 'occupants',
+                listItems: 'model',
+                sortEvent: 'change:role',
+                listSelector: '.occupant-list',
+
+                ItemView: _converse.ChatRoomOccupantView,
+
+                initialize () {
+                    Backbone.OrderedListView.prototype.initialize.apply(this, arguments);
+
+                    this.chatroomview = this.model.chatroomview;
+                    this.chatroomview.model.on('change:open', this.renderInviteWidget, this);
+                    this.chatroomview.model.on('change:affiliation', this.renderInviteWidget, this);
+                    this.chatroomview.model.on('change:hidden', this.onFeatureChanged, this);
+                    this.chatroomview.model.on('change:mam_enabled', this.onFeatureChanged, this);
+                    this.chatroomview.model.on('change:membersonly', this.onFeatureChanged, this);
+                    this.chatroomview.model.on('change:moderated', this.onFeatureChanged, this);
+                    this.chatroomview.model.on('change:nonanonymous', this.onFeatureChanged, this);
+                    this.chatroomview.model.on('change:open', this.onFeatureChanged, this);
+                    this.chatroomview.model.on('change:passwordprotected', this.onFeatureChanged, this);
+                    this.chatroomview.model.on('change:persistent', this.onFeatureChanged, this);
+                    this.chatroomview.model.on('change:publicroom', this.onFeatureChanged, this);
+                    this.chatroomview.model.on('change:semianonymous', this.onFeatureChanged, this);
+                    this.chatroomview.model.on('change:temporary', this.onFeatureChanged, this);
+                    this.chatroomview.model.on('change:unmoderated', this.onFeatureChanged, this);
+                    this.chatroomview.model.on('change:unsecured', this.onFeatureChanged, this);
+
+                    const id = b64_sha1(`converse.occupants${_converse.bare_jid}${this.chatroomview.model.get('jid')}`);
+                    this.model.browserStorage = new Backbone.BrowserStorage.session(id);
+                    this.render();
+                    this.model.fetch({
+                        'add': true,
+                        'silent': true,
+                        'success': this.sortAndPositionAllItems.bind(this)
+                    });
+                },
+
+                render () {
+                    this.el.innerHTML = tpl_chatroom_sidebar(
+                        _.extend(this.chatroomview.model.toJSON(), {
+                            'allow_muc_invitations': _converse.allow_muc_invitations,
+                            'label_occupants': __('Occupants')
+                        })
+                    );
+                    if (_converse.allow_muc_invitations) {
+                        _converse.api.waitUntil('rosterContactsFetched').then(
+                            this.renderInviteWidget.bind(this)
+                        );
+                    }
+                    return this.renderRoomFeatures();
+                },
+
+                renderInviteWidget () {
+                    const form = this.el.querySelector('form.room-invite');
+                    if (this.shouldInviteWidgetBeShown()) {
+                        if (_.isNull(form)) {
+                            const heading = this.el.querySelector('.occupants-heading');
+                            heading.insertAdjacentHTML(
+                                'afterend',
+                                tpl_chatroom_invite({
+                                    'error_message': null,
+                                    'label_invitation': __('Invite'),
+                                })
+                            );
+                            this.initInviteWidget();
+                        }
+                    } else if (!_.isNull(form)) {
+                        form.remove();
+                    }
+                    return this;
+                },
+
+                renderRoomFeatures () {
+                    const picks = _.pick(this.chatroomview.model.attributes, _converse.ROOM_FEATURES),
+                        iteratee = (a, v) => a || v,
+                        el = this.el.querySelector('.chatroom-features');
+
+                    el.innerHTML = tpl_chatroom_features(
+                            _.extend(this.chatroomview.model.toJSON(), {
+                                'has_features': _.reduce(_.values(picks), iteratee),
+                                'label_features': __('Features'),
+                                'label_hidden': __('Hidden'),
+                                'label_mam_enabled': __('Message archiving'),
+                                'label_membersonly': __('Members only'),
+                                'label_moderated': __('Moderated'),
+                                'label_nonanonymous': __('Non-anonymous'),
+                                'label_open': __('Open'),
+                                'label_passwordprotected': __('Password protected'),
+                                'label_persistent': __('Persistent'),
+                                'label_public': __('Public'),
+                                'label_semianonymous': __('Semi-anonymous'),
+                                'label_temporary': __('Temporary'),
+                                'label_unmoderated': __('Unmoderated'),
+                                'label_unsecured': __('No password'),
+                                'tt_hidden': __('This room is not publicly searchable'),
+                                'tt_mam_enabled': __('Messages are archived on the server'),
+                                'tt_membersonly': __('This room is restricted to members only'),
+                                'tt_moderated': __('This room is being moderated'),
+                                'tt_nonanonymous': __('All other room occupants can see your XMPP username'),
+                                'tt_open': __('Anyone can join this room'),
+                                'tt_passwordprotected': __('This room requires a password before entry'),
+                                'tt_persistent': __('This room persists even if it\'s unoccupied'),
+                                'tt_public': __('This room is publicly searchable'),
+                                'tt_semianonymous': __('Only moderators can see your XMPP username'),
+                                'tt_temporary': __('This room will disappear once the last person leaves'),
+                                'tt_unmoderated': __('This room is not being moderated'),
+                                'tt_unsecured': __('This room does not require a password upon entry')
+                            }));
+                    this.setOccupantsHeight();
+                    return this;
+                },
+
+                onFeatureChanged (model) {
+                    /* When a feature has been changed, it's logical opposite
+                     * must be set to the opposite value.
+                     *
+                     * So for example, if "temporary" was set to "false", then
+                     * "persistent" will be set to "true" in this method.
+                     *
+                     * Additionally a debounced render method is called to make
+                     * sure the features widget gets updated.
+                     */
+                    if (_.isUndefined(this.debouncedRenderRoomFeatures)) {
+                        this.debouncedRenderRoomFeatures = _.debounce(
+                            this.renderRoomFeatures, 100, {'leading': false}
+                        );
+                    }
+                    const changed_features = {};
+                    _.each(_.keys(model.changed), function (k) {
+                        if (!_.isNil(ROOM_FEATURES_MAP[k])) {
+                            changed_features[ROOM_FEATURES_MAP[k]] = !model.changed[k];
+                        }
+                    });
+                    this.chatroomview.model.save(changed_features, {'silent': true});
+                    this.debouncedRenderRoomFeatures();
+                },
+
+                setOccupantsHeight () {
+                    const el = this.el.querySelector('.chatroom-features');
+                    this.el.querySelector('.occupant-list').style.cssText =
+                        `height: calc(100% - ${el.offsetHeight}px - 5em);`;
+                },
+
+                parsePresence (pres) {
+                    const id = Strophe.getResourceFromJid(pres.getAttribute("from"));
+                    const data = {
+                        nick: id,
+                        type: pres.getAttribute("type"),
+                        states: []
+                    };
+                    _.each(pres.childNodes, function (child) {
+                        switch (child.nodeName) {
+                            case "status":
+                                data.status = child.textContent || null;
+                                break;
+                            case "show":
+                                data.show = child.textContent || 'online';
+                                break;
+                            case "x":
+                                if (child.getAttribute("xmlns") === Strophe.NS.MUC_USER) {
+                                    _.each(child.childNodes, function (item) {
+                                        switch (item.nodeName) {
+                                            case "item":
+                                                data.affiliation = item.getAttribute("affiliation");
+                                                data.role = item.getAttribute("role");
+                                                data.jid = item.getAttribute("jid");
+                                                data.nick = item.getAttribute("nick") || data.nick;
+                                                break;
+                                            case "status":
+                                                if (item.getAttribute("code")) {
+                                                    data.states.push(item.getAttribute("code"));
+                                                }
+                                        }
+                                    });
+                                }
+                        }
+                    });
+                    return data;
+                },
+
+                findOccupant (data) {
+                    /* 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.
+                     */
+                    const jid = Strophe.getBareJidFromJid(data.jid);
+                    if (jid !== null) {
+                        return this.model.where({'jid': jid}).pop();
+                    } else {
+                        return this.model.where({'nick': data.nick}).pop();
+                    }
+                },
+
+                updateOccupantsOnPresence (pres) {
+                    /* Given a presence stanza, update the occupant models
+                     * based on its contents.
+                     *
+                     * Parameters:
+                     *  (XMLElement) pres: The presence stanza
+                     */
+                    const data = this.parsePresence(pres);
+                    if (data.type === 'error') {
+                        return true;
+                    }
+                    const occupant = this.findOccupant(data);
+                    if (data.type === 'unavailable') {
+                        if (occupant) { occupant.destroy(); }
+                    } else {
+                        const jid = Strophe.getBareJidFromJid(data.jid);
+                        const attributes = _.extend(data, {
+                            'jid': jid ? jid : undefined,
+                            'resource': data.jid ? Strophe.getResourceFromJid(data.jid) : undefined
+                        });
+                        if (occupant) {
+                            occupant.save(attributes);
+                        } else {
+                            this.model.create(attributes);
+                        }
+                    }
+                },
+
+                promptForInvite (suggestion) {
+                    const reason = prompt(
+                        __('You are about to invite %1$s to the chat room "%2$s". '+
+                           'You may optionally include a message, explaining the reason for the invitation.',
+                           suggestion.text.label, this.model.get('id'))
+                    );
+                    if (reason !== null) {
+                        this.chatroomview.directInvite(suggestion.text.value, reason);
+                    }
+                    const form = suggestion.target.form,
+                          error = form.querySelector('.pure-form-message.error');
+                    if (!_.isNull(error)) {
+                        error.parentNode.removeChild(error);
+                    }
+                    suggestion.target.value = '';
+                },
+
+                inviteFormSubmitted (evt) {
+                    evt.preventDefault();
+                    const el = evt.target.querySelector('input.invited-contact'),
+                          jid = el.value;
+                    if (!jid || _.compact(jid.split('@')).length < 2) {
+                        evt.target.outerHTML = tpl_chatroom_invite({
+                            'error_message': __('Please enter a valid XMPP username'),
+                            'label_invitation': __('Invite'),
+                        });
+                        this.initInviteWidget();
+                        return;
+                    }
+                    this.promptForInvite({
+                        'target': el,
+                        'text': {
+                            'label': jid,
+                            'value': jid
+                        }});
+                },
+
+                shouldInviteWidgetBeShown () {
+                    return _converse.allow_muc_invitations &&
+                        (this.chatroomview.model.get('open') ||
+                            this.chatroomview.model.get('affiliation') === "owner"
+                        );
+                },
+
+                initInviteWidget () {
+                    const form = this.el.querySelector('form.room-invite');
+                    if (_.isNull(form)) {
+                        return;
+                    }
+                    form.addEventListener('submit', this.inviteFormSubmitted.bind(this), false);
+                    const el = this.el.querySelector('input.invited-contact');
+                    const list = _converse.roster.map(function (item) {
+                            const label = item.get('fullname') || item.get('jid');
+                            return {'label': label, 'value':item.get('jid')};
+                        });
+                    const awesomplete = new Awesomplete(el, {
+                        'minChars': 1,
+                        'list': list
+                    });
+                    el.addEventListener('awesomplete-selectcomplete',
+                        this.promptForInvite.bind(this));
+                }
+            });
+
+
+            function setMUCDomain (domain, controlboxview) {
+                _converse.muc_domain = domain;
+                controlboxview.roomspanel.model.save({'muc_domain': domain});
+            }
+
+            function setMUCDomainFromDisco (controlboxview) {
+                /* Check whether service discovery for the user's domain
+                 * returned MUC information and use that to automatically
+                 * set the MUC domain for the "Rooms" panel of the controlbox.
+                 */
+                function featureAdded (feature) {
+                    if ((feature.get('var') === Strophe.NS.MUC)) {
+                        setMUCDomain(feature.get('from'), controlboxview);
+                    }
+                }
+                _converse.api.waitUntil('discoInitialized').then(() => {
+                    _converse.api.listen.on('serviceDiscovered', featureAdded);
+                    // Features could have been added before the controlbox was
+                    // initialized. We're only interested in MUC
+                    _converse.disco_entities.each((entity) => {
+                        const feature = entity.features.findWhere({'var': Strophe.NS.MUC });
+                        if (feature) {
+                            featureAdded(feature)
+                        }
+                    });
+                }).catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
+            }
+
+            function fetchAndSetMUCDomain (controlboxview) {
+                if (controlboxview.model.get('connected')) {
+                    if (!controlboxview.roomspanel.model.get('muc_domain')) {
+                        if (_.isUndefined(_converse.muc_domain)) {
+                            setMUCDomainFromDisco(controlboxview);
+                        } else {
+                            setMUCDomain(_converse.muc_domain, controlboxview);
+                        }
+                    }
+                }
+            }
+
+            /************************ BEGIN Event Handlers ************************/
+            _converse.on('controlboxInitialized', (view) => {
+                if (!_converse.allow_muc) {
+                    return;
+                }
+                fetchAndSetMUCDomain(view);
+                view.model.on('change:connected', _.partial(fetchAndSetMUCDomain, view));
+            });
+            /************************ END Event Handlers ************************/
+        }
+    });
+}));

Fichier diff supprimé car celui-ci est trop grand
+ 169 - 2617
src/converse-muc.js


+ 1 - 0
src/converse.js

@@ -14,6 +14,7 @@ if (typeof define !== 'undefined') {
         "converse-mam",         // XEP-0313 Message Archive Management
         "converse-mam",         // XEP-0313 Message Archive Management
         "converse-muc",         // XEP-0045 Multi-user chat
         "converse-muc",         // XEP-0045 Multi-user chat
         "converse-muc-embedded",
         "converse-muc-embedded",
+        "converse-muc-views",
         "converse-vcard",       // XEP-0054 VCard-temp
         "converse-vcard",       // XEP-0054 VCard-temp
         "converse-otr",         // Off-the-record encryption for one-on-one messages
         "converse-otr",         // Off-the-record encryption for one-on-one messages
         "converse-register",    // XEP-0077 In-band registration
         "converse-register",    // XEP-0077 In-band registration

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff