Jelajahi Sumber

Initial work on breaking converse.js up into components

First component to remove is MUC which was moved to src/converse-muc.js
The components are written as plugins and use converse.js's plugin machinery.

Update the plugin docs somewhat with new insites found while working on the muc
plugin.
JC Brand 9 tahun lalu
induk
melakukan
576efc4815
5 mengubah file dengan 4345 tambahan dan 4127 penghapusan
  1. 9 4122
      converse.js
  2. 20 0
      docs/source/development.rst
  3. 9 5
      main.js
  4. 3152 0
      src/converse-core.js
  5. 1155 0
      src/converse-muc.js

File diff ditekan karena terlalu besar
+ 9 - 4122
converse.js


+ 20 - 0
docs/source/development.rst

@@ -837,6 +837,26 @@ An example plugin
         // The following line registers your plugin.
         converse_api.plugins.add('myplugin', {
 
+            initialize: function () {
+                // Converse.js's plugin mechanism will call the initialize
+                // method on any plugin (if it exists) as soon as the plugin has
+                // been loaded.
+
+                // Inside this method, you have access to the protected "inner"
+                // converse object, from which you can get any configuration
+                // options that the user might have passed in via
+                // converse.initialize. These values are stored in the
+                // "user_settings" attribute.
+                
+                // Let's assume the user might in a custom setting, like so:
+                // converse.initialize({
+                //      "initialize_message": "My plugin has been initialized"
+                // });
+                //
+                // Then we can alert that message, like so:
+                alert(this.converse.user_settings.initialize_message);
+            },
+
             myFunction: function () {
                 // This is a function which does not override anything in
                 // converse.js itself, but in which you still have access to

+ 9 - 5
main.js

@@ -17,8 +17,6 @@ require.config({
         "backbone":                 "components/backbone/backbone",
         "backbone.browserStorage":  "components/backbone.browserStorage/backbone.browserStorage",
         "backbone.overview":        "components/backbone.overview/backbone.overview",
-        "converse-dependencies":    "src/deps-full",
-        "converse-templates":       "src/templates",
         "eventemitter":             "components/otr/build/dep/eventemitter",
         "jquery":                   "components/jquery/dist/jquery",
         "jquery-private":           "src/jquery-private",
@@ -44,6 +42,12 @@ require.config({
         "underscore":               "components/underscore/underscore",
         "utils":                    "src/utils",
         "polyfill":                 "src/polyfill",
+        
+        // Converse
+        "converse-core":            "src/converse-core",
+        "converse-muc":             "src/converse-muc",
+        "converse-dependencies":    "src/deps-full",
+        "converse-templates":       "src/templates",
 
         // Off-the-record-encryption
         "bigint":               "src/bigint",
@@ -57,7 +61,7 @@ require.config({
         "crypto.md5":           "components/crypto-js-evanvosberg/src/md5",
         "crypto.mode-ctr":      "components/otr/vendor/cryptojs/mode-ctr",
         "crypto.pad-nopadding": "components/otr/vendor/cryptojs/pad-nopadding",
-        "crypto.sha1":         "components/otr/vendor/cryptojs/sha1",
+        "crypto.sha1":          "components/otr/vendor/cryptojs/sha1",
         "crypto.sha256":        "components/otr/vendor/cryptojs/sha256",
         "salsa20":              "components/otr/build/dep/salsa20",
         "otr":                  "src/otr",
@@ -195,7 +199,7 @@ require.config({
 });
 
 if (typeof(require) === 'function') {
-    require(["converse"], function(converse) {
-        window.converse = converse;
+    require(["converse"], function(converse_api) {
+        window.converse = converse_api;
     });
 }

File diff ditekan karena terlalu besar
+ 3152 - 0
src/converse-core.js


+ 1155 - 0
src/converse-muc.js

@@ -0,0 +1,1155 @@
+// Converse.js (A browser based XMPP chat client)
+// http://conversejs.org
+//
+// Copyright (c) 2012-2016, Jan-Carel Brand <jc@opkode.com>
+// Licensed under the Mozilla Public License (MPLv2)
+//
+/*global converse, utils, Backbone, define, window, setTimeout */
+
+(function (root, factory) {
+    if (typeof define === 'function' && define.amd) {
+        // AMD module loading
+        define("converse-muc", ["converse-core", "utils"], factory);
+    } else {
+        // When not using a module loader
+        // -------------------------------
+        // In this case, the dependencies need to be available already as
+        // global variables, and should be loaded separately via *script* tags.
+        // See the file **non_amd.html** for an example of this usecase.
+        root.converse = factory(converse, utils);
+    }
+}(this, function (converse_api, utils) {
+    // Strophe methods for building stanzas
+    var Strophe = converse_api.env.Strophe,
+        $iq = converse_api.env.$iq,
+        $msg = converse_api.env.$msg,
+        $pres = converse_api.env.$pres,
+        $build = converse_api.env.$build,
+        b64_sha1 = converse_api.env.b64_sha1;
+    // Other necessary globals
+    var $ = converse_api.env.jQuery,
+        _ = converse_api.env._,
+        moment = converse_api.env.moment;
+
+    // Translation machinery
+    // ---------------------
+    var __ = utils.__.bind(this);
+    var ___ = utils.___;
+    
+    // Add Strophe Namespaces
+    Strophe.addNamespace('MUC_ADMIN', Strophe.NS.MUC + "#admin");
+    Strophe.addNamespace('MUC_OWNER', Strophe.NS.MUC + "#owner");
+    Strophe.addNamespace('MUC_REGISTER', "jabber:iq:register");
+    Strophe.addNamespace('MUC_ROOMCONF', Strophe.NS.MUC + "#roomconfig");
+    Strophe.addNamespace('MUC_USER', Strophe.NS.MUC + "#user");
+
+    converse_api.plugins.add('muc', {
+        /* This plugin adds support for XEP-0045 Multi-user chat
+         */
+
+        overrides: {
+            // Overrides mentioned here will be picked up by converse.js's
+            // plugin architecture they will replace existing methods on the
+            // relevant objects or classes.
+            //
+            // New functions which don't exist yet can also be added.
+            ChatBoxView: {
+                clearChatRoomMessages: function (ev) {
+                    /* New method added to the ChatBox model which allows all
+                     * messages in a chatroom to be cleared.
+                     */
+                    if (typeof ev !== "undefined") { ev.stopPropagation(); }
+                    var result = confirm(__("Are you sure you want to clear the messages from this room?"));
+                    if (result === true) {
+                        this.$content.empty();
+                    }
+                    return this;
+                },
+            },
+
+            ChatBoxes: {
+                registerMessageHandler: function () {
+                    /* Override so that we can register a handler
+                     * for chat room invites.
+                     */
+                    this._super.registerMessageHandler(); // First call the original
+                    this._super.converse.connection.addHandler(
+                        function (message) {
+                            this.onInvite(message);
+                            return true;
+                        }.bind(this), 'jabber:x:conference', 'message');
+                },
+
+                onInvite: function (message) {
+                    /* An invitation to join a chat room has been received */
+                    var converse = this._super.converse,
+                        $message = $(message),
+                        $x = $message.children('x[xmlns="jabber:x:conference"]'),
+                        from = Strophe.getBareJidFromJid($message.attr('from')),
+                        room_jid = $x.attr('jid'),
+                        reason = $x.attr('reason'),
+                        contact = converse.roster.get(from),
+                        result;
+
+                    if (converse.auto_join_on_invite) {
+                        result = true;
+                    } else {
+                        contact = contact? contact.get('fullname'): Strophe.getNodeFromJid(from);   // Invite request might come from someone not your roster list
+                        if (!reason) {
+                            result = confirm(
+                                __(___("%1$s has invited you to join a chat room: %2$s"), contact, room_jid)
+                            );
+                        } else {
+                            result = confirm(
+                                __(___('%1$s has invited you to join a chat room: %2$s, and left the following reason: "%3$s"'), contact, room_jid, reason)
+                            );
+                        }
+                    }
+                    if (result === true) {
+                        var chatroom = converse.chatboxviews.showChat({
+                            'id': room_jid,
+                            'jid': room_jid,
+                            'name': Strophe.unescapeNode(Strophe.getNodeFromJid(room_jid)),
+                            'nick': Strophe.unescapeNode(Strophe.getNodeFromJid(converse.connection.jid)),
+                            'chatroom': true,
+                            'box_id': b64_sha1(room_jid),
+                            'password': $x.attr('password')
+                        });
+                        if (!_.contains(
+                                    [Strophe.Status.CONNECTING, Strophe.Status.CONNECTED],
+                                    chatroom.get('connection_status'))
+                                ) {
+                            converse.chatboxviews.get(room_jid).join(null);
+                        }
+                    }
+                }
+            },
+        },
+
+        initialize: function () {
+            /* The initialize function gets called as soon as the plugin is
+             * loaded by converse.js's plugin machinery.
+             */
+
+            var converse = this.converse;
+            // Configuration values for this plugin
+            var settings = {
+                auto_join_on_invite: false  // Auto-join chatroom on invite
+            };
+            _.extend(this, settings);
+            _.extend(this, _.pick(converse.user_settings, Object.keys(settings)));
+
+            converse.ChatRoomView = converse.ChatBoxView.extend({
+                /* Backbone View which renders a chat room, based upon the view
+                * for normal one-on-one chat boxes.
+                */
+                length: 300,
+                tagName: 'div',
+                className: 'chatbox chatroom',
+                events: {
+                    'click .close-chatbox-button': 'close',
+                    'click .toggle-chatbox-button': 'minimize',
+                    'click .configure-chatroom-button': 'configureChatRoom',
+                    'click .toggle-smiley': 'toggleEmoticonMenu',
+                    'click .toggle-smiley ul li': 'insertEmoticon',
+                    'click .toggle-clear': 'clearChatRoomMessages',
+                    'click .toggle-call': 'toggleCall',
+                    'click .toggle-occupants a': 'toggleOccupants',
+                    'keypress textarea.chat-textarea': 'keyPressed',
+                    'mousedown .dragresize-top': 'onStartVerticalResize',
+                    'mousedown .dragresize-left': 'onStartHorizontalResize',
+                    'mousedown .dragresize-topleft': 'onStartDiagonalResize'
+                },
+                is_chatroom: true,
+
+                initialize: function () {
+                    $(window).on('resize', _.debounce(this.setDimensions.bind(this), 100));
+                    this.model.messages.on('add', this.onMessageAdded, this);
+                    this.model.on('change:minimized', function (item) {
+                        if (item.get('minimized')) {
+                            this.hide();
+                        } else {
+                            this.maximize();
+                        }
+                    }, this);
+                    this.model.on('destroy', function () {
+                        this.hide().leave();
+                    }, this);
+
+                    this.occupantsview = new converse.ChatRoomOccupantsView({
+                        model: new converse.ChatRoomOccupants({nick: this.model.get('nick')})
+                    });
+                    var id = b64_sha1('converse.occupants'+converse.bare_jid+this.model.get('id')+this.model.get('nick'));
+                    this.occupantsview.model.browserStorage = new Backbone.BrowserStorage[converse.storage](id);
+
+                    this.occupantsview.chatroomview = this;
+                    this.render().$el.hide();
+                    this.occupantsview.model.fetch({add:true});
+                    this.join(null, {'maxstanzas': converse.muc_history_max_stanzas});
+                    this.fetchMessages();
+                    converse.emit('chatRoomOpened', this);
+
+                    this.$el.insertAfter(converse.chatboxviews.get("controlbox").$el);
+                    if (this.model.get('minimized')) {
+                        this.hide();
+                    } else {
+                        this.show();
+                    }
+                },
+
+                render: function () {
+                    this.$el.attr('id', this.model.get('box_id'))
+                            .html(converse.templates.chatroom(this.model.toJSON()));
+                    this.renderChatArea();
+                    this.$content.on('scroll', _.debounce(this.onScroll.bind(this), 100));
+                    this.setWidth();
+                    setTimeout(converse.refreshWebkit, 50);
+                    return this;
+                },
+
+                renderChatArea: function () {
+                    if (!this.$('.chat-area').length) {
+                        this.$('.chatroom-body').empty()
+                            .append(
+                                converse.templates.chatarea({
+                                    'show_toolbar': converse.show_toolbar,
+                                    'label_message': __('Message')
+                                }))
+                            .append(this.occupantsview.render().$el);
+                        this.renderToolbar();
+                        this.$content = this.$el.find('.chat-content');
+                    }
+                    this.toggleOccupants(null, true);
+                    return this;
+                },
+
+                toggleOccupants: function (ev, preserve_state) {
+                    if (ev) {
+                        ev.preventDefault();
+                        ev.stopPropagation();
+                    }
+                    if (preserve_state) {
+                        // Bit of a hack, to make sure that the sidebar's state doesn't change
+                        this.model.set({hidden_occupants: !this.model.get('hidden_occupants')});
+                    }
+                    var $el = this.$('.icon-hide-users');
+                    if (!this.model.get('hidden_occupants')) {
+                        this.model.save({hidden_occupants: true});
+                        $el.removeClass('icon-hide-users').addClass('icon-show-users');
+                        this.$('.occupants').addClass('hidden');
+                        this.$('.chat-area').addClass('full');
+                        this.scrollDown();
+                    } else {
+                        this.model.save({hidden_occupants: false});
+                        $el.removeClass('icon-show-users').addClass('icon-hide-users');
+                        this.$('.chat-area').removeClass('full');
+                        this.$('div.occupants').removeClass('hidden');
+                        this.scrollDown();
+                    }
+                },
+
+                directInvite: function (receiver, reason) {
+                    var 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'); }
+                    var invitation = $msg({
+                        from: converse.connection.jid,
+                        to: receiver,
+                        id: converse.connection.getUniqueId()
+                    }).c('x', attrs);
+                    converse.connection.send(invitation);
+                    converse.emit('roomInviteSent', this, receiver, reason);
+                },
+
+                onCommandError: function (stanza) {
+                    this.showStatusNotification(__("Error: could not execute the command"), true);
+                },
+
+                sendChatRoomMessage: function (text) {
+                    var msgid = converse.connection.getUniqueId();
+                    var 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("composing");
+                    converse.connection.send(msg);
+
+                    var fullname = converse.xmppstatus.get('fullname');
+                    this.model.messages.create({
+                        fullname: _.isEmpty(fullname)? converse.bare_jid: fullname,
+                        sender: 'me',
+                        time: moment().format(),
+                        message: text,
+                        msgid: msgid
+                    });
+                },
+
+                setAffiliation: function(room, jid, affiliation, reason, onSuccess, onError) {
+                    var item = $build("item", {jid: jid, affiliation: affiliation});
+                    var 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.tree(), onSuccess, onError);
+                },
+
+                modifyRole: function(room, nick, role, reason, onSuccess, onError) {
+                    var item = $build("item", {nick: nick, role: role});
+                    var 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.tree(), onSuccess, onError);
+                },
+
+                member: function(room, jid, reason, handler_cb, error_cb) {
+                    return this.setAffiliation(room, jid, 'member', reason, handler_cb, error_cb);
+                },
+                revoke: function(room, jid, reason, handler_cb, error_cb) {
+                    return this.setAffiliation(room, jid, 'none', reason, handler_cb, error_cb);
+                },
+                owner: function(room, jid, reason, handler_cb, error_cb) {
+                    return this.setAffiliation(room, jid, 'owner', reason, handler_cb, error_cb);
+                },
+                admin: function(room, jid, reason, handler_cb, error_cb) {
+                    return this.setAffiliation(room, jid, 'admin', reason, handler_cb, error_cb);
+                },
+
+                validateRoleChangeCommand: function (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 \""+command+"\" command takes two arguments, the user's nickname and optionally a reason."),
+                            true
+                        );
+                        return false;
+                    }
+                    return true;
+                },
+
+                onChatRoomMessageSubmitted: function (text) {
+                    /* Gets called when the user presses enter to send off a
+                    * message in a chat room.
+                    *
+                    * Parameters:
+                    *    (String) text - The message text.
+                    */
+                    var match = text.replace(/^\s*/, "").match(/^\/(.*?)(?: (.*))?$/) || [false, '', ''],
+                        args = match[2] && match[2].splitOnce(' ') || [];
+                    switch (match[1]) {
+                        case 'admin':
+                            if (!this.validateRoleChangeCommand(match[1], args)) { break; }
+                            this.setAffiliation(
+                                    this.model.get('jid'), args[0], 'admin', args[1],
+                                    undefined, this.onCommandError.bind(this));
+                            break;
+                        case 'ban':
+                            if (!this.validateRoleChangeCommand(match[1], args)) { break; }
+                            this.setAffiliation(
+                                    this.model.get('jid'), args[0], 'outcast', args[1],
+                                    undefined, this.onCommandError.bind(this));
+                            break;
+                        case 'clear':
+                            this.clearChatRoomMessages();
+                            break;
+                        case 'deop':
+                            if (!this.validateRoleChangeCommand(match[1], args)) { break; }
+                            this.modifyRole(
+                                    this.model.get('jid'), args[0], 'occupant', 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 occupant'),
+                                '<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>/topic</strong>: ' +__('Set room topic'),
+                                '<strong>/voice</strong>: ' +__('Allow muted user to post messages')
+                            ]);
+                            break;
+                        case 'kick':
+                            if (!this.validateRoleChangeCommand(match[1], args)) { break; }
+                            this.modifyRole(
+                                    this.model.get('jid'), args[0], 'none', args[1],
+                                    undefined, this.onCommandError.bind(this));
+                            break;
+                        case 'mute':
+                            if (!this.validateRoleChangeCommand(match[1], args)) { break; }
+                            this.modifyRole(
+                                    this.model.get('jid'), args[0], 'visitor', args[1],
+                                    undefined, this.onCommandError.bind(this));
+                            break;
+                        case 'member':
+                            if (!this.validateRoleChangeCommand(match[1], args)) { break; }
+                            this.setAffiliation(
+                                    this.model.get('jid'), args[0], 'member', args[1],
+                                    undefined, 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(match[1], args)) { break; }
+                            this.setAffiliation(
+                                    this.model.get('jid'), args[0], 'owner', args[1],
+                                    undefined, this.onCommandError.bind(this));
+                            break;
+                        case 'op':
+                            if (!this.validateRoleChangeCommand(match[1], args)) { break; }
+                            this.modifyRole(
+                                    this.model.get('jid'), args[0], 'moderator', args[1],
+                                    undefined, this.onCommandError.bind(this));
+                            break;
+                        case 'revoke':
+                            if (!this.validateRoleChangeCommand(match[1], args)) { break; }
+                            this.setAffiliation(
+                                    this.model.get('jid'), args[0], 'none', args[1],
+                                    undefined, this.onCommandError.bind(this));
+                            break;
+                        case 'topic':
+                            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(match[1], args)) { break; }
+                            this.modifyRole(
+                                    this.model.get('jid'), args[0], 'occupant', args[1],
+                                    undefined, this.onCommandError.bind(this));
+                            break;
+                        default:
+                            this.sendChatRoomMessage(text);
+                        break;
+                    }
+                },
+
+                handleMUCStanza: function (stanza) {
+                    var xmlns, xquery, i;
+                    var from = stanza.getAttribute('from');
+                    var is_mam = $(stanza).find('[xmlns="'+Strophe.NS.MAM+'"]').length > 0;
+                    if (!from || (this.model.get('id') !== from.split("/")[0])  || is_mam) {
+                        return true;
+                    }
+                    if (stanza.nodeName === "message") {
+                        _.compose(this.onChatRoomMessage.bind(this), this.showStatusMessages.bind(this))(stanza);
+                    } else if (stanza.nodeName === "presence") {
+                        xquery = stanza.getElementsByTagName("x");
+                        if (xquery.length > 0) {
+                            for (i = 0; i < xquery.length; i++) {
+                                xmlns = xquery[i].getAttribute("xmlns");
+                                if (xmlns && xmlns.match(Strophe.NS.MUC)) {
+                                    this.onChatRoomPresence(stanza);
+                                    break;
+                                }
+                            }
+                        }
+                    }
+                    return true;
+                },
+
+                getRoomJIDAndNick: function (nick) {
+                    nick = nick || this.model.get('nick');
+                    var room = this.model.get('jid');
+                    var node = Strophe.getNodeFromJid(room);
+                    var domain = Strophe.getDomainFromJid(room);
+                    return node + "@" + domain + (nick !== null ? "/" + nick : "");
+                },
+
+                join: function (password, history_attrs, extended_presence) {
+                    var stanza = $pres({
+                        from: converse.connection.jid,
+                        to: this.getRoomJIDAndNick()
+                    }).c("x", {
+                        xmlns: Strophe.NS.MUC
+                    });
+                    if (typeof history_attrs === "object" && Object.keys(history_attrs).length) {
+                        stanza = stanza.c("history", history_attrs).up();
+                    }
+                    if (password) {
+                        stanza.cnode(Strophe.xmlElement("password", [], password));
+                    }
+                    if (typeof extended_presence !== "undefined" && extended_presence !== null) {
+                        stanza.up.cnode(extended_presence);
+                    }
+                    if (!this.handler) {
+                        this.handler = converse.connection.addHandler(this.handleMUCStanza.bind(this));
+                    }
+                    this.model.set('connection_status', Strophe.Status.CONNECTING);
+                    return converse.connection.send(stanza);
+                },
+
+                leave: function(exit_msg) {
+                    var presenceid = converse.connection.getUniqueId();
+                    var presence = $pres({
+                        type: "unavailable",
+                        id: presenceid,
+                        from: converse.connection.jid,
+                        to: this.getRoomJIDAndNick()
+                    });
+                    if (exit_msg !== null) {
+                        presence.c("status", exit_msg);
+                    }
+                    converse.connection.addHandler(
+                        function () { this.model.set('connection_status', Strophe.Status.DISCONNECTED); }.bind(this),
+                        null, "presence", null, presenceid);
+                    converse.connection.send(presence);
+                },
+
+                renderConfigurationForm: function (stanza) {
+                    var $form = this.$el.find('form.chatroom-form'),
+                        $fieldset = $form.children('fieldset:first'),
+                        $stanza = $(stanza),
+                        $fields = $stanza.find('field'),
+                        title = $stanza.find('title').text(),
+                        instructions = $stanza.find('instructions').text();
+                    $fieldset.find('span.spinner').remove();
+                    $fieldset.append($('<legend>').text(title));
+                    if (instructions && instructions !== title) {
+                        $fieldset.append($('<p class="instructions">').text(instructions));
+                    }
+                    _.each($fields, function (field) {
+                        $fieldset.append(utils.xForm2webForm($(field), $stanza));
+                    });
+                    $form.append('<fieldset></fieldset>');
+                    $fieldset = $form.children('fieldset:last');
+                    $fieldset.append('<input type="submit" class="pure-button button-primary" value="'+__('Save')+'"/>');
+                    $fieldset.append('<input type="button" class="pure-button button-cancel" value="'+__('Cancel')+'"/>');
+                    $fieldset.find('input[type=button]').on('click', this.cancelConfiguration.bind(this));
+                    $form.on('submit', this.saveConfiguration.bind(this));
+                },
+
+                sendConfiguration: function(config, onSuccess, onError) {
+                    // Send an IQ stanza with the room configuration.
+                    var 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(); });
+                    return converse.connection.sendIQ(iq.tree(), onSuccess, onError);
+                },
+
+                saveConfiguration: function (ev) {
+                    ev.preventDefault();
+                    var that = this;
+                    var $inputs = $(ev.target).find(':input:not([type=button]):not([type=submit])'),
+                        count = $inputs.length,
+                        configArray = [];
+                    $inputs.each(function () {
+                        configArray.push(utils.webForm2xForm(this));
+                        if (!--count) {
+                            that.sendConfiguration(
+                                configArray,
+                                that.onConfigSaved.bind(that),
+                                that.onErrorConfigSaved.bind(that)
+                            );
+                        }
+                    });
+                    this.$el.find('div.chatroom-form-container').hide(
+                        function () {
+                            $(this).remove();
+                            that.$el.find('.chat-area').removeClass('hidden');
+                            that.$el.find('.occupants').removeClass('hidden');
+                        });
+                },
+
+                onConfigSaved: function (stanza) {
+                    // TODO: provide feedback
+                },
+
+                onErrorConfigSaved: function (stanza) {
+                    this.showStatusNotification(__("An error occurred while trying to save the form."));
+                },
+
+                cancelConfiguration: function (ev) {
+                    ev.preventDefault();
+                    var that = this;
+                    this.$el.find('div.chatroom-form-container').hide(
+                        function () {
+                            $(this).remove();
+                            that.$el.find('.chat-area').removeClass('hidden');
+                            that.$el.find('.occupants').removeClass('hidden');
+                        });
+                },
+
+                configureChatRoom: function (ev) {
+                    ev.preventDefault();
+                    if (this.$el.find('div.chatroom-form-container').length) {
+                        return;
+                    }
+                    this.$('.chatroom-body').children().addClass('hidden');
+                    this.$('.chatroom-body').append(converse.templates.chatroom_form());
+                    converse.connection.sendIQ(
+                            $iq({
+                                to: this.model.get('jid'),
+                                type: "get"
+                            }).c("query", {xmlns: Strophe.NS.MUC_OWNER}).tree(),
+                            this.renderConfigurationForm.bind(this)
+                    );
+                },
+
+                submitPassword: function (ev) {
+                    ev.preventDefault();
+                    var password = this.$el.find('.chatroom-form').find('input[type=password]').val();
+                    this.$el.find('.chatroom-form-container').replaceWith('<span class="spinner centered"/>');
+                    this.join(password);
+                },
+
+                renderPasswordForm: function () {
+                    this.$('.chatroom-body').children().hide();
+                    this.$('span.centered.spinner').remove();
+                    this.$('.chatroom-body').append(
+                        converse.templates.chatroom_password_form({
+                            heading: __('This chatroom requires a password'),
+                            label_password: __('Password: '),
+                            label_submit: __('Submit')
+                        }));
+                    this.$('.chatroom-form').on('submit', this.submitPassword.bind(this));
+                },
+
+                showDisconnectMessage: function (msg) {
+                    this.$('.chat-area').hide();
+                    this.$('.occupants').hide();
+                    this.$('span.centered.spinner').remove();
+                    this.$('.chatroom-body').append($('<p>'+msg+'</p>'));
+                },
+
+                /* http://xmpp.org/extensions/xep-0045.html
+                * ----------------------------------------
+                * 100 message      Entering a room         Inform user that any occupant is allowed to see the user's full JID
+                * 101 message (out of band)                Affiliation change  Inform user that his or her affiliation changed while not in the room
+                * 102 message      Configuration change    Inform occupants that room now shows unavailable members
+                * 103 message      Configuration change    Inform occupants that room now does not show unavailable members
+                * 104 message      Configuration change    Inform occupants that a non-privacy-related room configuration change has occurred
+                * 110 presence     Any room presence       Inform user that presence refers to one of its own room occupants
+                * 170 message or initial presence          Configuration change    Inform occupants that room logging is now enabled
+                * 171 message      Configuration change    Inform occupants that room logging is now disabled
+                * 172 message      Configuration change    Inform occupants that the room is now non-anonymous
+                * 173 message      Configuration change    Inform occupants that the room is now semi-anonymous
+                * 174 message      Configuration change    Inform occupants that the room is now fully-anonymous
+                * 201 presence     Entering a room         Inform user that a new room has been created
+                * 210 presence     Entering a room         Inform user that the service has assigned or modified the occupant's roomnick
+                * 301 presence     Removal from room       Inform user that he or she has been banned from the room
+                * 303 presence     Exiting a room          Inform all occupants of new room nickname
+                * 307 presence     Removal from room       Inform user that he or she has been kicked from the room
+                * 321 presence     Removal from room       Inform user that he or she is being removed from the room because of an affiliation change
+                * 322 presence     Removal from room       Inform user that he or she is being removed from the room because the room has been changed to members-only and the user is not a member
+                * 332 presence     Removal from room       Inform user that he or she is being removed from the room because of a system shutdown
+                */
+                infoMessages: {
+                    100: __('This room is not anonymous'),
+                    102: __('This room now shows unavailable members'),
+                    103: __('This room does not show unavailable members'),
+                    104: __('Non-privacy-related room configuration has changed'),
+                    170: __('Room logging is now enabled'),
+                    171: __('Room logging is now disabled'),
+                    172: __('This room is now non-anonymous'),
+                    173: __('This room is now semi-anonymous'),
+                    174: __('This room is now fully-anonymous'),
+                    201: __('A new room has been created')
+                },
+
+                disconnectMessages: {
+                    301: __('You have been banned from this room'),
+                    307: __('You have been kicked from this room'),
+                    321: __("You have been removed from this room because of an affiliation change"),
+                    322: __("You have been removed from this room because the room has changed to members-only and you're not a member"),
+                    332: __("You have been removed from this room because the MUC (Multi-user chat) service is being shut down.")
+                },
+
+                actionInfoMessages: {
+                    /* XXX: Note the triple underscore function and not double
+                    * underscore.
+                    *
+                    * This is a hack. We can't pass the strings to __ because we
+                    * don't yet know what the variable to interpolate is.
+                    *
+                    * Triple underscore will just return the string again, but we
+                    * can then at least tell gettext to scan for it so that these
+                    * strings are picked up by the translation machinery.
+                    */
+                    301: ___("<strong>%1$s</strong> has been banned"),
+                    303: ___("<strong>%1$s</strong>'s nickname has changed"),
+                    307: ___("<strong>%1$s</strong> has been kicked out"),
+                    321: ___("<strong>%1$s</strong> has been removed because of an affiliation change"),
+                    322: ___("<strong>%1$s</strong> has been removed for not being a member")
+                },
+
+                newNicknameMessages: {
+                    210: ___('Your nickname has been automatically changed to: <strong>%1$s</strong>'),
+                    303: ___('Your nickname has been changed to: <strong>%1$s</strong>')
+                },
+
+                showStatusMessages: function (el, is_self) {
+                    /* Check for status codes and communicate their purpose to the user.
+                    * Allow user to configure chat room if they are the owner.
+                    * See: http://xmpp.org/registrar/mucstatus.html
+                    */
+                    var $el = $(el),
+                        i, disconnect_msgs = [], msgs = [], reasons = [];
+
+                    $el.find('x[xmlns="'+Strophe.NS.MUC_USER+'"]').each(function (idx, x) {
+                        var $item = $(x).find('item');
+                        if (Strophe.getBareJidFromJid($item.attr('jid')) === converse.bare_jid && $item.attr('affiliation') === 'owner') {
+                            this.$el.find('a.configure-chatroom-button').show();
+                        }
+                        $(x).find('item reason').each(function (idx, reason) {
+                            if ($(reason).text()) {
+                                reasons.push($(reason).text());
+                            }
+                        });
+                        $(x).find('status').each(function (idx, stat) {
+                            var code = stat.getAttribute('code');
+                            var from_nick = Strophe.unescapeNode(Strophe.getResourceFromJid($el.attr('from')));
+                            if (is_self && code === "210") {
+                                msgs.push(__(this.newNicknameMessages[code], from_nick));
+                            } else if (is_self && code === "303") {
+                                msgs.push(__(this.newNicknameMessages[code], $item.attr('nick')));
+                            } else if (is_self && _.contains(_.keys(this.disconnectMessages), code)) {
+                                disconnect_msgs.push(this.disconnectMessages[code]);
+                            } else if (!is_self && _.contains(_.keys(this.actionInfoMessages), code)) {
+                                msgs.push(__(this.actionInfoMessages[code], from_nick));
+                            } else if (_.contains(_.keys(this.infoMessages), code)) {
+                                msgs.push(this.infoMessages[code]);
+                            } else if (code !== '110') {
+                                if ($(stat).text()) {
+                                    msgs.push($(stat).text()); // Sometimes the status contains human readable text and not a code.
+                                }
+                            }
+                        }.bind(this));
+                    }.bind(this));
+
+                    if (disconnect_msgs.length > 0) {
+                        for (i=0; i<disconnect_msgs.length; i++) {
+                            this.showDisconnectMessage(disconnect_msgs[i]);
+                        }
+                        for (i=0; i<reasons.length; i++) {
+                            this.showDisconnectMessage(__('The reason given is: "'+reasons[i]+'"'), true);
+                        }
+                        this.model.set('connection_status', Strophe.Status.DISCONNECTED);
+                        return;
+                    }
+                    for (i=0; i<msgs.length; i++) {
+                        this.$content.append(converse.templates.info({message: msgs[i]}));
+                    }
+                    for (i=0; i<reasons.length; i++) {
+                        this.showStatusNotification(__('The reason given is: "'+reasons[i]+'"'), true);
+                    }
+                    this.scrollDown();
+                    return el;
+                },
+
+                showErrorMessage: function ($error) {
+                    // We didn't enter the room, so we must remove it from the MUC
+                    // add-on
+                    if ($error.attr('type') === 'auth') {
+                        if ($error.find('not-authorized').length) {
+                            this.renderPasswordForm();
+                        } else if ($error.find('registration-required').length) {
+                            this.showDisconnectMessage(__('You are not on the member list of this room'));
+                        } else if ($error.find('forbidden').length) {
+                            this.showDisconnectMessage(__('You have been banned from this room'));
+                        }
+                    } else if ($error.attr('type') === 'modify') {
+                        if ($error.find('jid-malformed').length) {
+                            this.showDisconnectMessage(__('No nickname was specified'));
+                        }
+                    } else if ($error.attr('type') === 'cancel') {
+                        if ($error.find('not-allowed').length) {
+                            this.showDisconnectMessage(__('You are not allowed to create new rooms'));
+                        } else if ($error.find('not-acceptable').length) {
+                            this.showDisconnectMessage(__("Your nickname doesn't conform to this room's policies"));
+                        } else if ($error.find('conflict').length) {
+                            this.showDisconnectMessage(__("Your nickname is already taken"));
+                            // TODO: give user the option of choosing a different nickname
+                        } else if ($error.find('item-not-found').length) {
+                            this.showDisconnectMessage(__("This room does not (yet) exist"));
+                        } else if ($error.find('service-unavailable').length) {
+                            this.showDisconnectMessage(__("This room has reached it's maximum number of occupants"));
+                        }
+                    }
+                },
+
+                onChatRoomPresence: function (pres) {
+                    var $presence = $(pres), is_self;
+                    var nick = this.model.get('nick');
+                    if ($presence.attr('type') === 'error') {
+                        this.model.set('connection_status', Strophe.Status.DISCONNECTED);
+                        this.showErrorMessage($presence.find('error'));
+                    } else {
+                        is_self = ($presence.find("status[code='110']").length) ||
+                            ($presence.attr('from') === this.model.get('id')+'/'+Strophe.escapeNode(nick));
+                        if (this.model.get('connection_status') !== Strophe.Status.CONNECTED) {
+                            this.model.set('connection_status', Strophe.Status.CONNECTED);
+                        }
+                        this.showStatusMessages(pres, is_self);
+                    }
+                    this.occupantsview.updateOccupantsOnPresence(pres);
+                },
+
+                onChatRoomMessage: function (message) {
+                    var $message = $(message),
+                        archive_id = $message.find('result[xmlns="'+Strophe.NS.MAM+'"]').attr('id'),
+                        delayed = $message.find('delay').length > 0,
+                        $forwarded = $message.find('forwarded'),
+                        $delay;
+
+                    if ($forwarded.length) {
+                        $message = $forwarded.children('message');
+                        $delay = $forwarded.children('delay');
+                        delayed = $delay.length > 0;
+                    }
+                    var body = $message.children('body').text(),
+                        jid = $message.attr('from'),
+                        msgid = $message.attr('id'),
+                        resource = Strophe.getResourceFromJid(jid),
+                        sender = resource && Strophe.unescapeNode(resource) || '',
+                        subject = $message.children('subject').text();
+
+                    if (msgid && this.model.messages.findWhere({msgid: msgid})) {
+                        return true; // We already have this message stored.
+                    }
+                    if (subject) {
+                        this.$el.find('.chatroom-topic').text(subject).attr('title', 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.append(
+                            converse.templates.info({
+                                'message': __('Topic set by %1$s to: %2$s', sender, subject)
+                            }));
+                    }
+                    if (sender === '') {
+                        return true;
+                    }
+                    this.model.createMessage($message, $delay, archive_id);
+                    if (!delayed && sender !== this.model.get('nick') && (new RegExp("\\b"+this.model.get('nick')+"\\b")).test(body)) {
+                        converse.playNotification();
+                    }
+                    if (sender !== this.model.get('nick')) {
+                        // We only emit an event if it's not our own message
+                        converse.emit('message', message);
+                    }
+                    return true;
+                },
+                
+                fetchArchivedMessages: function (options) {
+                    /* Fetch archived chat messages from the XMPP server.
+                    *
+                    * Then, upon receiving them, call onChatRoomMessage
+                    * so that they are displayed inside it.
+                    */
+                    if (!converse.features.findWhere({'var': Strophe.NS.MAM})) {
+                        converse.log("Attempted to fetch archived messages but this user's server doesn't support XEP-0313");
+                        return;
+                    }
+                    this.addSpinner();
+                    converse_api.archive.query(_.extend(options, {'groupchat': true}),
+                        function (messages) {
+                            this.clearSpinner();
+                            if (messages.length) {
+                                _.map(messages, this.onChatRoomMessage.bind(this));
+                            }
+                        }.bind(this),
+                        function () {
+                            this.clearSpinner();
+                            converse.log("Error while trying to fetch archived messages", "error");
+                        }.bind(this)
+                    );
+                }
+            });
+
+            converse.RoomsPanel = Backbone.View.extend({
+                /* Backbone View 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': 'createChatRoom',
+                    'click input#show-rooms': 'showRooms',
+                    'click a.open-room': 'createChatRoom',
+                    'click a.room-info': 'showRoomInfo',
+                    'change input[name=server]': 'setDomain',
+                    'change input[name=nick]': 'setNick'
+                },
+
+                initialize: function (cfg) {
+                    this.$parent = cfg.$parent;
+                    this.model.on('change:muc_domain', this.onDomainChange, this);
+                    this.model.on('change:nick', this.onNickChange, this);
+                },
+
+                render: function () {
+                    this.$parent.append(
+                        this.$el.html(
+                            converse.templates.room_panel({
+                                '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')
+                            })
+                        ).hide());
+                    this.$tabs = this.$parent.parent().find('#controlbox-tabs');
+                    this.$tabs.append(converse.templates.chatrooms_tab({label_rooms: __('Rooms')}));
+                    return this;
+                },
+
+                onDomainChange: function (model) {
+                    var $server = this.$el.find('input.new-chatroom-server');
+                    $server.val(model.get('muc_domain'));
+                    if (converse.auto_list_rooms) {
+                        this.updateRoomsList();
+                    }
+                },
+
+                onNickChange: function (model) {
+                    var $nick = this.$el.find('input.new-chatroom-nick');
+                    $nick.val(model.get('nick'));
+                },
+
+                informNoRoomsFound: function () {
+                    var $available_chatrooms = this.$el.find('#available-chatrooms');
+                    // For translators: %1$s is a variable and will be replaced with the XMPP server name
+                    $available_chatrooms.html('<dt>'+__('No rooms on %1$s',this.model.get('muc_domain'))+'</dt>');
+                    $('input#show-rooms').show().siblings('span.spinner').remove();
+                },
+
+                onRoomsFound: function (iq) {
+                    /* Handle the IQ stanza returned from the server, containing
+                    * all its public rooms.
+                    */
+                    var name, jid, i, fragment,
+                        $available_chatrooms = this.$el.find('#available-chatrooms');
+                    this.rooms = $(iq).find('query').find('item');
+                    if (this.rooms.length) {
+                        // For translators: %1$s is a variable and will be
+                        // replaced with the XMPP server name
+                        $available_chatrooms.html('<dt>'+__('Rooms on %1$s',this.model.get('muc_domain'))+'</dt>');
+                        fragment = document.createDocumentFragment();
+                        for (i=0; i<this.rooms.length; i++) {
+                            name = Strophe.unescapeNode($(this.rooms[i]).attr('name')||$(this.rooms[i]).attr('jid'));
+                            jid = $(this.rooms[i]).attr('jid');
+                            fragment.appendChild($(
+                                converse.templates.room_item({
+                                    'name':name,
+                                    'jid':jid,
+                                    'open_title': __('Click to open this room'),
+                                    'info_title': __('Show more information on this room')
+                                    })
+                                )[0]);
+                        }
+                        $available_chatrooms.append(fragment);
+                        $('input#show-rooms').show().siblings('span.spinner').remove();
+                    } else {
+                        this.informNoRoomsFound();
+                    }
+                    return true;
+                },
+
+                updateRoomsList: function () {
+                    /* Send and 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)
+                    );
+                },
+
+                showRooms: function () {
+                    var $available_chatrooms = this.$el.find('#available-chatrooms');
+                    var $server = this.$el.find('input.new-chatroom-server');
+                    var server = $server.val();
+                    if (!server) {
+                        $server.addClass('error');
+                        return;
+                    }
+                    this.$el.find('input.new-chatroom-name').removeClass('error');
+                    $server.removeClass('error');
+                    $available_chatrooms.empty();
+                    $('input#show-rooms').hide().after('<span class="spinner"/>');
+                    this.model.save({muc_domain: server});
+                    this.updateRoomsList();
+                },
+
+                showRoomInfo: function (ev) {
+                    var target = ev.target,
+                        $dd = $(target).parent('dd'),
+                        $div = $dd.find('div.room-info');
+                    if ($div.length) {
+                        $div.remove();
+                    } else {
+                        $dd.find('span.spinner').remove();
+                        $dd.append('<span class="spinner hor_centered"/>');
+                        converse.connection.disco.info(
+                            $(target).attr('data-room-jid'),
+                            null,
+                            function (stanza) {
+                                var $stanza = $(stanza);
+                                // All MUC features found here: http://xmpp.org/registrar/disco-features.html
+                                $dd.find('span.spinner').replaceWith(
+                                    converse.templates.room_description({
+                                        'desc': $stanza.find('field[var="muc#roominfo_description"] value').text(),
+                                        'occ': $stanza.find('field[var="muc#roominfo_occupants"] value').text(),
+                                        'hidden': $stanza.find('feature[var="muc_hidden"]').length,
+                                        'membersonly': $stanza.find('feature[var="muc_membersonly"]').length,
+                                        'moderated': $stanza.find('feature[var="muc_moderated"]').length,
+                                        'nonanonymous': $stanza.find('feature[var="muc_nonanonymous"]').length,
+                                        'open': $stanza.find('feature[var="muc_open"]').length,
+                                        'passwordprotected': $stanza.find('feature[var="muc_passwordprotected"]').length,
+                                        'persistent': $stanza.find('feature[var="muc_persistent"]').length,
+                                        'publicroom': $stanza.find('feature[var="muc_public"]').length,
+                                        'semianonymous': $stanza.find('feature[var="muc_semianonymous"]').length,
+                                        'temporary': $stanza.find('feature[var="muc_temporary"]').length,
+                                        'unmoderated': $stanza.find('feature[var="muc_unmoderated"]').length,
+                                        'label_desc': __('Description:'),
+                                        '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')
+                                    }));
+                            }.bind(this));
+                    }
+                },
+
+                createChatRoom: function (ev) {
+                    ev.preventDefault();
+                    var name, $name,
+                        server, $server,
+                        jid,
+                        $nick = this.$el.find('input.new-chatroom-nick'),
+                        nick = $nick.val(),
+                        chatroom;
+
+                    if (!nick) { $nick.addClass('error'); }
+                    else { $nick.removeClass('error'); }
+
+                    if (ev.type === 'click') {
+                        name = $(ev.target).text();
+                        jid = $(ev.target).attr('data-room-jid');
+                    } else {
+                        $name = this.$el.find('input.new-chatroom-name');
+                        $server= this.$el.find('input.new-chatroom-server');
+                        server = $server.val();
+                        name = $name.val().trim();
+                        $name.val(''); // Clear the input
+                        if (name && server) {
+                            jid = Strophe.escapeNode(name.toLowerCase()) + '@' + server.toLowerCase();
+                            $name.removeClass('error');
+                            $server.removeClass('error');
+                            this.model.save({muc_domain: server});
+                        } else {
+                            if (!name) { $name.addClass('error'); }
+                            if (!server) { $server.addClass('error'); }
+                            return;
+                        }
+                    }
+                    if (!nick) { return; }
+                    chatroom = converse.chatboxviews.showChat({
+                        'id': jid,
+                        'jid': jid,
+                        'name': name || Strophe.unescapeNode(Strophe.getNodeFromJid(jid)),
+                        'nick': nick,
+                        'chatroom': true,
+                        'box_id': b64_sha1(jid)
+                    });
+                },
+
+                setDomain: function (ev) {
+                    this.model.save({muc_domain: ev.target.value});
+                },
+
+                setNick: function (ev) {
+                    this.model.save({nick: ev.target.value});
+                }
+            });
+
+            _.extend(converse_api, {
+                /* We extend the default converse.js API to add methods specific to MUC
+                * chat rooms.
+                */
+                'rooms': {
+                    'open': function (jids, nick) {
+                        if (!nick) {
+                            nick = Strophe.getNodeFromJid(converse.bare_jid);
+                        }
+                        if (typeof nick !== "string") {
+                            throw new TypeError('rooms.open: invalid nick, must be string');
+                        }
+                        var _transform = function (jid) {
+                            jid = jid.toLowerCase();
+                            var chatroom = converse.chatboxes.get(jid);
+                            converse.log('jid');
+                            if (!chatroom) {
+                                chatroom = converse.chatboxviews.showChat({
+                                    'id': jid,
+                                    'jid': jid,
+                                    'name': Strophe.unescapeNode(Strophe.getNodeFromJid(jid)),
+                                    'nick': nick,
+                                    'chatroom': true,
+                                    'box_id': b64_sha1(jid)
+                                });
+                            }
+                            return converse.wrappedChatBox(converse.chatboxes.getChatBox(jid, true));
+                        };
+                        if (typeof jids === "undefined") {
+                            throw new TypeError('rooms.open: You need to provide at least one JID');
+                        } else if (typeof jids === "string") {
+                            return _transform(jids);
+                        }
+                        return _.map(jids, _transform);
+                    },
+                    'get': function (jids) {
+                        if (typeof jids === "undefined") {
+                            throw new TypeError("rooms.get: You need to provide at least one JID");
+                        } else if (typeof jids === "string") {
+                            return converse.wrappedChatBox(converse.chatboxes.getChatBox(jids, true));
+                        }
+                        return _.map(jids, _.partial(converse.wrappedChatBox, _.bind(converse.chatboxes.getChatBox, converse.chatboxes, _, true)));
+
+                    }
+                }
+            });
+        }
+    });
+}));

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini