Parcourir la source

Move various MUC methods onto the Backbone.Model

To more cleanly separate views and models and to make MUC in headless
mode more viable.

Refs #1032
JC Brand il y a 7 ans
Parent
commit
9528d81c00

+ 11 - 5
CHANGES.md

@@ -4,8 +4,8 @@
 
 ## UI changes
 
-* The UI is now based on Bootstrap4 and Flexbox is used extensively.
-* #956 Conversation pane should show my own identity in pane header 
+- The UI is now based on Bootstrap4 and Flexbox is used extensively.
+- #956 Conversation pane should show my own identity in pane header 
 
 ## New Features
 
@@ -13,15 +13,21 @@
 
 ## Configuration changes 
 
-* Removed the `xhr_custom_status` and `xhr_custom_status_url` configuration
+- Removed the `xhr_custom_status` and `xhr_custom_status_url` configuration
   settings. If you relied on these settings, you can instead listen for the
   [statusMessageChanged](https://conversejs.org/docs/html/events.html#contactstatusmessagechanged)
   event and make the XMLHttpRequest yourself.
-* Removed  `xhr_user_search` in favor of only accepting `xhr_user_search_url` as configuration option.
-* The data returned from the `xhr_user_search_url` must now include the user's
+- Removed  `xhr_user_search` in favor of only accepting `xhr_user_search_url` as configuration option.
+- The data returned from the `xhr_user_search_url` must now include the user's
   `jid` instead of just an `id`.
 - New configuration setting [nickname](https://conversejs.org/docs/html/configurations.html#nickname)
 
+## Architectural changes
+
+- Extracted the views from `converse-muc.js` into `converse-muc-views.js` and
+  where appropriate moved methods from the views into the models/collections.
+  This makes MUC possible in headless mode.
+
 ### Bugfixes
 
 - Spoiler messages didn't include the message author's name.

+ 3 - 0
css/converse.css

@@ -7594,6 +7594,9 @@ body.reset {
                 #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li .toolbar-menu ul li.insert-emoji a:hover,
                 #conversejs .chatbox .sendXMPPMessage .chat-toolbar li .toolbar-menu ul li.insert-emoji a:hover {
                   color: #8f2831; }
+        #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley a.toggle-smiley,
+        #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley a.toggle-smiley {
+          padding: 0; }
         #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar,
         #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar {
           box-shadow: 0 -1px 1px 0 rgba(0, 0, 0, 0.4); }

+ 30 - 29
css/inverse.css

@@ -7647,28 +7647,31 @@ body {
                 #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li .toolbar-menu ul li.insert-emoji a:hover,
                 #conversejs .chatbox .sendXMPPMessage .chat-toolbar li .toolbar-menu ul li.insert-emoji a:hover {
                   color: #8f2831; }
-        #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar,
-        #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar {
-          box-shadow: 0 -1px 1px 0 rgba(0, 0, 0, 0.4); }
-          #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker,
-          #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker {
-            padding-top: 0.5em; }
-            #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker ul,
-            #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker ul {
-              display: flex;
-              flex-direction: row;
-              justify-content: space-between; }
-          #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker li,
-          #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-skintone-picker li,
-          #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker li,
-          #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-skintone-picker li {
-            padding: 0.2em;
-            font-size: 26px; }
-            #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker li:hover,
-            #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-skintone-picker li:hover,
-            #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker li:hover,
-            #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-skintone-picker li:hover {
-              background-color: #DCF9F6; }
+        #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley,
+        #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley {
+          padding: 0 0 0 0.5em; }
+          #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar,
+          #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar {
+            box-shadow: 0 -1px 1px 0 rgba(0, 0, 0, 0.4); }
+            #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker,
+            #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker {
+              padding-top: 0.5em; }
+              #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker ul,
+              #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker ul {
+                display: flex;
+                flex-direction: row;
+                justify-content: space-between; }
+            #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker li,
+            #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-skintone-picker li,
+            #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker li,
+            #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-skintone-picker li {
+              padding: 0.2em;
+              font-size: 26px; }
+              #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker li:hover,
+              #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-skintone-picker li:hover,
+              #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-category-picker li:hover,
+              #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-smiley .emoji-toolbar .emoji-skintone-picker li:hover {
+                background-color: #DCF9F6; }
         #converse-embedded-chat .chatbox .sendXMPPMessage .chat-toolbar li.toggle-otr ul,
         #conversejs .chatbox .sendXMPPMessage .chat-toolbar li.toggle-otr ul {
           z-index: 99; }
@@ -7788,13 +7791,11 @@ body {
     line-height: 26px; }
   #conversejs.fullscreen .chatbox .sendXMPPMessage ul {
     width: 100%; }
-  #conversejs.fullscreen .chatbox .sendXMPPMessage .toggle-smiley {
-    padding-left: 0.5em; }
-    #conversejs.fullscreen .chatbox .sendXMPPMessage .toggle-smiley ul.emoji-toolbar .emoji-category-picker {
-      margin-right: 5em; }
-    #conversejs.fullscreen .chatbox .sendXMPPMessage .toggle-smiley ul.emoji-toolbar .emoji-category {
-      padding-left: 10px;
-      padding-right: 10px; }
+  #conversejs.fullscreen .chatbox .sendXMPPMessage .toggle-smiley ul.emoji-toolbar .emoji-category-picker {
+    margin-right: 5em; }
+  #conversejs.fullscreen .chatbox .sendXMPPMessage .toggle-smiley ul.emoji-toolbar .emoji-category {
+    padding-left: 10px;
+    padding-right: 10px; }
 
 @media screen and (max-width: 767px) {
   #conversejs.fullscreen .chatbox {

+ 15 - 2
docs/source/events.rst

@@ -34,8 +34,8 @@ For more info on how to use (or add promises), you can read the
 Below we will now list all events and also specify whether they are available
 as promises.
 
-List of Events (and promises)
------------------------------
+List of global events (and promises)
+------------------------------------
 
 Hooking into events that Converse.js emits is a great way to extend or
 customize its functionality.
@@ -478,3 +478,16 @@ windowStateChanged
 When window state has changed. Used to determine when a user left the page and when came back.
 
 ``_converse.on('windowStateChanged', function (data) { ... });``
+
+
+List of events on the ChatRoom Backbone.Model
+---------------------------------------------
+
+configurationNeeded
+~~~~~~~~~~~~~~~~~~~
+
+Triggered when a new room has been created which first needs to be configured
+and when `auto_configure` is set to `false`.
+
+Used by the core `ChatRoomView` view in order to know when to render the
+configuration form for a new room.

+ 3 - 0
sass/_chatbox.scss

@@ -433,6 +433,9 @@
                         }
                     }
                     &.toggle-smiley {
+                        a.toggle-smiley {
+                            padding: 0;
+                        }
                         .emoji-toolbar {
                             box-shadow: 0 -1px 1px 0 rgba(0, 0, 0, 0.4);
 

+ 0 - 1
sass/inverse/_chatbox.scss

@@ -78,7 +78,6 @@
                 width: 100%;
             }
             .toggle-smiley {
-                padding-left: 0.5em;
                 ul {
                     &.emoji-toolbar {
                         .emoji-category-picker {

+ 229 - 231
spec/chatroom.js

@@ -127,7 +127,7 @@
                 // Mock 'getRoomFeatures', otherwise the room won't be
                 // displayed as it waits first for the features to be returned
                 // (when it's a new room being created).
-                spyOn(_converse.ChatRoomView.prototype, 'getRoomFeatures').and.callFake(function () {
+                spyOn(_converse.ChatRoom.prototype, 'getRoomFeatures').and.callFake(function () {
                     var deferred = new $.Deferred();
                     deferred.resolve();
                     return deferred.promise();
@@ -426,7 +426,7 @@
                  * know about them because we receive their presences before we
                  * receive our own.
                  */
-                presence = $pres({
+                var presence = $pres({
                         to: 'dummy@localhost/_converse.js-29092160',
                         from: 'coven@chat.shakespeare.lit/oldguy'
                     }).c('x', {xmlns: Strophe.NS.MUC_USER})
@@ -446,7 +446,7 @@
                  *      </x>
                  *  </presence></body>
                  */
-                var presence = $pres({
+                presence = $pres({
                         to: 'dummy@localhost/_converse.js-29092160',
                         from: 'coven@chat.shakespeare.lit/some1'
                     }).c('x', {xmlns: Strophe.NS.MUC_USER})
@@ -615,140 +615,159 @@
                     null, ['rosterGroupsFetched'], {},
                     function (done, _converse) {
 
-                test_utils.openChatRoom(_converse, "coven", 'chat.shakespeare.lit', 'some1');
-                var view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
-                var $chat_content = $(view.el).find('.chat-content');
-
-                /* <presence to="dummy@localhost/_converse.js-29092160"
-                 *           from="coven@chat.shakespeare.lit/some1">
-                 *      <x xmlns="http://jabber.org/protocol/muc#user">
-                 *          <item affiliation="owner" jid="dummy@localhost/_converse.js-29092160" role="moderator"/>
-                 *          <status code="110"/>
-                 *      </x>
-                 *  </presence></body>
-                 */
-                var presence = $pres({
-                        to: 'dummy@localhost/_converse.js-29092160',
-                        from: 'coven@chat.shakespeare.lit/some1'
-                    }).c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'owner',
-                        'jid': 'dummy@localhost/_converse.js-29092160',
-                        'role': 'moderator'
-                    }).up()
-                    .c('status', {code: '110'});
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-                var $time = $chat_content.find('time');
-                expect($time.length).toEqual(1);
-                expect($time.attr('class')).toEqual('message chat-info chat-date badge badge-info');
-                expect($time.data('isodate')).toEqual(moment().startOf('day').format());
-                expect($time.text()).toEqual(moment().startOf('day').format("dddd MMM Do YYYY"));
-                expect($chat_content.find('div.chat-info:first').html()).toBe("some1 has entered the room");
-
-                // XXX: Hack. We clear the chat contents instead of mocking the date
-                $chat_content.html('');
+                test_utils.openAndEnterChatRoom(_converse, 'coven', 'chat.shakespeare.lit', 'dummy').then(function () {
+                    var view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
+                    var chat_content = view.el.querySelector('.chat-content');
+                    var $chat_content = $(chat_content);
+                    var time = chat_content.querySelector('time');
+                    expect(time).not.toBe(null);
+                    expect(time.getAttribute('class')).toEqual('message chat-info chat-date badge badge-info');
+                    expect(time.getAttribute('data-isodate')).toEqual(moment().startOf('day').format());
+                    expect(time.textContent).toEqual(moment().startOf('day').format("dddd MMM Do YYYY"));
+                    expect(chat_content.querySelectorAll('div.chat-info').length).toBe(1);
+                    expect(chat_content.querySelector('div.chat-info').textContent).toBe(
+                        "dummy has entered the room"
+                    );
 
-                // Test a user leaving a chat room
-                presence = $pres({
-                        to: 'dummy@localhost/_converse.js-29092160',
-                        type: 'unavailable',
-                        from: 'coven@chat.shakespeare.lit/some1'
-                    })
-                    .c('status', 'Disconnected: Replaced by new connection').up()
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
+                    var baseTime = new Date();
+                    jasmine.clock().install();
+                    jasmine.clock().mockDate(baseTime);
+                    var ONE_DAY_LATER = 86400000;
+                    jasmine.clock().tick(ONE_DAY_LATER);
+
+                    /* <presence to="dummy@localhost/_converse.js-29092160"
+                     *           from="coven@chat.shakespeare.lit/some1">
+                     *      <x xmlns="http://jabber.org/protocol/muc#user">
+                     *          <item affiliation="owner" jid="dummy@localhost/_converse.js-29092160" role="moderator"/>
+                     *          <status code="110"/>
+                     *      </x>
+                     *  </presence></body>
+                     */
+                    var presence = $pres({
+                            to: 'dummy@localhost/_converse.js-29092160',
+                            from: 'coven@chat.shakespeare.lit/some1'
+                        }).c('x', {xmlns: Strophe.NS.MUC_USER})
                         .c('item', {
-                            'affiliation': 'none',
+                            'affiliation': 'owner',
                             'jid': 'some1@localhost/_converse.js-290929789',
-                            'role': 'none'
+                            'role': 'moderator'
                         });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
+                    _converse.connection._dataRecv(test_utils.createRequest(presence));
 
-                $time = $chat_content.find('time');
-                expect($time.length).toEqual(1);
-                expect($time.attr('class')).toEqual('message chat-info chat-date badge badge-info');
-                expect($time.data('isodate')).toEqual(moment().startOf('day').format());
-                expect($time.text()).toEqual(moment().startOf('day').format("dddd MMM Do YYYY"));
-                expect($chat_content.find('div.chat-info').length).toBe(1);
-                expect($chat_content.find('div.chat-info:last').html()).toBe(
-                    'some1 has left the room. '+
-                    '"Disconnected: Replaced by new connection"');
+                    time = chat_content.querySelector('time[data-isodate="'+moment().startOf('day').format()+'"]');
+                    expect(time).not.toBe(null);
+                    expect(time.getAttribute('class')).toEqual('message chat-info chat-date badge badge-info');
+                    expect(time.getAttribute('data-isodate')).toEqual(moment().startOf('day').format());
+                    expect(time.textContent).toEqual(moment().startOf('day').format("dddd MMM Do YYYY"));
+                    expect(chat_content.querySelector('div.chat-info:last-child').textContent).toBe(
+                        "some1 has entered the room"
+                    );
 
-                // XXX: Hack. We clear the chat contents instead of mocking the date
-                $chat_content.html('');
+                    jasmine.clock().tick(ONE_DAY_LATER);
 
-                var stanza = Strophe.xmlHtmlNode(
-                    '<message xmlns="jabber:client"' +
-                    '   to="dummy@localhost/_converse.js-290929789"' +
-                    '   type="groupchat"' +
-                    '   from="coven@chat.shakespeare.lit/some1">'+
-                    '       <body>hello world</body>'+
-                    '       <delay xmlns="urn:xmpp:delay" stamp="2018-01-01T09:35:39Z" from="some1@localhost"/>'+
-                    '</message>').firstChild;
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
+                    // Test a user leaving a chat room
+                    presence = $pres({
+                            to: 'dummy@localhost/_converse.js-29092160',
+                            type: 'unavailable',
+                            from: 'coven@chat.shakespeare.lit/some1'
+                        })
+                        .c('status', 'Disconnected: Replaced by new connection').up()
+                        .c('x', {xmlns: Strophe.NS.MUC_USER})
+                            .c('item', {
+                                'affiliation': 'none',
+                                'jid': 'some1@localhost/_converse.js-290929789',
+                                'role': 'none'
+                            });
+                    _converse.connection._dataRecv(test_utils.createRequest(presence));
 
-                presence = $pres({
-                        to: 'dummy@localhost/_converse.js-29092160',
-                        from: 'coven@chat.shakespeare.lit/newguy'
-                    }).c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': 'newguy@localhost/_converse.js-290929789',
-                        'role': 'participant'
-                    });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
+                    time = chat_content.querySelector('time[data-isodate="'+moment().startOf('day').format()+'"]');
+                    expect(time).not.toBe(null);
+                    expect(time.getAttribute('class')).toEqual('message chat-info chat-date badge badge-info');
+                    expect(time.getAttribute('data-isodate')).toEqual(moment().startOf('day').format());
+                    expect(time.textContent).toEqual(moment().startOf('day').format("dddd MMM Do YYYY"));
+                    expect($(chat_content).find('div.chat-info').length).toBe(4);
+                    expect($(chat_content).find('div.chat-info:last').html()).toBe(
+                        'some1 has left the room. '+
+                        '"Disconnected: Replaced by new connection"');
 
-                $time = $chat_content.find('time');
-                expect($time.length).toEqual(2);
-
-                $time = $chat_content.find('time:eq(1)');
-                expect($time.attr('class')).toEqual('message chat-info chat-date badge badge-info');
-                expect($time.data('isodate')).toEqual(moment().startOf('day').format());
-                expect($time.text()).toEqual(moment().startOf('day').format("dddd MMM Do YYYY"));
-                expect($chat_content.find('div.chat-info').length).toBe(1);
-                expect($chat_content.find('div.chat-info:first').html()).toBe("newguy has entered the room");
-
-                // XXX: Hack. We clear the chat contents instead of mocking the date
-                $chat_content.html('');
-
-                stanza = Strophe.xmlHtmlNode(
-                    '<message xmlns="jabber:client"' +
-                    '   to="dummy@localhost/_converse.js-290929789"' +
-                    '   type="groupchat"' +
-                    '   from="coven@chat.shakespeare.lit/some1">'+
-                    '       <body>hello world</body>'+
-                    '       <delay xmlns="urn:xmpp:delay" stamp="2018-01-01T09:35:39Z" from="some1@localhost"/>'+
-                    '</message>').firstChild;
-                _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                // Test a user leaving a chat room
-                presence = $pres({
-                        to: 'dummy@localhost/_converse.js-29092160',
-                        type: 'unavailable',
-                        from: 'coven@chat.shakespeare.lit/some1'
-                    })
-                    .c('status', 'Disconnected: Replaced by new connection').up()
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
+                    jasmine.clock().tick(ONE_DAY_LATER);
+
+                    var stanza = Strophe.xmlHtmlNode(
+                        '<message xmlns="jabber:client"' +
+                        '   to="dummy@localhost/_converse.js-290929789"' +
+                        '   type="groupchat"' +
+                        '   from="coven@chat.shakespeare.lit/some1">'+
+                        '       <body>hello world</body>'+
+                        '       <delay xmlns="urn:xmpp:delay" stamp="'+moment().format()+'" from="some1@localhost"/>'+
+                        '</message>').firstChild;
+                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
+
+                    presence = $pres({
+                            to: 'dummy@localhost/_converse.js-29092160',
+                            from: 'coven@chat.shakespeare.lit/newguy'
+                        }).c('x', {xmlns: Strophe.NS.MUC_USER})
                         .c('item', {
                             'affiliation': 'none',
-                            'jid': 'some1@localhost/_converse.js-290929789',
-                            'role': 'none'
+                            'jid': 'newguy@localhost/_converse.js-290929789',
+                            'role': 'participant'
                         });
-                _converse.connection._dataRecv(test_utils.createRequest(presence));
+                    _converse.connection._dataRecv(test_utils.createRequest(presence));
 
-                $time = $chat_content.find('time');
-                expect($time.length).toEqual(2);
+                    var $time = $chat_content.find('time');
+                    expect($time.length).toEqual(4);
+
+                    $time = $chat_content.find('time:eq(3)');
+                    expect($time.attr('class')).toEqual('message chat-info chat-date badge badge-info');
+                    expect($time.data('isodate')).toEqual(moment().startOf('day').format());
+                    expect($time.text()).toEqual(moment().startOf('day').format("dddd MMM Do YYYY"));
+                    expect($chat_content.find('div.chat-info').length).toBe(5);
+                    expect($chat_content.find('div.chat-info:last').html()).toBe("newguy has entered the room");
+
+                    jasmine.clock().tick(ONE_DAY_LATER);
+
+                    stanza = Strophe.xmlHtmlNode(
+                        '<message xmlns="jabber:client"' +
+                        '   to="dummy@localhost/_converse.js-290929789"' +
+                        '   type="groupchat"' +
+                        '   from="coven@chat.shakespeare.lit/some1">'+
+                        '       <body>hello world</body>'+
+                        '       <delay xmlns="urn:xmpp:delay" stamp="'+moment().format()+'" from="some1@localhost"/>'+
+                        '</message>').firstChild;
+                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
 
-                $time = $chat_content.find('time:eq(1)');
-                expect($time.attr('class')).toEqual('message chat-info chat-date badge badge-info');
-                expect($time.data('isodate')).toEqual(moment().startOf('day').format());
-                expect($time.text()).toEqual(moment().startOf('day').format("dddd MMM Do YYYY"));
-                expect($chat_content.find('div.chat-info').length).toBe(1);
-                expect($chat_content.find('div.chat-info:last').html()).toBe(
-                    'some1 has left the room. '+
-                    '"Disconnected: Replaced by new connection"');
-                done();
-                return;
+                    jasmine.clock().tick(ONE_DAY_LATER);
+
+                    // Test a user leaving a chat room
+                    presence = $pres({
+                            to: 'dummy@localhost/_converse.js-29092160',
+                            type: 'unavailable',
+                            from: 'coven@chat.shakespeare.lit/newguy'
+                        })
+                        .c('status', 'Disconnected: Replaced by new connection').up()
+                        .c('x', {xmlns: Strophe.NS.MUC_USER})
+                            .c('item', {
+                                'affiliation': 'none',
+                                'jid': 'newguy@localhost/_converse.js-290929789',
+                                'role': 'none'
+                            });
+                    _converse.connection._dataRecv(test_utils.createRequest(presence));
+
+                    $time = $chat_content.find('time');
+                    expect($time.length).toEqual(6);
+
+                    $time = $chat_content.find('time:eq(5)');
+                    expect($time.attr('class')).toEqual('message chat-info chat-date badge badge-info');
+                    expect($time.data('isodate')).toEqual(moment().startOf('day').format());
+                    expect($time.text()).toEqual(moment().startOf('day').format("dddd MMM Do YYYY"));
+                    expect($chat_content.find('div.chat-info').length).toBe(6);
+                    expect($chat_content.find('div.chat-info:last').html()).toBe(
+                        'newguy has left the room. '+
+                        '"Disconnected: Replaced by new connection"');
+
+                    jasmine.clock().uninstall();
+                    done();
+                    return;
+                });
             }));
 
             it("shows its description in the chat heading",
@@ -818,7 +837,7 @@
                             to: 'dummy@localhost',
                             type: 'groupchat'
                         }).c('body').t(message).tree();
-                    view.handleMUCMessage(msg);
+                    view.model.onMessage(msg);
                     expect($(view.el).find('.chat-message').hasClass('mentioned')).toBeTruthy();
                     done();
                 });
@@ -848,7 +867,7 @@
                             to: 'dummy@localhost',
                             type: 'groupchat'
                         }).c('body').t(message).tree();
-                    view.handleMUCMessage(msg);
+                    view.model.onMessage(msg);
                     expect(_.includes($(view.el).find('.chat-msg-author').text(), '**Dyon van de Wege')).toBeTruthy();
                     expect($(view.el).find('.chat-msg-content').text()).toBe(' is tired');
 
@@ -859,7 +878,7 @@
                         to: 'dummy@localhost',
                         type: 'groupchat'
                     }).c('body').t(message).tree();
-                    view.handleMUCMessage(msg);
+                    view.model.onMessage(msg);
                     expect(_.includes($(view.el).find('.chat-msg-author:last').text(), '**Max Mustermann')).toBeTruthy();
                     expect($(view.el).find('.chat-msg-content:last').text()).toBe(' is as well');
                     done();
@@ -1321,7 +1340,7 @@
                     .c('status').attrs({code:'210'}).nodeTree;
 
                     _converse.connection._dataRecv(test_utils.createRequest(presence));
-                    var info_text = $(view.el).find('.chat-content .chat-info').text();
+                    var info_text = $(view.el).find('.chat-content .chat-info:first').text();
                     expect(info_text).toBe('Your nickname has been automatically set to thirdwitch');
                     done();
                 });
@@ -1442,7 +1461,7 @@
                     to: 'dummy@localhost',
                     type: 'groupchat'
                 }).c('body').t(text);
-                view.onChatRoomMessage(message.nodeTree);
+                view.model.onMessage(message.nodeTree);
                 var $chat_content = $(view.el).find('.chat-content');
                 expect($chat_content.find('.chat-message').length).toBe(1);
                 expect($chat_content.find('.chat-msg-content').text()).toBe(text);
@@ -1480,7 +1499,7 @@
                         type: 'groupchat',
                         id: view.model.messages.at(0).get('msgid')
                     }).c('body').t(text);
-                    view.onChatRoomMessage(message.nodeTree);
+                    view.model.onMessage(message.nodeTree);
                     expect($chat_content.find('.chat-message').length).toBe(1);
                     expect($chat_content.find('.chat-msg-content').last().text()).toBe(text);
                     // We don't emit an event if it's our own message
@@ -1502,7 +1521,7 @@
                     * scrollbar.
                     */
                     for (var i=0; i<20; i++) {
-                        view.handleMUCMessage(
+                        view.model.onMessage(
                             $msg({
                                 from: 'lounge@localhost/someone',
                                 to: 'dummy@localhost.com',
@@ -1513,7 +1532,7 @@
                     // Give enough time for `markScrolled` to have been called
                     setTimeout(function () {
                         view.content.scrollTop = 0;
-                        view.handleMUCMessage(
+                        view.model.onMessage(
                             $msg({
                                 from: 'lounge@localhost/someone',
                                 to: 'dummy@localhost.com',
@@ -1562,7 +1581,10 @@
                     spyOn(window, 'alert');
                     var subject = '<img src="x" onerror="alert(\'XSS\');"/>';
                     var view = _converse.chatboxviews.get('jdev@conference.jabber.org');
-                    view.setChatRoomSubject('ralphm', subject);
+                    view.model.set({'subject': {
+                        'text': subject,
+                        'author': 'ralphm'
+                    }});
                     var chat_content = view.el.querySelector('.chat-content');
                     expect($(chat_content).find('.chat-event:last').text()).toBe('Topic set by ralphm');
                     expect($(chat_content).find('.chat-topic:last').text()).toBe(subject);
@@ -1615,35 +1637,14 @@
                     var view = _converse.chatboxviews.get('lounge@localhost');
                     var $chat_content = $(view.el).find('.chat-content');
 
-                    // The user has just entered the room and receives their own
-                    // presence from the server.
-                    // See example 24:
-                    // http://xmpp.org/extensions/xep-0045.html#enter-pres
-                    var presence = $pres({
-                            to:'dummy@localhost/pda',
-                            from:'lounge@localhost/oldnick',
-                            id:'DC352437-C019-40EC-B590-AF29E879AF97'
-                    }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                    .c('item').attrs({
-                        affiliation: 'member',
-                        jid: 'dummy@localhost/pda',
-                        role: 'participant'
-                    }).up()
-                    .c('status').attrs({code:'110'}).up()
-                    .c('status').attrs({code:'210'}).nodeTree;
-
-                    _converse.connection._dataRecv(test_utils.createRequest(presence));
                     var $occupants = $(view.el.querySelector('.occupant-list'));
                     expect($occupants.children().length).toBe(1);
                     expect($occupants.children().first(0).text()).toBe("oldnick");
 
-                    expect($chat_content.find('div.chat-info').length).toBe(2);
+                    expect($chat_content.find('div.chat-info').length).toBe(1);
                     expect($chat_content.find('div.chat-info:first').html()).toBe("oldnick has entered the room");
-                    expect($chat_content.find('div.chat-info:last').html()).toBe(
-                        __(_converse.muc.new_nickname_messages["210"], "oldnick")
-                    );
 
-                    presence = $pres().attrs({
+                    var presence = $pres().attrs({
                             from:'lounge@localhost/oldnick',
                             id:'DC352437-C019-40EC-B590-AF29E879AF98',
                             to:'dummy@localhost/pda',
@@ -1660,13 +1661,13 @@
                         .c('status').attrs({code:'110'}).nodeTree;
 
                     _converse.connection._dataRecv(test_utils.createRequest(presence));
-                    expect($chat_content.find('div.chat-info').length).toBe(3);
+                    expect($chat_content.find('div.chat-info').length).toBe(2);
                     expect($chat_content.find('div.chat-info').last().html()).toBe(
                         __(_converse.muc.new_nickname_messages["303"], "newnick")
                     );
 
                     $occupants = $(view.el.querySelector('.occupant-list'));
-                    expect($occupants.children().length).toBe(0);
+                    expect($occupants.children().length).toBe(1);
 
                     presence = $pres().attrs({
                             from:'lounge@localhost/newnick',
@@ -1682,12 +1683,10 @@
                         .c('status').attrs({code:'110'}).nodeTree;
 
                     _converse.connection._dataRecv(test_utils.createRequest(presence));
-                    expect($chat_content.find('div.chat-info').length).toBe(4);
-                    expect($chat_content.find('div.chat-info').get(2).textContent).toBe(
+                    expect($chat_content.find('div.chat-info').length).toBe(2);
+                    expect($chat_content.find('div.chat-info').get(1).textContent).toBe(
                         __(_converse.muc.new_nickname_messages["303"], "newnick")
                     );
-                    expect($chat_content.find('div.chat-info').last().html()).toBe(
-                        "newnick has entered the room");
                     $occupants = $(view.el.querySelector('.occupant-list'));
                     expect($occupants.children().length).toBe(1);
                     expect($occupants.children().first(0).text()).toBe("newnick");
@@ -1907,9 +1906,9 @@
                         .up()
                         .c('status').attrs({code:'110'}).up()
                         .c('status').attrs({code:'307'}).nodeTree;
+                    _converse.connection._dataRecv(test_utils.createRequest(presence));
 
                     var view = _converse.chatboxviews.get('lounge@localhost');
-                    view.onChatRoomPresence(presence);
                     expect($(view.el.querySelector('.chat-area')).is(':visible')).toBeFalsy();
                     expect($(view.el.querySelector('.occupants')).is(':visible')).toBeFalsy();
                     var $chat_body = $(view.el.querySelector('.chatroom-body'));
@@ -1992,16 +1991,12 @@
                 var view = _converse.chatboxviews.get('lounge@localhost');
                 spyOn(view, 'close').and.callThrough();
                 spyOn(_converse, 'emit');
-                spyOn(view, 'leave');
+                spyOn(view.model, 'leave');
                 view.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
                 view.el.querySelector('.close-chatbox-button').click();
                 expect(view.close).toHaveBeenCalled();
-                expect(view.leave).toHaveBeenCalled();
-                // XXX: After refactoring, the chat box only gets closed
-                // once we have confirmation from the server. To test this,
-                // we would have to mock the returned presence stanza.
-                // See the "leave" method on the ChatRoomView.
-                // expect(_converse.emit).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
+                expect(view.model.leave).toHaveBeenCalled();
+                expect(_converse.emit).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
                 done();
             }));
         });
@@ -2600,18 +2595,19 @@
 
                 test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
                 .then(function () {
+                    var view = _converse.chatboxviews.get('problematic@muc.localhost');
+                    spyOn(view, 'renderPasswordForm').and.callThrough();
+
                     var presence = $pres().attrs({
-                        from:'lounge@localhost/thirdwitch',
-                            id:'n13mt3l',
-                            to:'dummy@localhost/pda',
-                            type:'error'})
+                        from:'problematic@muc.localhost/dummy',
+                        id:'n13mt3l',
+                        to:'dummy@localhost/pda',
+                        type:'error'})
                     .c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
                     .c('error').attrs({by:'lounge@localhost', type:'auth'})
-                        .c('not-authorized').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+                        .c('not-authorized').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'});
 
-                    var view = _converse.chatboxviews.get('problematic@muc.localhost');
-                    spyOn(view, 'renderPasswordForm').and.callThrough();
-                    view.onChatRoomPresence(presence);
+                    _converse.connection._dataRecv(test_utils.createRequest(presence));
 
                     var $chat_body = $(view.el).find('.chatroom-body');
                     expect(view.renderPasswordForm).toHaveBeenCalled();
@@ -2636,16 +2632,16 @@
                 test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
                 .then(function () {
                     var presence = $pres().attrs({
-                        from:'lounge@localhost/thirdwitch',
-                            id:'n13mt3l',
-                            to:'dummy@localhost/pda',
-                            type:'error'})
+                        from:'problematic@muc.localhost/dummy',
+                        id:'n13mt3l',
+                        to:'dummy@localhost/pda',
+                        type:'error'})
                     .c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
                     .c('error').attrs({by:'lounge@localhost', type:'auth'})
                         .c('registration-required').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
                     var view = _converse.chatboxviews.get('problematic@muc.localhost');
                     spyOn(view, 'showErrorMessage').and.callThrough();
-                    view.onChatRoomPresence(presence);
+                    _converse.connection._dataRecv(test_utils.createRequest(presence));
                     expect($(view.el).find('.chatroom-body p:last').text()).toBe('You are not on the member list of this room.');
                     done();
                 });
@@ -2659,16 +2655,16 @@
                 test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
                 .then(function () {
                     var presence = $pres().attrs({
-                        from:'lounge@localhost/thirdwitch',
-                            id:'n13mt3l',
-                            to:'dummy@localhost/pda',
-                            type:'error'})
+                        from:'problematic@muc.localhost/dummy',
+                        id:'n13mt3l',
+                        to:'dummy@localhost/pda',
+                        type:'error'})
                     .c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
                     .c('error').attrs({by:'lounge@localhost', type:'auth'})
                         .c('forbidden').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
                     var view = _converse.chatboxviews.get('problematic@muc.localhost');
                     spyOn(view, 'showErrorMessage').and.callThrough();
-                    view.onChatRoomPresence(presence);
+                    _converse.connection._dataRecv(test_utils.createRequest(presence));
                     expect($(view.el).find('.chatroom-body p:last').text()).toBe('You have been banned from this room.');
                     done();
                 });
@@ -2682,16 +2678,16 @@
                 test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
                 .then(function () {
                     var presence = $pres().attrs({
-                        from:'lounge@localhost/thirdwitch',
-                            id:'n13mt3l',
-                            to:'dummy@localhost/pda',
-                            type:'error'})
+                        from:'problematic@muc.localhost/dummy',
+                        id:'n13mt3l',
+                        to:'dummy@localhost/pda',
+                        type:'error'})
                     .c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
                     .c('error').attrs({by:'lounge@localhost', type:'cancel'})
                         .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
                     var view = _converse.chatboxviews.get('problematic@muc.localhost');
                     spyOn(view, 'showErrorMessage').and.callThrough();
-                    view.onChatRoomPresence(presence);
+                    _converse.connection._dataRecv(test_utils.createRequest(presence));
                     expect($(view.el).find('.chatroom-body form.chatroom-form label:first').text()).toBe('Please choose your nickname');
 
                     var $input = $(view.el).find('.chatroom-body form.chatroom-form input:first');
@@ -2722,14 +2718,14 @@
                     _converse.muc_nickname_from_jid = true;
 
                     var attrs = {
-                        from:'lounge@localhost/dummy',
-                        id:'n13mt3l',
+                        from:'problematic@muc.localhost/dummy',
                         to:'dummy@localhost/pda',
                         type:'error'
                     };
+                    attrs.id = new Date().getTime();
                     var presence = $pres().attrs(attrs)
                         .c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
-                        .c('error').attrs({by:'lounge@localhost', type:'cancel'})
+                        .c('error').attrs({by:'problematic@muc.localhost', type:'cancel'})
                             .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
 
                     var view = _converse.chatboxviews.get('problematic@muc.localhost');
@@ -2738,24 +2734,26 @@
 
                     // Simulate repeatedly that there's already someone in the room
                     // with that nickname
-                    view.onChatRoomPresence(presence);
+                    _converse.connection._dataRecv(test_utils.createRequest(presence));
                     expect(view.join).toHaveBeenCalledWith('dummy-2');
 
-                    attrs.from = 'lounge@localhost/dummy-2';
+                    attrs.from = 'problematic@muc.localhost/dummy-2';
+                    attrs.id = new Date().getTime();
                     presence = $pres().attrs(attrs)
                         .c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
-                        .c('error').attrs({by:'lounge@localhost', type:'cancel'})
+                        .c('error').attrs({by:'problematic@muc.localhost', type:'cancel'})
                             .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
-                    view.onChatRoomPresence(presence);
+                    _converse.connection._dataRecv(test_utils.createRequest(presence));
 
                     expect(view.join).toHaveBeenCalledWith('dummy-3');
 
-                    attrs.from = 'lounge@localhost/dummy-3';
+                    attrs.from = 'problematic@muc.localhost/dummy-3';
+                    attrs.id = new Date().getTime();
                     presence = $pres().attrs(attrs)
                         .c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
-                        .c('error').attrs({by:'lounge@localhost', type:'cancel'})
+                        .c('error').attrs({by:'problematic@muc.localhost', type:'cancel'})
                             .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
-                    view.onChatRoomPresence(presence);
+                    _converse.connection._dataRecv(test_utils.createRequest(presence));
                     expect(view.join).toHaveBeenCalledWith('dummy-4');
                     done();
                 });
@@ -2769,16 +2767,16 @@
                 test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
                 .then(function () {
                     var presence = $pres().attrs({
-                        from:'lounge@localhost/thirdwitch',
-                            id:'n13mt3l',
-                            to:'dummy@localhost/pda',
-                            type:'error'})
+                        from:'problematic@muc.localhost/dummy',
+                        id:'n13mt3l',
+                        to:'dummy@localhost/pda',
+                        type:'error'})
                     .c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
                     .c('error').attrs({by:'lounge@localhost', type:'cancel'})
                         .c('not-allowed').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
                     var view = _converse.chatboxviews.get('problematic@muc.localhost');
                     spyOn(view, 'showErrorMessage').and.callThrough();
-                    view.onChatRoomPresence(presence);
+                    _converse.connection._dataRecv(test_utils.createRequest(presence));
                     expect($(view.el).find('.chatroom-body p:last').text()).toBe('You are not allowed to create new rooms.');
                     done();
                 });
@@ -2792,16 +2790,16 @@
                 test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
                 .then(function () {
                     var presence = $pres().attrs({
-                        from:'lounge@localhost/thirdwitch',
-                            id:'n13mt3l',
-                            to:'dummy@localhost/pda',
-                            type:'error'})
+                        from:'problematic@muc.localhost/dummy',
+                        id:'n13mt3l',
+                        to:'dummy@localhost/pda',
+                        type:'error'})
                     .c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
                     .c('error').attrs({by:'lounge@localhost', type:'cancel'})
                         .c('not-acceptable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
                     var view = _converse.chatboxviews.get('problematic@muc.localhost');
                     spyOn(view, 'showErrorMessage').and.callThrough();
-                    view.onChatRoomPresence(presence);
+                    _converse.connection._dataRecv(test_utils.createRequest(presence));
                     expect($(view.el).find('.chatroom-body p:last').text()).toBe("Your nickname doesn't conform to this room's policies.");
                     done();
                 });
@@ -2815,16 +2813,16 @@
                 test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
                 .then(function () {
                     var presence = $pres().attrs({
-                        from:'lounge@localhost/thirdwitch',
-                            id:'n13mt3l',
-                            to:'dummy@localhost/pda',
-                            type:'error'})
+                        from:'problematic@muc.localhost/dummy',
+                        id:'n13mt3l',
+                        to:'dummy@localhost/pda',
+                        type:'error'})
                     .c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
                     .c('error').attrs({by:'lounge@localhost', type:'cancel'})
                         .c('item-not-found').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
                     var view = _converse.chatboxviews.get('problematic@muc.localhost');
                     spyOn(view, 'showErrorMessage').and.callThrough();
-                    view.onChatRoomPresence(presence);
+                    _converse.connection._dataRecv(test_utils.createRequest(presence));
                     expect($(view.el).find('.chatroom-body p:last').text()).toBe("This room does not (yet) exist.");
                     done();
                 });
@@ -2838,7 +2836,7 @@
                 test_utils.openChatRoomViaModal(_converse, 'problematic@muc.localhost', 'dummy')
                 .then(function () {
                     var presence = $pres().attrs({
-                        from:'lounge@localhost/thirdwitch',
+                        from:'problematic@muc.localhost/dummy',
                             id:'n13mt3l',
                             to:'dummy@localhost/pda',
                             type:'error'})
@@ -2847,7 +2845,7 @@
                         .c('service-unavailable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
                     var view = _converse.chatboxviews.get('problematic@muc.localhost');
                     spyOn(view, 'showErrorMessage').and.callThrough();
-                    view.onChatRoomPresence(presence);
+                    _converse.connection._dataRecv(test_utils.createRequest(presence));
                     expect($(view.el).find('.chatroom-body p:last').text()).toBe("This room has reached its maximum number of occupants.");
                     done();
                 });
@@ -3091,7 +3089,7 @@
                 test_utils.waitUntil(function () {
                     return u.isVisible(modal.el);
                 }, 1000).then(function () {
-                    spyOn(_converse.ChatRoomView.prototype, 'getRoomFeatures').and.callFake(function () {
+                    spyOn(_converse.ChatRoom.prototype, 'getRoomFeatures').and.callFake(function () {
                         var deferred = new $.Deferred();
                         deferred.resolve();
                         return deferred.promise();
@@ -3126,7 +3124,7 @@
                 test_utils.waitUntil(function () {
                     return u.isVisible(modal.el);
                 }, 1000).then(function () {
-                    spyOn(_converse.ChatRoomView.prototype, 'getRoomFeatures').and.callFake(function () {
+                    spyOn(_converse.ChatRoom.prototype, 'getRoomFeatures').and.callFake(function () {
                         var deferred = new $.Deferred();
                         deferred.resolve();
                         return deferred.promise();
@@ -3206,7 +3204,7 @@
                             type: 'groupchat'
                         }).c('body').t(message).tree();
 
-                    view.handleMUCMessage(msg);
+                    view.model.onMessage(msg);
 
                     expect(roomspanel.el.querySelectorAll('.available-room').length).toBe(1);
                     expect(roomspanel.el.querySelectorAll('.msgs-indicator').length).toBe(1);
@@ -3218,7 +3216,7 @@
                         to: 'dummy@localhost',
                         type: 'groupchat'
                     }).c('body').t(message).tree();
-                    view.handleMUCMessage(msg);
+                    view.model.onMessage(msg);
 
                     expect(roomspanel.el.querySelectorAll('.available-room').length).toBe(1);
                     expect(roomspanel.el.querySelectorAll('.msgs-indicator').length).toBe(1);
@@ -3308,7 +3306,7 @@
                                     to: 'dummy@localhost',
                                     type: 'groupchat'
                                 }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                            view.handleMUCMessage(msg);
+                            view.model.onMessage(msg);
 
                             // Check that the notification appears inside the chatbox in the DOM
                             var events = view.el.querySelectorAll('.chat-event');
@@ -3334,7 +3332,7 @@
                                     to: 'dummy@localhost',
                                     type: 'groupchat'
                                 }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                            view.handleMUCMessage(msg);
+                            view.model.onMessage(msg);
 
                             events = view.el.querySelectorAll('.chat-event');
                             expect(events.length).toBe(4);
@@ -3356,7 +3354,7 @@
                                     to: 'dummy@localhost',
                                     type: 'groupchat'
                                 }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                            view.handleMUCMessage(msg);
+                            view.model.onMessage(msg);
                             events = view.el.querySelectorAll('.chat-event');
                             expect(events.length).toBe(4);
                             expect(events[0].textContent).toEqual('some1 has entered the room');
@@ -3378,7 +3376,7 @@
                                 to: 'dummy@localhost',
                                 type: 'groupchat'
                             }).c('body').t('hello world').tree();
-                            view.handleMUCMessage(msg);
+                            view.model.onMessage(msg);
 
                             var messages = view.el.querySelectorAll('.message');
                             expect(messages.length).toBe(8);
@@ -3483,7 +3481,7 @@
                                 to: 'dummy@localhost',
                                 type: 'groupchat'
                             }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        view.handleMUCMessage(msg);
+                        view.model.onMessage(msg);
 
                         // Check that the notification appears inside the chatbox in the DOM
                         var events = view.el.querySelectorAll('.chat-event');
@@ -3503,7 +3501,7 @@
                                 to: 'dummy@localhost',
                                 type: 'groupchat'
                             }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        view.handleMUCMessage(msg);
+                        view.model.onMessage(msg);
 
                         events = view.el.querySelectorAll('.chat-event');
                         expect(events.length).toBe(3);
@@ -3522,7 +3520,7 @@
                                 to: 'dummy@localhost',
                                 type: 'groupchat'
                             }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        view.handleMUCMessage(msg);
+                        view.model.onMessage(msg);
                         events = view.el.querySelectorAll('.chat-event');
                         expect(events.length).toBe(3);
                         expect(events[0].textContent).toEqual('some1 has entered the room');
@@ -3541,7 +3539,7 @@
                                 to: 'dummy@localhost',
                                 type: 'groupchat'
                             }).c('body').c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        view.handleMUCMessage(msg);
+                        view.model.onMessage(msg);
                         events = view.el.querySelectorAll('.chat-event');
                         expect(events.length).toBe(3);
                         expect(events[0].textContent).toEqual('some1 has entered the room');

+ 1 - 1
spec/mam.js

@@ -39,7 +39,7 @@
                                 </forwarded>
                             </result>
                         </message>`).firstElementChild;
-                    chatroomview.onChatRoomMessage(stanza);
+                    chatroomview.model.onMessage(stanza);
                     expect(chatroomview.content.querySelectorAll('.chat-message').length).toBe(1);
                     done();
                 });

+ 1 - 1
spec/minchats.js

@@ -158,7 +158,7 @@
                         to: 'dummy@localhost',
                         type: 'groupchat'
                     }).c('body').t(message).tree();
-                view.handleMUCMessage(msg);
+                view.model.onMessage(msg);
 
                 expect($(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count')).is(':visible')).toBeTruthy();
                 expect($(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count')).text()).toBe('1');

+ 3 - 3
spec/notification.js

@@ -173,7 +173,7 @@
                             to: 'dummy@localhost',
                             type: 'groupchat'
                         }).c('body').t(text);
-                        view.onChatRoomMessage(message.nodeTree);
+                        view.model.onMessage(message.nodeTree);
                         expect(_converse.playSoundNotification).toHaveBeenCalled();
 
                         text = "This message won't play a sound";
@@ -183,7 +183,7 @@
                             to: 'dummy@localhost',
                             type: 'groupchat'
                         }).c('body').t(text);
-                        view.onChatRoomMessage(message.nodeTree);
+                        view.model.onMessage(message.nodeTree);
                         expect(_converse.playSoundNotification, 1);
                         _converse.play_sounds = false;
 
@@ -194,7 +194,7 @@
                             to: 'dummy@localhost',
                             type: 'groupchat'
                         }).c('body').t(text);
-                        view.onChatRoomMessage(message.nodeTree);
+                        view.model.onMessage(message.nodeTree);
                         expect(_converse.playSoundNotification, 1);
                         _converse.play_sounds = false;
                         done();

+ 3 - 3
spec/roomslist.js

@@ -97,7 +97,7 @@
                     view.model.set({'minimized': true});
                     var contact_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@localhost';
                     var nick = mock.chatroom_names[0];
-                    view.handleMUCMessage(
+                    view.model.onMessage(
                         $msg({
                             from: room_jid+'/'+nick,
                             id: (new Date()).getTime(),
@@ -112,7 +112,7 @@
                     expect(_.includes(room_el.classList, 'unread-msgs'));
 
                     // If the user is mentioned, the counter also gets updated
-                    view.handleMUCMessage(
+                    view.model.onMessage(
                         $msg({
                             from: room_jid+'/'+nick,
                             id: (new Date()).getTime(),
@@ -123,7 +123,7 @@
                     var indicator_el = _converse.rooms_list_view.el.querySelector(".msgs-indicator");
                     expect(indicator_el.textContent).toBe('1');
 
-                    view.handleMUCMessage(
+                    view.model.onMessage(
                         $msg({
                             from: room_jid+'/'+nick,
                             id: (new Date()).getTime(),

+ 1 - 0
src/converse-chatview.js

@@ -416,6 +416,7 @@
                             'isodate': isodate,
                             'data': data
                         }));
+                    this.insertDayIndicator(this.content.lastElementChild);
                     this.scrollDown();
                     return isodate;
                 },

+ 22 - 20
src/converse-mam.js

@@ -269,13 +269,16 @@
                 },
             },
 
-            ChatRoomView: {
+            ChatRoom: {
 
-                initialize () {
-                    const { _converse } = this.__super__;
-                    this.__super__.initialize.apply(this, arguments);
-                    this.model.on('change:mam_enabled', this.fetchArchivedMessagesIfNecessary, this);
-                    this.model.on('change:connection_status', this.fetchArchivedMessagesIfNecessary, this);
+                onMessage (stanza) {
+                    /* MAM (message archive management XEP-0313) messages are
+                     * ignored, since they're handled separately.
+                     */
+                    if (sizzle(`[xmlns="${Strophe.NS.MAM}"]`, stanza).length > 0) {
+                        return true;
+                    }
+                    return this.__super__.onMessage.apply(this, arguments);
                 },
 
                 isDuplicate (message, original_stanza) {
@@ -285,8 +288,18 @@
                     }
                     const archive_id = getMessageArchiveID(original_stanza);
                     if (archive_id) {
-                        return this.model.messages.filter({'archive_id': archive_id}).length > 0;
+                        return this.messages.filter({'archive_id': archive_id}).length > 0;
                     }
+                }
+            },
+
+            ChatRoomView: {
+
+                initialize () {
+                    const { _converse } = this.__super__;
+                    this.__super__.initialize.apply(this, arguments);
+                    this.model.on('change:mam_enabled', this.fetchArchivedMessagesIfNecessary, this);
+                    this.model.on('change:connection_status', this.fetchArchivedMessagesIfNecessary, this);
                 },
 
                 renderChatArea () {
@@ -297,16 +310,6 @@
                     return result;
                 },
 
-                handleMUCMessage (stanza) {
-                    /* MAM (message archive management XEP-0313) messages are
-                     * ignored, since they're handled separately.
-                     */
-                    if (sizzle(`[xmlns="${Strophe.NS.MAM}"]`, stanza).length > 0) {
-                        return true;
-                    }
-                    return this.__super__.handleMUCMessage.apply(this, arguments);
-                },
-
                 fetchArchivedMessagesIfNecessary () {
                     if (this.model.get('connection_status') !== converse.ROOMSTATUS.ENTERED ||
                         !this.model.get('mam_enabled') ||
@@ -321,7 +324,7 @@
                 fetchArchivedMessages (options) {
                     /* Fetch archived chat messages for this Chat Room
                      *
-                     * Then, upon receiving them, call onChatRoomMessage
+                     * Then, upon receiving them, call onMessage
                      * so that they are displayed inside it.
                      */
                     const that = this;
@@ -337,7 +340,7 @@
                         function (messages) {
                             that.clearSpinner();
                             if (messages.length) {
-                                _.each(messages, that.onChatRoomMessage.bind(that));
+                                _.each(messages, that.model.onMessage.bind(that));
                             }
                         },
                         function () {
@@ -363,7 +366,6 @@
                 message_archiving_timeout: 8000, // Time (in milliseconds) to wait before aborting MAM request
             });
 
-
             _converse.onMAMError = function (iq) {
                 if (iq.querySelectorAll('feature-not-implemented').length) {
                     _converse.log(

+ 168 - 510
src/converse-muc-views.js

@@ -151,6 +151,99 @@
 
             _converse.api.promises.add(['roomsPanelRendered']);
 
+            // Configuration values for this plugin
+            // ====================================
+            // Refer to docs/source/configuration.rst for explanations of these
+            // configuration settings.
+            _converse.api.settings.update({
+                auto_list_rooms: false,
+                hide_muc_server: false, // TODO: no longer implemented...
+                muc_disable_moderator_commands: false,
+                visible_toolbar_buttons: {
+                    'toggle_occupants': true
+                }
+            });
+
+
+            function ___ (str) {
+                /* This is part of a hack to get gettext to scan strings to be
+                * translated. Strings we cannot send to the function above because
+                * they require variable interpolation and we don't yet have the
+                * variables at scan time.
+                *
+                * See actionInfoMessages further below.
+                */
+                return str;
+            }
+
+            /* 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
+             */
+            _converse.muc = {
+                info_messages: {
+                    100: __('This room is not anonymous'),
+                    102: __('This room now shows unavailable members'),
+                    103: __('This room does not show unavailable members'),
+                    104: __('The room configuration has changed'),
+                    170: __('Room logging is now enabled'),
+                    171: __('Room logging is now disabled'),
+                    172: __('This room is now no longer anonymous'),
+                    173: __('This room is now semi-anonymous'),
+                    174: __('This room is now fully-anonymous'),
+                    201: __('A new room has been created')
+                },
+
+                disconnect_messages: {
+                    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")
+                },
+
+                action_info_messages: {
+                    /* 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: ___("%1$s has been banned"),
+                    303: ___("%1$s's nickname has changed"),
+                    307: ___("%1$s has been kicked out"),
+                    321: ___("%1$s has been removed because of an affiliation change"),
+                    322: ___("%1$s has been removed for not being a member")
+                },
+
+                new_nickname_messages: {
+                    210: ___('Your nickname has been automatically set to %1$s'),
+                    303: ___('Your nickname has been changed to %1$s')
+                }
+            };
+
 
             function insertRoomInfo (el, stanza) {
                 /* Insert room info (based on returned #disco IQ stanza)
@@ -422,13 +515,18 @@
                     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:connection_status', this.afterConnected, this);
                     this.model.on('change:description', this.renderHeading, this);
                     this.model.on('change:name', this.renderHeading, this);
+                    this.model.on('change:subject', this.setChatRoomSubject, this);
+                    this.model.on('configurationNeeded', this.getAndRenderConfigurationForm, this);
+                    this.model.on('destroy', this.hide, this);
+                    this.model.on('show', this.show, this);
+
+                    this.model.occupants.on('add', this.showJoinNotification, this);
+                    this.model.occupants.on('remove', this.showLeaveNotification, this);
 
                     this.createEmojiPicker();
                     this.createOccupantsView();
@@ -441,7 +539,7 @@
                             this.fetchMessages();
                             _converse.emit('chatRoomOpened', this);
                         }
-                        this.getRoomFeatures().then(handler, handler);
+                        this.model.getRoomFeatures().then(handler, handler);
                     } else {
                         this.fetchMessages();
                         _converse.emit('chatRoomOpened', this);
@@ -487,9 +585,8 @@
                 createOccupantsView () {
                     /* Create the ChatRoomOccupantsView Backbone.NativeView
                      */
-                    const model = new _converse.ChatRoomOccupants();
-                    model.chatroomview = this;
-                    this.occupantsview = new _converse.ChatRoomOccupantsView({'model': model});
+                    this.model.occupants.chatroomview = this;
+                    this.occupantsview = new _converse.ChatRoomOccupantsView({'model': this.model.occupants});
                     this.occupantsview.model.on('change:role', this.informOfOccupantsRoleChange, this);
                     return this;
                 },
@@ -550,6 +647,7 @@
 
                 afterConnected () {
                     if (this.model.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
+                        this.hideSpinner();
                         this.setChatState(_converse.ACTIVE);
                         this.scrollDown();
                         this.focus();
@@ -583,7 +681,12 @@
                     /* Close this chat box, which implies leaving the room as
                      * well.
                      */
-                    this.leave();
+                    this.hide();
+                    if (Backbone.history.getFragment() === "converse/room?jid="+this.model.get('jid')) {
+                        _converse.router.navigate('');
+                    }
+                    this.model.leave();
+                    _converse.ChatBoxView.prototype.close.apply(this, arguments);
                 },
 
                 setOccupantsVisibility () {
@@ -802,7 +905,7 @@
                         case 'nick':
                             _converse.connection.send($pres({
                                 from: _converse.connection.jid,
-                                to: this.getRoomJIDAndNick(match[2]),
+                                to: this.model.getRoomJIDAndNick(match[2]),
                                 id: _converse.connection.getUniqueId()
                             }).tree());
                             break;
@@ -848,76 +951,39 @@
                     }
                 },
 
-                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}
-                    );
+                    // XXX: Ideally this can be refactored out so that we don't
+                    // need to do stanza processing inside the views in this
+                    // module. See the comment in "onPresence" for more info.
+                    this.model.addHandler('presence', 'ChatRoomView.onPresence', this.onPresence.bind(this));
+                    // XXX instead of having a method showStatusMessages, we could instead
+                    // create message models in converse-muc.js and then give them views in this module.
+                    this.model.addHandler('message', 'ChatRoomView.showStatusMessages', this.showStatusMessages.bind(this));
                 },
 
-                removeHandlers () {
-                    /* Remove the presence and message handlers that were
-                     * registered for this chat room.
+                onPresence (pres) {
+                    /* Handles all MUC presence stanzas.
+                     *
+                     * Parameters:
+                     *  (XMLElement) pres: The stanza
                      */
-                    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;
+                    // XXX: Current thinking is that excessive stanza
+                    // processing inside a view is a "code smell".
+                    // Instead stanza processing should happen inside the
+                    // models/collections.
+                    if (pres.getAttribute('type') === 'error') {
+                        this.showErrorMessageFromPresence(pres);
+                    } else {
+                        // Instead of doing it this way, we could perhaps rather
+                        // create StatusMessage objects inside the messages
+                        // Collection and then simply render those. Then stanza
+                        // processing is done on the model and rendering in the
+                        // view(s).
+                        this.showStatusMessages(pres);
                     }
-                    return this;
                 },
 
                 join (nick, password) {
@@ -928,66 +994,14 @@
                      *  (String) password: Optional password, if required by
                      *      the room.
                      */
-                    nick = nick ? nick : this.model.get('nick');
-                    if (!nick) {
+                    if (!nick && !this.model.get('nick')) {
                         this.checkForReservedNick();
                         return this;
                     }
-                    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);
+                    this.model.join(nick, password);
                     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.
@@ -1037,78 +1051,15 @@
 
                     form_el.addEventListener('submit', (ev) => {
                             ev.preventDefault();
-                            this.saveConfiguration(ev.target).then(
-                                this.getRoomFeatures.bind(this)
+                            this.model.saveConfiguration(ev.target).then(
+                                this.model.getRoomFeatures.bind(this.model)
                             );
+                            this.closeForm();
                         },
                         false
                     );
                 },
 
-                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.model.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.model.sendConfiguration(configArray, resolve, reject);
-                                }
-                            });
-                        });
-                    });
-                },
-
                 closeForm () {
                     /* Remove the configuration form without submitting and
                      * return to the chat view.
@@ -1117,47 +1068,6 @@
                     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
-                        );
-                    });
-                },
-
-
-                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.model.parseRoomFeatures.bind(this.model), 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
@@ -1174,7 +1084,7 @@
                      *      the settings.
                      */
                     this.showSpinner();
-                    this.fetchRoomConfiguration()
+                    this.model.fetchRoomConfiguration()
                         .then(this.renderConfigurationForm.bind(this))
                         .catch(_.partial(_converse.log, _, Strophe.LogLevel.ERROR));
                 },
@@ -1207,7 +1117,6 @@
                         this.onNickNameFound.bind(this),
                         this.onNickNameNotFound.bind(this)
                     )
-                    return this;
                 },
 
                 onNickNameFound (iq) {
@@ -1410,7 +1319,7 @@
                     return notification;
                 },
 
-                displayNotificationsforUser (notification) {
+                showNotificationsforUser (notification) {
                     /* Given the notification object generated by
                      * parseXUserElement, display any relevant messages and
                      * information to the user.
@@ -1444,13 +1353,16 @@
                     }
                 },
 
-                displayJoinNotification (stanza) {
-                    const nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
-                    const stat = stanza.querySelector('status');
+                showJoinNotification (occupant) {
+                    if (this.model.get('connection_status') !==  converse.ROOMSTATUS.ENTERED) {
+                        return;
+                    }
+                    const nick = occupant.get('nick');
+                    const stat = occupant.get('status');
                     const last_el = this.content.lastElementChild;
 
                     if (_.includes(_.get(last_el, 'classList', []), 'chat-info') &&
-                            _.get(last_el, 'dataset', {}).leave === `"${nick}"`) {
+                        _.get(last_el, 'dataset', {}).leave === `"${nick}"`) {
                         last_el.outerHTML =
                             tpl_info({
                                 'data': `data-leavejoin="${nick}"`,
@@ -1460,10 +1372,10 @@
                             });
                     } else {
                         let  message;
-                        if (_.get(stat, 'textContent')) {
-                            message = __('%1$s has entered the room. "%2$s"', nick, stat.textContent);
-                        } else {
+                        if (_.isNil(stat)) {
                             message = __('%1$s has entered the room', nick);
+                        } else {
+                            message = __('%1$s has entered the room. "%2$s"', nick, stat);
                         }
                         const data = {
                             'data': `data-join="${nick}"`,
@@ -1484,18 +1396,18 @@
                     this.scrollDown();
                 },
 
-                displayLeaveNotification (stanza) {
-                    const nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
-                    const stat = stanza.querySelector('status');
+                showLeaveNotification (occupant) {
+                    const nick = occupant.get('nick');
+                    const stat = occupant.get('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 {
+                        if (_.isNil(stat)) {
                             message = __('%1$s has entered and left the room', nick);
+                        } else {
+                            message = __('%1$s has entered and left the room. "%2$s"', nick, stat);
                         }
                         last_el.outerHTML =
                             tpl_info({
@@ -1506,10 +1418,10 @@
                             });
                     } else {
                         let message;
-                        if (_.get(stat, 'textContent')) {
-                            message = __('%1$s has left the room. "%2$s"', nick, stat.textContent);
-                        } else {
+                        if (_.isNil(stat)) {
                             message = __('%1$s has left the room', nick);
+                        } else {
+                            message = __('%1$s has left the room. "%2$s"', nick, stat);
                         }
                         const data = {
                             'message': message,
@@ -1530,20 +1442,6 @@
                     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
@@ -1556,16 +1454,7 @@
                     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;
+                    _.each(notifications, this.showNotificationsforUser.bind(this));
                 },
 
                 showErrorMessageFromPresence (presence) {
@@ -1637,87 +1526,18 @@
                     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.model.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.showErrorMessageFromPresence(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) {
+                setChatRoomSubject () {
                     // 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!
+                    const subject = this.model.get('subject');
                     this.content.insertAdjacentHTML(
                         'beforeend',
                         tpl_info({
                             'data': '',
                             'isodate': moment().format(),
                             'extra_classes': 'chat-event',
-                            'message': __('Topic set by %1$s', sender)
+                            'message': __('Topic set by %1$s', subject.author)
                         }));
                     this.content.insertAdjacentHTML(
                         'beforeend',
@@ -1725,91 +1545,9 @@
                             'data': '',
                             'isodate': moment().format(),
                             'extra_classes': 'chat-topic',
-                            'message': subject
+                            'message': subject.text
                         }));
                     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;
                 }
             });
 
@@ -2030,86 +1768,6 @@
                         `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(

+ 504 - 117
src/converse-muc.js

@@ -39,6 +39,8 @@
     Strophe.addNamespace('MUC_ROOMCONF', Strophe.NS.MUC + "#roomconfig");
     Strophe.addNamespace('MUC_USER', Strophe.NS.MUC + "#user");
 
+    converse.MUC_NICK_CHANGED_CODE = "303";
+
     converse.CHATROOMS_TYPE = 'chatroom';
 
     converse.ROOM_FEATURES = [
@@ -107,90 +109,6 @@
             const { _converse } = this,
                   { __ } = _converse;
 
-            function ___ (str) {
-                /* This is part of a hack to get gettext to scan strings to be
-                * translated. Strings we cannot send to the function above because
-                * they require variable interpolation and we don't yet have the
-                * variables at scan time.
-                *
-                * See actionInfoMessages further below.
-                */
-                return str;
-            }
-
-            // XXX: Inside plugins, all calls to the translation machinery
-            // (e.g. u.__) should only be done in the initialize function.
-            // If called before, we won't know what language the user wants,
-            // and it'll fall back to English.
-
-            /* 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
-             */
-            _converse.muc = {
-                info_messages: {
-                    100: __('This room is not anonymous'),
-                    102: __('This room now shows unavailable members'),
-                    103: __('This room does not show unavailable members'),
-                    104: __('The room configuration has changed'),
-                    170: __('Room logging is now enabled'),
-                    171: __('Room logging is now disabled'),
-                    172: __('This room is now no longer anonymous'),
-                    173: __('This room is now semi-anonymous'),
-                    174: __('This room is now fully-anonymous'),
-                    201: __('A new room has been created')
-                },
-
-                disconnect_messages: {
-                    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")
-                },
-
-                action_info_messages: {
-                    /* 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: ___("%1$s has been banned"),
-                    303: ___("%1$s's nickname has changed"),
-                    307: ___("%1$s has been kicked out"),
-                    321: ___("%1$s has been removed because of an affiliation change"),
-                    322: ___("%1$s has been removed for not being a member")
-                },
-
-                new_nickname_messages: {
-                    210: ___('Your nickname has been automatically set to %1$s'),
-                    303: ___('Your nickname has been changed to %1$s')
-                }
-            };
-
             // Configuration values for this plugin
             // ====================================
             // Refer to docs/source/configuration.rst for explanations of these
@@ -200,17 +118,10 @@
                 allow_muc_invitations: true,
                 auto_join_on_invite: false,
                 auto_join_rooms: [],
-                auto_list_rooms: false,
-                hide_muc_server: false,
-                muc_disable_moderator_commands: false,
                 muc_domain: undefined,
                 muc_history_max_stanzas: undefined,
                 muc_instant_rooms: true,
-                muc_nickname_from_jid: false,
-                muc_show_join_leave: true,
-                visible_toolbar_buttons: {
-                    'toggle_occupants': true
-                },
+                muc_nickname_from_jid: false
             });
             _converse.api.promises.add(['roomsAutoJoined']);
 
@@ -275,6 +186,156 @@
                     );
                 },
 
+                initialize() {
+                    this.constructor.__super__.initialize.apply(this, arguments);
+                    this.occupants = new _converse.ChatRoomOccupants();
+                    this.registerHandlers();
+                },
+
+                registerHandlers () {
+                    /* Register presence and message handlers for this chat
+                     * room
+                     */
+                    const room_jid = this.get('jid');
+                    this.removeHandlers();
+                    this.presence_handler = _converse.connection.addHandler((stanza) => {
+                            _.each(_.values(this.handlers.presence), (callback) => callback(stanza));
+                            this.onPresence(stanza);
+                            return true;
+                        },
+                        Strophe.NS.MUC, 'presence', null, null, room_jid,
+                        {'ignoreNamespaceFragment': true, 'matchBareFromJid': true}
+                    );
+                    this.message_handler = _converse.connection.addHandler((stanza) => {
+                            _.each(_.values(this.handlers.message), (callback) => callback(stanza));
+                            this.onMessage(stanza);
+                            return true;
+                        }, 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;
+                },
+
+                addHandler (type, name, callback) {
+                    /* Allows 'presence' and 'message' handlers to be
+                     * registered. These will be executed once presence or
+                     * message stanzas are received, and *before* this model's
+                     * own handlers are executed.
+                     */
+                    if (_.isNil(this.handlers)) {
+                        this.handlers = {};
+                    }
+                    if (_.isNil(this.handlers[type])) {
+                        this.handlers[type] = {};
+                    }
+                    this.handlers[type][name] = callback;
+                },
+
+                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.get('nick');
+                    if (!nick) {
+                        throw new TypeError('join: You need to provide a valid nickname');
+                    }
+                    if (this.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.save('connection_status', converse.ROOMSTATUS.CONNECTING);
+                    _converse.connection.send(stanza);
+                    return this;
+                },
+
+                leave (exit_msg) {
+                    /* Leave the chat room.
+                     *
+                     * Parameters:
+                     *  (String) exit_msg: Optional message to indicate your
+                     *      reason for leaving.
+                     */
+                    this.occupants.reset();
+                    this.occupants.browserStorage._clear();
+                    if (_converse.connection.connected) {
+                        this.sendUnavailablePresence(exit_msg);
+                    }
+                    u.safeSave(this, {'connection_status': converse.ROOMSTATUS.DISCONNECTED});
+                    this.removeHandlers();
+                },
+
+                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);
+                },
+
+                getRoomFeatures () {
+                    /* Fetch the room disco info, parse it and then save it.
+                     */
+                    return new Promise((resolve, reject) => {
+                        _converse.connection.disco.info(
+                            this.get('jid'),
+                            null,
+                            _.flow(this.parseRoomFeatures.bind(this), resolve),
+                            () => { reject(new Error("Could not parse the room features")) },
+                            5000
+                        );
+                    });
+                },
+
+                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.save({'nick': nick});
+                    } else {
+                        nick = this.get('nick');
+                    }
+                    const room = this.get('jid');
+                    const jid = Strophe.getBareJidFromJid(room);
+                    return jid + (nick !== null ? `/${nick}` : "");
+                },
+
                 directInvite (recipient, reason) {
                     /* Send a direct invitation as per XEP-0249
                      *
@@ -314,30 +375,6 @@
                     });
                 },
 
-
-                sendConfiguration (config, callback, errback) {
-                    /* Send an IQ stanza with the room configuration.
-                     *
-                     * Parameters:
-                     *  (Array) config: The room configuration
-                     *  (Function) callback: 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) errback: 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.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(); });
-                    callback = _.isUndefined(callback) ? _.noop : _.partial(callback, iq.nodeTree);
-                    errback = _.isUndefined(errback) ? _.noop : _.partial(errback, iq.nodeTree);
-                    return _converse.connection.sendIQ(iq, callback, errback);
-                },
-
                 parseRoomFeatures (iq) {
                     /* Parses an IQ stanza containing the room's features.
                      *
@@ -432,6 +469,106 @@
                     return Promise.all(promises);
                 },
 
+                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.
+                     *      If no form is provided, the default configuration
+                     *      values will be used.
+                     */
+                    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);
+                    });
+                },
+
+                autoConfigureChatRoom () {
+                    /* Automatically configure room based on this model's
+                     * 'roomconfig' data.
+                     *
+                     * Returns a promise which resolves once a response IQ has
+                     * been received.
+                     */
+                    return new Promise((resolve, reject) => {
+                        this.fetchRoomConfiguration().then((stanza) => {
+                            const configArray = [],
+                                fields = stanza.querySelectorAll('field'),
+                                config = this.get('roomconfig');
+                            let count = fields.length;
+
+                            _.each(fields, (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) {
+                                    this.sendConfiguration(configArray, resolve, reject);
+                                }
+                            });
+                        });
+                    });
+                },
+
+                fetchRoomConfiguration () {
+                    /* Send an IQ stanza to fetch the room configuration data.
+                     * Returns a promise which resolves once the response IQ
+                     * has been received.
+                     */
+                    return new Promise((resolve, reject) => {
+                        _converse.connection.sendIQ(
+                            $iq({
+                                'to': this.get('jid'),
+                                'type': "get"
+                            }).c("query", {xmlns: Strophe.NS.MUC_OWNER}),
+                            resolve,
+                            reject
+                        );
+                    });
+                },
+
+                sendConfiguration (config, callback, errback) {
+                    /* Send an IQ stanza with the room configuration.
+                     *
+                     * Parameters:
+                     *  (Array) config: The room configuration
+                     *  (Function) callback: 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) errback: 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.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(); });
+                    callback = _.isUndefined(callback) ? _.noop : _.partial(callback, iq.nodeTree);
+                    errback = _.isUndefined(errback) ? _.noop : _.partial(errback, iq.nodeTree);
+                    return _converse.connection.sendIQ(iq, callback, errback);
+                },
+
                 saveAffiliationAndRole (pres) {
                     /* Parse the presence stanza for the current user's
                      * affiliation.
@@ -555,6 +692,256 @@
                     return this;
                 },
 
+                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.occupants.where({'jid': jid}).pop();
+                    } else {
+                        return this.occupants.where({'nick': data.nick}).pop();
+                    }
+                },
+
+                updateOccupantsOnPresence (pres) {
+                    /* Given a presence stanza, update the occupant model
+                     * 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) {
+                            // Even before destroying, we set the new data, so
+                            // that we can for example show the
+                            // disconnection message.
+                            occupant.set(data);
+                        }
+                        if (!_.includes(data.states, converse.MUC_NICK_CHANGED_CODE)) {
+                            // We only destroy the occupant if this is not a
+                            // nickname change operation.
+                            if (occupant) {
+                                occupant.destroy();
+                            }
+                            return;
+                        }
+                    }
+                    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.occupants.create(attributes);
+                    }
+                },
+
+                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;
+                },
+
+                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.messages.where({
+                                'sender': 'me',
+                                'message': this.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.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);
+                },
+
+                fetchFeaturesIfConfigurationChanged (stanza) {
+                    const configuration_changed = stanza.querySelector("status[code='104']"),
+                          logging_enabled = stanza.querySelector("status[code='170']"),
+                          logging_disabled = stanza.querySelector("status[code='171']"),
+                          room_no_longer_anon = stanza.querySelector("status[code='172']"),
+                          room_now_semi_anon = stanza.querySelector("status[code='173']"),
+                          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();
+                    }
+                },
+
+                onMessage (stanza) {
+                    /* Handler for all MUC messages sent to this chat room.
+                     *
+                     * Parameters:
+                     *  (XMLElement) stanza: The message stanza.
+                     */
+                    this.fetchFeaturesIfConfigurationChanged(stanza);
+
+                    const original_stanza = stanza,
+                          forwarded = stanza.querySelector('forwarded');
+                    let delay;
+                    if (!_.isNull(forwarded)) {
+                        stanza = forwarded.querySelector('message');
+                        delay = forwarded.querySelector('delay');
+                    }
+                    const jid = stanza.getAttribute('from'),
+                        resource = Strophe.getResourceFromJid(jid),
+                        sender = resource && Strophe.unescapeNode(resource) || '',
+                        subject = _.propertyOf(stanza.querySelector('subject'))('textContent');
+
+                    if (this.isDuplicate(stanza, original_stanza)) {
+                        return;
+                    }
+                    if (subject) {
+                        u.safeSave(this, {'subject': {'author': sender, 'text': subject}});
+                    }
+                    if (sender === '') {
+                        return;
+                    }
+                    this.incrementUnreadMsgCounter(original_stanza);
+                    this.createMessage(stanza, delay, original_stanza);
+                    if (sender !== this.get('nick')) {
+                        // We only emit an event if it's not our own message
+                        _converse.emit('message', {'stanza': original_stanza, 'chatbox': this});
+                    }
+                },
+
+                onPresence (pres) {
+                    /* Handles all MUC presence stanzas.
+                     *
+                     * Parameters:
+                     *  (XMLElement) pres: The stanza
+                     */
+                    if (pres.getAttribute('type') === 'error') {
+                        this.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
+                        return;
+                    }
+                    const is_self = pres.querySelector("status[code='110']");
+                    if (is_self && pres.getAttribute('type') !== 'unavailable') {
+                        this.onOwnPresence(pres);
+                    }
+                    this.updateOccupantsOnPresence(pres);
+                    if (this.get('role') !== 'none' && this.get('connection_status') === converse.ROOMSTATUS.CONNECTING) {
+                        this.save('connection_status', converse.ROOMSTATUS.CONNECTED);
+                    }
+                },
+ 
+                onOwnPresence (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.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.trigger('configurationNeeded');
+                            return; // We haven't yet entered the room, so bail here.
+                        }
+                    } else if (!this.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.get('affiliation') === 'owner' && this.get('auto_configure')) {
+                            this.autoConfigureChatRoom().then(this.getRoomFeatures.bind(this));
+                        } else {
+                            this.getRoomFeatures();
+                        }
+                    }
+                    this.save('connection_status', converse.ROOMSTATUS.ENTERED);
+                },
+
                 isUserMentioned (message) {
                     /* Returns a boolean to indicate whether the current user
                      * was mentioned in a message.
@@ -725,7 +1112,7 @@
                 _converse.chatboxviews.each(function (view) {
                     if (view.model.get('type') === converse.CHATROOMS_TYPE) {
                         view.model.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
-                        view.registerHandlers();
+                        view.model.registerHandlers();
                         view.join();
                         view.fetchMessages();
                     }

+ 2 - 1
src/converse-roomslist.js

@@ -213,7 +213,8 @@
                     const name = ev.target.getAttribute('data-room-name');
                     const jid = ev.target.getAttribute('data-room-jid');
                     if (confirm(__("Are you sure you want to leave the room %1$s?", name))) {
-                        _converse.chatboxviews.get(jid).leave();
+                        // TODO: replace with API call
+                        _converse.chatboxviews.get(jid).close();
                     }
                 },