Преглед изворни кода

Render the MUC view component declaratively

JC Brand пре 4 година
родитељ
комит
d8daedea0d

+ 15 - 3
sass/_chatrooms.scss

@@ -1,3 +1,15 @@
+converse-muc-config-form {
+    width: 100%;
+    overflow: auto;
+}
+
+converse-muc-disconnected,
+converse-muc-destroyed {
+    padding: 2em;
+    width: 100%;
+    height: 100%;
+}
+
 #conversejs.converse-embedded,
 #conversejs {
     .badge--muc {
@@ -324,7 +336,7 @@
                         }
                     }
                 }
-                .chatroom-form-container {
+                .muc-form-container {
                     background-color: white;
                     border: 0;
                     color: var(--text-color);
@@ -376,7 +388,7 @@
                 padding: 0;
                 height: 16em;
 
-                .chatroom-form-container {
+                .muc-form-container {
                     .chatroom-form {
                         padding-top: 2em;
                         padding-bottom: 0;
@@ -528,7 +540,7 @@
                     }
                     .chatroom-body {
                         height: 100%;
-                        .chatroom-form-container {
+                        .muc-form-container {
                             height: 100%;
                             position: relative;
                         }

+ 4 - 0
sass/_forms.scss

@@ -118,6 +118,10 @@
             padding-bottom: 0;
         }
 
+        &.converse-form--spinner {
+            height: 100%;
+        }
+
         &.converse-centered-form {
             min-height: 66%;
             text-align: center;

+ 7 - 8
spec/bookmarks.js

@@ -27,21 +27,20 @@ describe("A chat room", function () {
         await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED));
         await mock.returnMemberLists(_converse, muc_jid, [], ['member', 'admin', 'owner']);
 
-        spyOn(view, 'renderBookmarkForm').and.callThrough();
-        spyOn(view, 'closeForm').and.callThrough();
         await u.waitUntil(() => view.querySelector('.toggle-bookmark') !== null);
         const toggle = view.querySelector('.toggle-bookmark');
         expect(toggle.title).toBe('Bookmark this groupchat');
         toggle.click();
-        expect(view.renderBookmarkForm).toHaveBeenCalled();
 
-        view.querySelector('.button-cancel').click();
-        expect(view.closeForm).toHaveBeenCalled();
+        const cancel_button = await u.waitUntil(() => view.querySelector('.button-cancel'));
+        expect(view.model.session.get('view')).toBe('bookmark-form');
+        cancel_button.click();
+
+        await u.waitUntil(() => view.model.session.get('view') === null);
         expect(u.hasClass('on-button', toggle), false);
         expect(toggle.title).toBe('Bookmark this groupchat');
 
         toggle.click();
-        expect(view.renderBookmarkForm).toHaveBeenCalled();
 
         /* Client uploads data:
          * --------------------
@@ -75,13 +74,13 @@ describe("A chat room", function () {
          *  </iq>
          */
         expect(view.model.get('bookmarked')).toBeFalsy();
-        const form = view.querySelector('.chatroom-form');
+        const form = await u.waitUntil(() => view.querySelector('.chatroom-form'));
         form.querySelector('input[name="name"]').value = 'Play&apos;s the Thing';
         form.querySelector('input[name="autojoin"]').checked = 'checked';
         form.querySelector('input[name="nick"]').value = 'JC';
 
         const IQ_stanzas = _converse.connection.IQ_stanzas;
-        view.querySelector('.muc-bookmark-form .btn-primary').click();
+        view.querySelector('converse-muc-bookmark-form .btn-primary').click();
 
         const sent_stanza = await u.waitUntil(
             () => IQ_stanzas.filter(s => sizzle('iq publish[node="storage:bookmarks"]', s).length).pop());

+ 15 - 16
spec/mentions.js

@@ -314,19 +314,19 @@ describe("A sent groupchat message", function () {
                 'stopPropagation': function stopPropagation () {},
                 'keyCode': 13 // Enter
             }
-            spyOn(_converse.connection, 'send');
             const bottom_panel = view.querySelector('converse-muc-bottom-panel');
             bottom_panel.onKeyDown(enter_event);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
-            const msg = _converse.connection.send.calls.all()[0].args[0];
-            expect(msg.toLocaleString())
-                .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
+            const sent_stanzas = _converse.connection.sent_stanzas;
+            const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop());
+            expect(Strophe.serialize(msg))
+                .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
                         `to="lounge@montague.lit" type="groupchat" `+
                         `xmlns="jabber:client">`+
                             `<body>hello Link Mauve</body>`+
                             `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
                             `<reference begin="6" end="16" type="mention" uri="xmpp:lounge@montague.lit/Link%20Mauve" xmlns="urn:xmpp:reference:0"/>`+
-                            `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+                            `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
                         `</message>`);
             done();
         }));
@@ -374,7 +374,6 @@ describe("A sent groupchat message", function () {
                 'stopPropagation': function stopPropagation () {},
                 'keyCode': 13 // Enter
             }
-            spyOn(_converse.connection, 'send');
             const bottom_panel = view.querySelector('converse-muc-bottom-panel');
             bottom_panel.onKeyDown(enter_event);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
@@ -385,9 +384,10 @@ describe("A sent groupchat message", function () {
                     'hello <span class="mention">z3r0</span> <span class="mention">gibson</span> <span class="mention">mr.robot</span>, how are you?'
             );
 
-            const msg = _converse.connection.send.calls.all()[0].args[0];
-            expect(msg.toLocaleString())
-                .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
+            const sent_stanzas = _converse.connection.sent_stanzas;
+            const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop());
+            expect(Strophe.serialize(msg))
+                .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
                         `to="lounge@montague.lit" type="groupchat" `+
                         `xmlns="jabber:client">`+
                             `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
@@ -395,7 +395,7 @@ describe("A sent groupchat message", function () {
                             `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
                             `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
                             `<reference begin="18" end="26" type="mention" uri="xmpp:mr.robot@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                            `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+                            `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
                         `</message>`);
 
             const action = await u.waitUntil(() => view.querySelector('.chat-msg .chat-msg__action'));
@@ -406,16 +406,15 @@ describe("A sent groupchat message", function () {
             expect(view.model.messages.at(0).get('correcting')).toBe(true);
             expect(view.querySelectorAll('.chat-msg').length).toBe(1);
             await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
-            await u.waitUntil(() => _converse.connection.send.calls.count() === 1);
 
             textarea.value = 'hello @z3r0 @gibson @sw0rdf1sh, how are you?';
             bottom_panel.onKeyDown(enter_event);
             await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent ===
                 'hello z3r0 gibson sw0rdf1sh, how are you?', 500);
 
-            const correction = _converse.connection.send.calls.all()[1].args[0];
-            expect(correction.toLocaleString())
-                .toBe(`<message from="romeo@montague.lit/orchard" id="${correction.nodeTree.getAttribute("id")}" `+
+            const correction = sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop();
+            expect(Strophe.serialize(correction))
+                .toBe(`<message from="romeo@montague.lit/orchard" id="${correction.getAttribute("id")}" `+
                         `to="lounge@montague.lit" type="groupchat" `+
                         `xmlns="jabber:client">`+
                             `<body>hello z3r0 gibson sw0rdf1sh, how are you?</body>`+
@@ -423,8 +422,8 @@ describe("A sent groupchat message", function () {
                             `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
                             `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
                             `<reference begin="18" end="27" type="mention" uri="xmpp:sw0rdf1sh@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                            `<replace id="${msg.nodeTree.getAttribute("id")}" xmlns="urn:xmpp:message-correct:0"/>`+
-                            `<origin-id id="${correction.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+                            `<replace id="${msg.getAttribute("id")}" xmlns="urn:xmpp:message-correct:0"/>`+
+                            `<origin-id id="${correction.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
                         `</message>`);
             done();
         }));

+ 3 - 0
spec/messages.js

@@ -1281,6 +1281,9 @@ describe("A Chat Message", function () {
                 await mock.openChatBoxFor(_converse, contact_jid);
 
                 const messages = _converse.connection.sent_stanzas.filter(s => s.nodeName === 'message');
+                if (messages.length > 1) {
+                    debugger;
+                }
                 expect(messages.length).toBe(1);
                 expect(Strophe.serialize(messages[0])).toBe(
                     `<message id="${messages[0].getAttribute('id')}" to="tybalt@montague.lit" type="chat" xmlns="jabber:client">`+

+ 1 - 1
spec/muc-api.js

@@ -1,7 +1,7 @@
 /*global mock, converse */
 
 const Model = converse.env.Model;
-const { sizzle, u } = converse.env;
+const { $pres, $iq, Strophe, sizzle, u } = converse.env;
 
 describe("Groupchats", function () {
 

+ 66 - 74
spec/muc.js

@@ -147,7 +147,6 @@ describe("Groupchats", function () {
             return done();
         }));
 
-
         it("maintains its state across reloads",
             mock.initConverse([], {
                     'clear_messages_on_reconnection': true,
@@ -248,7 +247,6 @@ describe("Groupchats", function () {
                         `<set xmlns="http://jabber.org/protocol/rsm"><before></before><max>50</max></set>`+
                     `</query>`+
                 `</iq>`);
-
             done();
         }));
 
@@ -567,7 +565,7 @@ describe("Groupchats", function () {
 
             const muc_jid = 'lounge@montague.lit';
             await mock.openAndEnterChatRoom(_converse, muc_jid , 'romeo');
-            const view = _converse.chatboxviews.get(muc_jid);
+            const model = _converse.chatboxes.get(muc_jid);
             const message = 'Hello world',
                     nick = mock.chatroom_names[0],
                     msg = $msg({
@@ -577,18 +575,19 @@ describe("Groupchats", function () {
                     'type': 'groupchat'
                 }).c('body').t(message).tree();
 
-            await view.model.handleMessageStanza(msg);
-            await view.model.close();
+            await model.handleMessageStanza(msg);
+            await u.waitUntil(() => document.querySelector('converse-chat-message'));
+            await model.close();
+            await u.waitUntil(() => !document.querySelector('converse-chat-message'));
 
             _converse.connection.IQ_stanzas = [];
             await mock.openAndEnterChatRoom(_converse, muc_jid , 'romeo');
-            await u.waitUntil(() => view.querySelector('converse-chat-message'));
-            expect(view.model.messages.length).toBe(1);
-            expect(view.querySelectorAll('converse-chat-message').length).toBe(1);
+            await u.waitUntil(() => document.querySelector('converse-chat-message'));
+            expect(model.messages.length).toBe(1);
+            expect(document.querySelectorAll('converse-chat-message').length).toBe(1);
             done()
         }));
 
-
         it("clears cached messages when it reconnects and clear_messages_on_reconnection is true",
                 mock.initConverse([], {'clear_messages_on_reconnection': true}, async function (done, _converse) {
 
@@ -734,7 +733,7 @@ describe("Groupchats", function () {
                 .c('status', {code: '110'});
             _converse.connection._dataRecv(mock.createRequest(presence));
 
-            const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
+            const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications')?.textContent);
             expect(csntext.trim()).toEqual("some1 has entered the groupchat");
 
             await room_creation_promise;
@@ -1228,6 +1227,10 @@ describe("Groupchats", function () {
                 .c('status', {code: '110'});
             _converse.connection._dataRecv(mock.createRequest(presence));
             await u.waitUntil(() => view.querySelector('.configure-chatroom-button') !== null);
+
+            const own_occupant = view.model.getOwnOccupant();
+            await u.waitUntil(() => own_occupant.get('affiliation') === 'owner');
+
             view.querySelector('.configure-chatroom-button').click();
 
             /* Check that an IQ is sent out, asking for the
@@ -1470,7 +1473,8 @@ describe("Groupchats", function () {
             view.model.rejoin();
             // Test that members aren't removed when we reconnect
             expect(view.model.occupants.length).toBe(8);
-            await u.waitUntil(() => occupants.querySelectorAll('li').length === 8);
+            view.model.session.set('connection_status', converse.ROOMSTATUS.ENTERED); // Hack
+            await u.waitUntil(() => view.querySelectorAll('.occupant-list li').length === 8);
             done();
         }));
 
@@ -1607,12 +1611,12 @@ describe("Groupchats", function () {
 
             const view = _converse.chatboxviews.get('problematic@muc.montague.lit');
             _converse.connection._dataRecv(mock.createRequest(presence));
-            expect(view.querySelector('.chatroom-body .disconnect-msg').textContent.trim())
-                .toBe('This groupchat no longer exists');
+            const msg = await u.waitUntil(() => view.querySelector('.chatroom-body .disconnect-msg'));
+            expect(msg.textContent.trim()).toBe('This groupchat no longer exists');
             expect(view.querySelector('.chatroom-body .destroyed-reason').textContent.trim())
-                .toBe(`"We didn't like the name"`);
+                .toBe(`The following reason was given: "We didn't like the name"`);
             expect(view.querySelector('.chatroom-body .moved-label').textContent.trim())
-                .toBe('The conversation has moved. Click below to enter.');
+                .toBe('The conversation has moved to a new address. Click the link below to enter.');
             expect(view.querySelector('.chatroom-body .moved-link').textContent.trim())
                 .toBe(`other-room@chat.jabberfr.org`);
             done();
@@ -1649,11 +1653,10 @@ describe("Groupchats", function () {
              *         node='x-roomuser-item'/>
              * </iq>
              */
-            const iq = await u.waitUntil(() => _.filter(
-                    IQ_stanzas,
+            const iq = await u.waitUntil(() => IQ_stanzas.filter(
                     s => sizzle(`iq[to="${muc_jid}"] query[node="x-roomuser-item"]`, s).length
-                ).pop()
-            );
+                ).pop());
+
             expect(Strophe.serialize(iq)).toBe(
                 `<iq from="romeo@montague.lit/orchard" id="${iq.getAttribute('id')}" to="lounge@montague.lit" `+
                     `type="get" xmlns="jabber:client">`+
@@ -2116,8 +2119,7 @@ describe("Groupchats", function () {
             const muc_jid = 'coven@chat.shakespeare.lit';
 
             await _converse.api.rooms.open(muc_jid, { nick });
-            const stanza = await u.waitUntil(() => _.filter(
-                IQ_stanzas,
+            const stanza = await u.waitUntil(() => IQ_stanzas.filter(
                 iq => iq.querySelector(
                     `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
                 )).pop());
@@ -2195,13 +2197,13 @@ describe("Groupchats", function () {
                 'muc_unmoderated',
                 'muc_nonanonymous'
             ];
-            await mock.openAndEnterChatRoom(_converse, 'room@conference.example.org', 'romeo', features);
-            const jid = 'room@conference.example.org';
-            const view = _converse.chatboxviews.get(jid);
+            const muc_jid = 'room@conference.example.org';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+            const view = _converse.chatboxviews.get(muc_jid);
 
             const info_el = view.querySelector(".show-muc-details-modal");
             info_el.click();
-            const modal = _converse.api.modal.get('muc-details-modal');
+            let modal = _converse.api.modal.get('muc-details-modal');
             await u.waitUntil(() => u.isVisible(modal.el), 1000);
 
             let features_list = modal.el.querySelector('.features-list');
@@ -2228,14 +2230,12 @@ describe("Groupchats", function () {
             expect(view.model.features.get('unsecured')).toBe(false);
             await u.waitUntil(() => view.querySelector('.chatbox-title__text').textContent.trim() === 'Room');
 
+            modal.el.querySelector('.close').click();
             view.querySelector('.configure-chatroom-button').click();
 
             const IQs = _converse.connection.IQ_stanzas;
-            let iq = await u.waitUntil(() => _.filter(
-                IQs,
-                iq => iq.querySelector(
-                    `iq[to="${jid}"] query[xmlns="${Strophe.NS.MUC_OWNER}"]`
-                )).pop());
+            const s = `iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_OWNER}"]`;
+            let iq = await u.waitUntil(() => IQs.filter(iq => iq.querySelector(s)).pop());
 
             const response_el = u.toStanza(
                `<iq xmlns="jabber:client"
@@ -2303,13 +2303,13 @@ describe("Groupchats", function () {
                  </query>
                  </iq>`);
             _converse.connection._dataRecv(mock.createRequest(response_el));
-            const el = await u.waitUntil(() => document.querySelector('.chatroom-form legend'));
-            expect(el.textContent.trim()).toBe("Configuration for room@conference.example.org");
+            await u.waitUntil(() => document.querySelector('.chatroom-form input'));
+            expect(view.querySelector('.chatroom-form legend').textContent.trim()).toBe("Configuration for room@conference.example.org");
             sizzle('[name="muc#roomconfig_membersonly"]', view).pop().click();
             sizzle('[name="muc#roomconfig_roomname"]', view).pop().value = "New room name"
             view.querySelector('.chatroom-form input[type="submit"]').click();
 
-            iq = await u.waitUntil(() => _.filter(IQs, iq => u.matchesSelector(iq, `iq[to="${jid}"][type="set"]`)).pop());
+            iq = await u.waitUntil(() => IQs.filter(iq => u.matchesSelector(iq, `iq[to="${muc_jid}"][type="set"]`)).pop());
             const result = $iq({
                 "xmlns": "jabber:client",
                 "type": "result",
@@ -2321,14 +2321,13 @@ describe("Groupchats", function () {
             IQs.length = 0; // Empty the array
             _converse.connection._dataRecv(mock.createRequest(result));
 
-            iq = await u.waitUntil(() => _.filter(
-                IQs,
+            iq = await u.waitUntil(() => IQs.filter(
                 iq => iq.querySelector(
-                    `iq[to="${jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
+                    `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
                 )).pop());
 
             const features_stanza = $iq({
-                'from': jid,
+                'from': muc_jid,
                 'id': iq.getAttribute('id'),
                 'to': 'romeo@montague.lit/desktop',
                 'type': 'result'
@@ -2360,6 +2359,11 @@ describe("Groupchats", function () {
             _converse.connection._dataRecv(mock.createRequest(features_stanza));
 
             await u.waitUntil(() => new Promise(success => view.model.features.on('change', success)));
+
+            info_el.click();
+            modal = _converse.api.modal.get('muc-details-modal');
+            await u.waitUntil(() => u.isVisible(modal.el), 1000);
+
             features_list = modal.el.querySelector('.features-list');
             features_shown = features_list.textContent.split('\n').map(s => s.trim()).filter(s => s);
             expect(features_shown.join(' ')).toBe(
@@ -2906,8 +2910,7 @@ describe("Groupchats", function () {
             });
             _converse.connection.IQ_stanzas = [];
             _converse.connection._dataRecv(mock.createRequest(result));
-            iq_stanza = await u.waitUntil(() => _.filter(
-                _converse.connection.IQ_stanzas,
+            iq_stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
                 iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="member"]')).pop()
             );
 
@@ -2930,8 +2933,7 @@ describe("Groupchats", function () {
             _converse.connection._dataRecv(mock.createRequest(result));
 
             expect(view.model.occupants.length).toBe(2);
-            iq_stanza = await u.waitUntil(() => _.filter(
-                _converse.connection.IQ_stanzas,
+            iq_stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
                 iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="owner"]')).pop()
             );
 
@@ -2954,8 +2956,7 @@ describe("Groupchats", function () {
             _converse.connection._dataRecv(mock.createRequest(result));
 
             expect(view.model.occupants.length).toBe(2);
-            iq_stanza = await u.waitUntil(() => _.filter(
-                _converse.connection.IQ_stanzas,
+            iq_stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
                 iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="admin"]')).pop()
             );
 
@@ -3721,7 +3722,6 @@ describe("Groupchats", function () {
             const muc_jid = 'protected';
             await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
             const view = _converse.chatboxviews.get(muc_jid);
-            spyOn(view, 'renderPasswordForm').and.callThrough();
 
             const presence = $pres().attrs({
                     'from': `${muc_jid}/romeo`,
@@ -3735,8 +3735,7 @@ describe("Groupchats", function () {
             _converse.connection._dataRecv(mock.createRequest(presence));
 
             const chat_body = view.querySelector('.chatroom-body');
-            expect(view.renderPasswordForm).toHaveBeenCalled();
-            expect(chat_body.querySelectorAll('form.chatroom-form').length).toBe(1);
+            await u.waitUntil(() => chat_body.querySelectorAll('form.chatroom-form').length === 1);
             expect(chat_body.querySelector('.chatroom-form label').textContent.trim())
                 .toBe('This groupchat requires a password');
 
@@ -3755,8 +3754,7 @@ describe("Groupchats", function () {
             const muc_jid = 'members-only@muc.montague.lit'
             await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
             const view = _converse.chatboxviews.get(muc_jid);
-            const iq = await u.waitUntil(() => _.filter(
-                _converse.connection.IQ_stanzas,
+            const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
                 iq => iq.querySelector(
                     `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
                 )).pop());
@@ -3791,8 +3789,8 @@ describe("Groupchats", function () {
                       .c('registration-required').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
 
             _converse.connection._dataRecv(mock.createRequest(presence));
-            expect(view.querySelector('.chatroom-body .disconnect-container .disconnect-msg:last-child').textContent.trim())
-                .toBe('You are not on the member list of this groupchat.');
+            await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child')?.textContent?.trim() ===
+                'You are not on the member list of this groupchat.');
             done();
         }));
 
@@ -3802,8 +3800,7 @@ describe("Groupchats", function () {
             const muc_jid = 'off-limits@muc.montague.lit'
             await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
 
-            const iq = await u.waitUntil(() => _.filter(
-                _converse.connection.IQ_stanzas,
+            const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
                 iq => iq.querySelector(
                     `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
                 )).pop());
@@ -3834,8 +3831,8 @@ describe("Groupchats", function () {
                       .c('forbidden').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
             _converse.connection._dataRecv(mock.createRequest(presence));
 
-            expect(view.querySelector('.chatroom-body .disconnect-container .disconnect-msg:last-child').textContent.trim())
-                .toBe('You have been banned from this groupchat.');
+            const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child'));
+            expect(el.textContent.trim()).toBe('You have been banned from this groupchat.');
             done();
         }));
 
@@ -3844,8 +3841,7 @@ describe("Groupchats", function () {
 
             const muc_jid = 'conflicted@muc.montague.lit';
             await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
-            const iq = await u.waitUntil(() => _.filter(
-                _converse.connection.IQ_stanzas,
+            const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
                 iq => iq.querySelector(
                     `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
                 )).pop());
@@ -3876,8 +3872,8 @@ describe("Groupchats", function () {
                       .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
             _converse.connection._dataRecv(mock.createRequest(presence));
 
-            expect(view.querySelector('.muc-nickname-form .validation-message').textContent.trim())
-                .toBe('The nickname you chose is reserved or currently in use, please choose a different one.');
+            const el = await u.waitUntil(() => view.querySelector('.muc-nickname-form .validation-message'));
+            expect(el.textContent.trim()).toBe('The nickname you chose is reserved or currently in use, please choose a different one.');
             done();
         }));
 
@@ -3948,8 +3944,7 @@ describe("Groupchats", function () {
             await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo')
 
             // We pretend this is a new room, so no disco info is returned.
-            const iq = await u.waitUntil(() => _.filter(
-                _converse.connection.IQ_stanzas,
+            const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
                 iq => iq.querySelector(
                     `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
                 )).pop());
@@ -3974,8 +3969,8 @@ describe("Groupchats", function () {
                   .c('error').attrs({by:'lounge@montague.lit', type:'cancel'})
                       .c('not-allowed').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
             _converse.connection._dataRecv(mock.createRequest(presence));
-            expect(view.querySelector('.chatroom-body .disconnect-container .disconnect-msg:last-child').textContent.trim())
-                .toBe('You are not allowed to create new groupchats.');
+            const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child'));
+            expect(el.textContent.trim()).toBe('You are not allowed to create new groupchats.');
             done();
         }));
 
@@ -3985,8 +3980,7 @@ describe("Groupchats", function () {
             const muc_jid = 'conformist@muc.montague.lit'
             await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
 
-            const iq = await u.waitUntil(() => _.filter(
-                _converse.connection.IQ_stanzas,
+            const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
                 iq => iq.querySelector(
                     `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
                 )).pop());
@@ -4013,8 +4007,8 @@ describe("Groupchats", function () {
                       .c('not-acceptable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
 
             _converse.connection._dataRecv(mock.createRequest(presence));
-            expect(view.querySelector('.chatroom-body .disconnect-container .disconnect-msg:last-child').textContent.trim())
-                .toBe("Your nickname doesn't conform to this groupchat's policies.");
+            const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child'));
+            expect(el.textContent.trim()).toBe("Your nickname doesn't conform to this groupchat's policies.");
             done();
         }));
 
@@ -4024,8 +4018,7 @@ describe("Groupchats", function () {
             const muc_jid = 'nonexistent@muc.montague.lit'
             await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo');
 
-            const iq = await u.waitUntil(() => _.filter(
-                _converse.connection.IQ_stanzas,
+            const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
                 iq => iq.querySelector(
                     `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
                 )).pop());
@@ -4052,8 +4045,8 @@ describe("Groupchats", function () {
                       .c('item-not-found').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
 
             _converse.connection._dataRecv(mock.createRequest(presence));
-            expect(view.querySelector('.chatroom-body .disconnect-container .disconnect-msg:last-child').textContent.trim())
-                .toBe("This groupchat does not (yet) exist.");
+            const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child'));
+            expect(el.textContent.trim()).toBe("This groupchat does not (yet) exist.");
             done();
         }));
 
@@ -4063,8 +4056,7 @@ describe("Groupchats", function () {
             const muc_jid = 'maxed-out@muc.montague.lit'
             await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo')
 
-            const iq = await u.waitUntil(() => _.filter(
-                _converse.connection.IQ_stanzas,
+            const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
                 iq => iq.querySelector(
                     `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
                 )).pop());
@@ -4091,8 +4083,8 @@ describe("Groupchats", function () {
                       .c('service-unavailable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
 
             _converse.connection._dataRecv(mock.createRequest(presence));
-            expect(view.querySelector('.chatroom-body .disconnect-container .disconnect-msg:last-child').textContent.trim())
-                .toBe("This groupchat has reached its maximum number of participants.");
+            const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child'));
+            expect(el.textContent.trim()).toBe("This groupchat has reached its maximum number of participants.");
             done();
         }));
     });

+ 12 - 10
src/headless/plugins/muc/index.js

@@ -37,7 +37,9 @@ converse.MUC_TRAFFIC_STATES_LIST = Object.values(converse.MUC_TRAFFIC_STATES);
 converse.MUC_ROLE_CHANGES = { OP: 'op', DEOP: 'deop', VOICE: 'voice', MUTE: 'mute' };
 converse.MUC_ROLE_CHANGES_LIST = Object.values(converse.MUC_ROLE_CHANGES);
 
-converse.MUC_INFO_CODES = {
+converse.MUC = {};
+
+converse.MUC.INFO_CODES = {
     'visibility_changes': ['100', '102', '103', '172', '173', '174'],
     'self': ['110'],
     'non_privacy_changes': ['104', '201'],
@@ -241,15 +243,15 @@ converse.plugins.add('converse-muc', {
             'muc_nickname_from_jid': false,
             'muc_send_probes': false,
             'muc_show_info_messages': [
-                ...converse.MUC_INFO_CODES.visibility_changes,
-                ...converse.MUC_INFO_CODES.self,
-                ...converse.MUC_INFO_CODES.non_privacy_changes,
-                ...converse.MUC_INFO_CODES.muc_logging_changes,
-                ...converse.MUC_INFO_CODES.nickname_changes,
-                ...converse.MUC_INFO_CODES.disconnect_messages,
-                ...converse.MUC_INFO_CODES.affiliation_changes,
-                ...converse.MUC_INFO_CODES.join_leave_events,
-                ...converse.MUC_INFO_CODES.role_changes
+                ...converse.MUC.INFO_CODES.visibility_changes,
+                ...converse.MUC.INFO_CODES.self,
+                ...converse.MUC.INFO_CODES.non_privacy_changes,
+                ...converse.MUC.INFO_CODES.muc_logging_changes,
+                ...converse.MUC.INFO_CODES.nickname_changes,
+                ...converse.MUC.INFO_CODES.disconnect_messages,
+                ...converse.MUC.INFO_CODES.affiliation_changes,
+                ...converse.MUC.INFO_CODES.join_leave_events,
+                ...converse.MUC.INFO_CODES.role_changes
             ],
             'muc_show_logs_before_join': false,
             'muc_show_ogp_unfurls': true,

+ 2 - 10
src/headless/plugins/muc/muc.js

@@ -2576,16 +2576,8 @@ const ChatRoomMixin = {
                     // Accept default configuration
                     this.sendConfiguration().then(() => this.refreshDiscoInfo());
                 } else {
-                    /**
-                     * Triggered when a new room has been created which first needs to be configured
-                     * and when `auto_configure` is set to `false`.
-                     * Used by `_converse.ChatRoomView` in order to know when to render the
-                     * configuration form for a new room.
-                     * @event _converse.ChatRoom#configurationNeeded
-                     * @example _converse.api.listen.on('configurationNeeded', () => { ... });
-                     */
-                    this.trigger('configurationNeeded');
-                    return; // We haven't yet entered the groupchat, so bail here.
+                    this.session.save({ 'view': converse.MUC.VIEWS.CONFIG });
+                    return;
                 }
             } else if (!this.features.get('fetched')) {
                 // The features for this groupchat weren't fetched.

+ 20 - 13
src/plugins/bookmark-views/form.js

@@ -1,24 +1,29 @@
 import tpl_muc_bookmark_form from './templates/form.js';
-import { View } from '@converse/skeletor/src/view.js';
-import { _converse } from '@converse/headless/core';
+import { CustomElement } from 'components/element';
+import { _converse, api } from "@converse/headless/core";
 
 
-const MUCBookmarkForm = View.extend({
-    className: 'muc-bookmark-form chatroom-form-container',
+class MUCBookmarkForm extends CustomElement {
 
-    initialize (attrs) {
-        this.chatroomview = attrs.chatroomview;
-        this.render();
-    },
+    static get properties () {
+        return {
+            'jid': { type: String }
+        }
+    }
+
+    connectedCallback () {
+        super.connectedCallback();
+        this.model = _converse.chatboxes.get(this.jid);
+    }
 
-    toHTML () {
+    render () {
         return tpl_muc_bookmark_form(
             Object.assign(this.model.toJSON(), {
                 'onCancel': ev => this.closeBookmarkForm(ev),
                 'onSubmit': ev => this.onBookmarkFormSubmitted(ev)
             })
         );
-    },
+    }
 
     onBookmarkFormSubmitted (ev) {
         ev.preventDefault();
@@ -29,12 +34,14 @@ const MUCBookmarkForm = View.extend({
             'nick': ev.target.querySelector('input[name=nick]')?.value
         });
         this.closeBookmarkForm(ev);
-    },
+    }
 
     closeBookmarkForm (ev) {
         ev.preventDefault();
-        this.chatroomview.closeForm();
+        this.model.session.save('view', null);
     }
-});
+}
+
+api.elements.define('converse-muc-bookmark-form', MUCBookmarkForm);
 
 export default MUCBookmarkForm;

+ 4 - 5
src/plugins/bookmark-views/mixins.js

@@ -22,7 +22,6 @@ export const bookmarkableChatRoomView = {
     },
 
     renderBookmarkForm () {
-        this.hideChatRoomContents();
         if (!this.bookmark_form) {
             this.bookmark_form = new _converse.MUCBookmarkForm({
                 'model': this.model,
@@ -38,7 +37,7 @@ export const bookmarkableChatRoomView = {
         ev?.preventDefault();
         const models = _converse.bookmarks.where({ 'jid': this.model.get('jid') });
         if (!models.length) {
-            this.renderBookmarkForm();
+            this.model.session.set('view', converse.MUC.VIEWS.BOOKMARK);
         } else {
             models.forEach(model => model.destroy());
         }
@@ -60,13 +59,13 @@ export const eventMethods = {
         }
     },
 
-    addBookmarkViaEvent (ev) {
+    async addBookmarkViaEvent (ev) {
         /* Add a bookmark as determined by the passed in
          * event.
          */
         ev.preventDefault();
         const jid = ev.target.getAttribute('data-room-jid');
-        api.rooms.open(jid, { 'bring_to_foreground': true });
-        _converse.chatboxviews.get(jid).renderBookmarkForm();
+        const room = await api.rooms.open(jid, { 'bring_to_foreground': true });
+        room.session.save('view', converse.MUC.VIEWS.BOOKMARK);
     }
 }

+ 7 - 6
src/plugins/minimize/utils.js

@@ -153,12 +153,13 @@ export function minimize (ev, model) {
     }
     // save the scroll position to restore it on maximize
     const view = _converse.chatboxviews.get(model.get('jid'));
-    const content = view.querySelector('.chat-content__messages');
-    const scroll = content.scrollTop;
-    if (model.collection && model.collection.browserStorage) {
-        model.save({ scroll });
-    } else {
-        model.set({ scroll });
+    const scroll = view.querySelector('.chat-content__messages')?.scrollTop;
+    if (scroll) {
+        if (model.collection && model.collection.browserStorage) {
+            model.save({ scroll });
+        } else {
+            model.set({ scroll });
+        }
     }
     model.setChatState(_converse.INACTIVE);
     u.safeSave(model, {

+ 33 - 33
src/plugins/muc-views/config-form.js

@@ -1,43 +1,41 @@
 import log from "@converse/headless/log";
 import tpl_muc_config_form from "./templates/muc-config-form.js";
-import { View } from '@converse/skeletor/src/view.js';
+import { CustomElement } from 'components/element';
 import { __ } from 'i18n';
-import { api, converse } from "@converse/headless/core";
+import { _converse, api, converse } from "@converse/headless/core";
 
 const { sizzle } = converse.env;
 const u = converse.env.utils;
 
 
-const MUCConfigForm = View.extend({
-    className: 'chatroom-form-container muc-config-form',
+class MUCConfigForm extends CustomElement {
 
-    initialize (attrs) {
-        this.chatroomview = attrs.chatroomview;
-        this.listenTo(this.chatroomview.model.features, 'change:passwordprotected', this.render);
-        this.listenTo(this.chatroomview.model.features, 'change:config_stanza', this.render);
-        this.render();
-    },
-
-    toHTML () {
-        const stanza = u.toStanza(this.model.get('config_stanza'));
-        const whitelist = api.settings.get('roomconfig_whitelist');
-        let fields = sizzle('field', stanza);
-        if (whitelist.length) {
-            fields = fields.filter(f => whitelist.includes(f.getAttribute('var')));
+    static get properties () {
+        return {
+            'jid': { type: String }
         }
-        const password_protected = this.model.features.get('passwordprotected');
-        const options = {
-            'new_password': !password_protected,
-            'fixed_username': this.model.get('jid')
-        };
+    }
+
+    connectedCallback () {
+        super.connectedCallback();
+        this.model = _converse.chatboxes.get(this.jid);
+        this.listenTo(this.model.features, 'change:passwordprotected', this.requestUpdate);
+        this.listenTo(this.model.session, 'change:config_stanza', this.requestUpdate);
+        this.getConfig();
+    }
+
+    render () {
         return tpl_muc_config_form({
-            'closeConfigForm': ev => this.closeConfigForm(ev),
-            'fields': fields.map(f => u.xForm2TemplateResult(f, stanza, options)),
-            'instructions': stanza.querySelector('instructions')?.textContent,
+            'model': this.model,
+            'closeConfigForm': ev => this.closeForm(ev),
             'submitConfigForm': ev => this.submitConfigForm(ev),
-            'title': stanza.querySelector('title')?.textContent
         });
-    },
+    }
+
+    async getConfig () {
+        const iq = await this.model.fetchRoomConfiguration();
+        this.model.session.set('config_stanza', iq.outerHTML);
+    }
 
     async submitConfigForm (ev) {
         ev.preventDefault();
@@ -53,13 +51,15 @@ const MUCConfigForm = View.extend({
             api.alert('error', __('Error'), message);
         }
         await this.model.refreshDiscoInfo();
-        this.chatroomview.closeForm();
-    },
+        this.closeForm();
+    }
 
-    closeConfigForm (ev) {
-        ev.preventDefault();
-        this.chatroomview.closeForm();
+    closeForm (ev) {
+        ev?.preventDefault?.();
+        this.model.session.set('view', null);
     }
-});
+}
+
+api.elements.define('converse-muc-config-form', MUCConfigForm);
 
 export default MUCConfigForm

+ 38 - 0
src/plugins/muc-views/destroyed.js

@@ -0,0 +1,38 @@
+import tpl_muc_destroyed from './templates/muc-destroyed.js';
+import { CustomElement } from 'components/element';
+import { _converse, api } from "@converse/headless/core";
+
+
+class MUCDestroyed extends CustomElement {
+
+    static get properties () {
+        return {
+            'jid': { type: String }
+        }
+    }
+
+    connectedCallback () {
+        super.connectedCallback();
+        this.model = _converse.chatboxes.get(this.jid);
+    }
+
+    render () {
+        const reason = this.model.get('destroyed_reason');
+        const moved_jid = this.model.get('moved_jid');
+        return tpl_muc_destroyed({
+            moved_jid,
+            reason,
+            'onSwitch': ev => this.onSwitch(ev)
+        });
+    }
+
+    async onSwitch (ev) {
+        ev.preventDefault();
+        const moved_jid = this.model.get('moved_jid');
+        const room = await api.rooms.get(moved_jid, {}, true);
+        room.maybeShow(true);
+        this.model.destroy();
+    }
+}
+
+api.elements.define('converse-muc-destroyed', MUCDestroyed);

+ 38 - 0
src/plugins/muc-views/disconnected.js

@@ -0,0 +1,38 @@
+import tpl_muc_disconnect from './templates/muc-disconnect.js';
+import { CustomElement } from 'components/element';
+import { __ } from 'i18n';
+import { _converse, api } from "@converse/headless/core";
+
+
+class MUCDisconnected extends CustomElement {
+
+    static get properties () {
+        return {
+            'jid': { type: String }
+        }
+    }
+
+    connectedCallback () {
+        super.connectedCallback();
+        this.model = _converse.chatboxes.get(this.jid);
+    }
+
+    render () {
+        const message = this.model.get('disconnection_message');
+        if (!message) {
+            return;
+        }
+        const messages = [message];
+        const actor = this.model.get('disconnection_actor');
+        if (actor) {
+            messages.push(__('This action was done by %1$s.', actor));
+        }
+        const reason = this.model.get('disconnection_reason');
+        if (reason) {
+            messages.push(__('The reason given is: "%1$s".', reason));
+        }
+        return tpl_muc_disconnect(messages);
+    }
+}
+
+api.elements.define('converse-muc-disconnected', MUCDisconnected);

+ 2 - 2
src/plugins/muc-views/heading.js

@@ -56,7 +56,7 @@ export default class MUCHeading extends ChatHeading {
     }
 
     getAndRenderConfigurationForm () {
-        _converse.chatboxviews.get(this.getAttribute('jid'))?.getAndRenderConfigurationForm();
+        this.model.session.set('view', converse.MUC.VIEWS.CONFIG);
     }
 
     showModeratorToolsModal () {
@@ -86,7 +86,7 @@ export default class MUCHeading extends ChatHeading {
             buttons.push({
                 'i18n_text': __('Configure'),
                 'i18n_title': __('Configure this groupchat'),
-                'handler': ev => this.getAndRenderConfigurationForm(ev),
+                'handler': () => this.getAndRenderConfigurationForm(),
                 'a_class': 'configure-chatroom-button',
                 'icon_class': 'fa-wrench',
                 'name': 'configure'

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

@@ -19,6 +19,11 @@ import { api, converse, _converse } from '@converse/headless/core';
 
 const { Strophe } = converse.env;
 
+converse.MUC.VIEWS = {
+    CONFIG: 'config-form',
+    BOOKMARK: 'bookmark-form'
+}
+
 function setMUCDomain (domain, controlboxview) {
     controlboxview.querySelector('converse-rooms-list')
         .model.save('muc_domain', Strophe.getDomainFromJid(domain));

+ 21 - 279
src/plugins/muc-views/muc.js

@@ -5,17 +5,10 @@ import BaseChatView from 'shared/chat/baseview.js';
 import ModeratorToolsModal from 'modals/moderator-tools.js';
 import log from '@converse/headless/log';
 import tpl_muc from './templates/muc.js';
-import tpl_muc_destroyed from './templates/muc-destroyed.js';
-import tpl_muc_disconnect from './templates/muc-disconnect.js';
-import tpl_muc_nickname_form from './templates/muc-nickname-form.js';
-import tpl_spinner from 'templates/spinner.js';
 import { Model } from '@converse/skeletor/src/model.js';
 import { __ } from 'i18n';
 import { _converse, api, converse } from '@converse/headless/core';
-import { render } from 'lit-html';
-
-const { sizzle } = converse.env;
-const u = converse.env.utils;
+import { html, render } from "lit-html";
 
 /**
  * Mixin which turns a ChatBoxView into a ChatRoomView
@@ -25,8 +18,6 @@ const u = converse.env.utils;
  */
 export default class MUCView extends BaseChatView {
     length = 300
-    tagName = 'div'
-    className = 'chatbox chatroom hidden'
     is_chatroom = true
     events = {
         'click .chatbox-navback': 'showControlBox',
@@ -34,14 +25,12 @@ export default class MUCView extends BaseChatView {
         // Arrow functions don't work here because you can't bind a different `this` param to them.
         'click .occupant-nick': function (ev) {
             this.insertIntoTextArea(ev.target.textContent);
-        },
-        'submit .muc-nickname-form': 'submitNickname'
+        }
     }
 
     async initialize () {
         const jid = this.getAttribute('jid');
         _converse.chatboxviews.add(jid, this);
-
         this.model = _converse.chatboxes.get(jid);
         this.initDebounced();
 
@@ -49,18 +38,17 @@ export default class MUCView extends BaseChatView {
         this.listenTo(this.model, 'change:composing_spoiler', this.renderMessageForm);
         this.listenTo(this.model, 'change:hidden', () => this.afterShown());
         this.listenTo(this.model, 'change:minimized', () => this.afterShown());
-        this.listenTo(this.model, 'configurationNeeded', this.getAndRenderConfigurationForm);
         this.listenTo(this.model, 'show', this.show);
-        this.listenTo(this.model.session, 'change:connection_status', this.renderAfterTransition);
+        this.listenTo(this.model.session, 'change:connection_status', this.updateAfterTransition);
+        this.listenTo(this.model.session, 'change:view', this.render);
 
         await this.render();
 
         // Need to be registered after render has been called.
         this.listenTo(this.model.messages, 'add', this.onMessageAdded);
         this.listenTo(this.model.occupants, 'change:show', this.showJoinOrLeaveNotification);
-        this.listenTo(this.model.occupants, 'remove', this.onOccupantRemoved);
 
-        this.renderAfterTransition();
+        this.updateAfterTransition();
         this.model.maybeShow();
         this.scrollDown();
         /**
@@ -76,47 +64,20 @@ export default class MUCView extends BaseChatView {
         this.setAttribute('id', this.model.get('box_id'));
         render(
             tpl_muc({
-                'chatview': this,
-                'conn_status': this.model.session.get('connection_status'),
+                'getNicknameRequiredTemplate': () => this.getNicknameRequiredTemplate(),
                 'model': this.model,
-                'occupants': this.model.occupants,
-                'show_sidebar':
-                    !this.model.get('hidden_occupants') &&
-                    this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED,
-                'markScrolled': ev => this.markScrolled(ev),
-                'muc_show_logs_before_join': api.settings.get('muc_show_logs_before_join'),
-                'show_send_button': _converse.show_send_button
             }),
             this
         );
-
         this.notifications = this.querySelector('.chat-content__notifications');
         this.help_container = this.querySelector('.chat-content__help');
 
-        if (
-            !api.settings.get('muc_show_logs_before_join') &&
-            this.model.session.get('connection_status') !== converse.ROOMSTATUS.ENTERED
-        ) {
-            this.showSpinner();
-        }
         // Render header as late as possible since it's async and we
         // want the rest of the DOM elements to be available ASAP.
         // Otherwise e.g. this.notifications is not yet defined when accessed elsewhere.
         !this.model.get('hidden') && this.show();
     }
 
-    /**
-     * Get the nickname value from the form and then join the groupchat with it.
-     * @private
-     * @method _converse.ChatRoomView#submitNickname
-     * @param { Event }
-     */
-    submitNickname (ev) {
-        ev.preventDefault();
-        const nick = ev.target.nick.value.trim();
-        nick && this.model.join(nick);
-    }
-
     showModeratorToolsModal (affiliation) {
         if (!this.model.verifyRoles(['moderator'])) {
             return;
@@ -197,247 +158,28 @@ export default class MUCView extends BaseChatView {
         }
     }
 
-    /**
-     * Renders a form given an IQ stanza containing the current
-     * groupchat configuration.
-     * Returns a promise which resolves once the user has
-     * either submitted the form, or canceled it.
-     * @private
-     * @method _converse.ChatRoomView#renderConfigurationForm
-     * @param { XMLElement } stanza: The IQ stanza containing the groupchat config.
-     */
-    renderConfigurationForm (stanza) {
-        this.hideChatRoomContents();
-        this.model.save('config_stanza', stanza.outerHTML);
-        if (!this.config_form) {
-            this.config_form = new _converse.MUCConfigForm({
-                'model': this.model,
-                'chatroomview': this
-            });
-            const container_el = this.querySelector('.chatroom-body');
-            container_el.insertAdjacentElement('beforeend', this.config_form.el);
-        }
-        u.showElement(this.config_form.el);
-    }
-
-    /**
-     * Renders a form which allows the user to choose theirnickname.
-     * @private
-     * @method _converse.ChatRoomView#renderNicknameForm
-     */
-    renderNicknameForm () {
+    getNicknameRequiredTemplate () {
+        const jid = this.model.get('jid');
         if (api.settings.get('muc_show_logs_before_join')) {
-            this.hideSpinner();
-            u.showElement(this.querySelector('converse-muc-chatarea'));
-        } else {
-            const form = this.querySelector('.muc-nickname-form');
-            const tpl_result = tpl_muc_nickname_form(this.model.toJSON());
-            const form_el = u.getElementFromTemplateResult(tpl_result);
-            if (form) {
-                sizzle('.spinner', this).forEach(u.removeElement);
-                form.outerHTML = form_el.outerHTML;
-            } else {
-                this.hideChatRoomContents();
-                const container = this.querySelector('.chatroom-body');
-                container.insertAdjacentElement('beforeend', form_el);
-            }
-        }
-        u.safeSave(this.model.session, { 'connection_status': converse.ROOMSTATUS.NICKNAME_REQUIRED });
-    }
-
-    /**
-     * Remove the configuration form without submitting and return to the chat view.
-     * @private
-     * @method _converse.ChatRoomView#closeForm
-     */
-    closeForm () {
-        sizzle('.chatroom-form-container', this).forEach(e => u.addClass('hidden', e));
-        this.renderAfterTransition();
-    }
-
-    /**
-     * Start the process of configuring a groupchat, either by
-     * rendering a configuration form, or by auto-configuring
-     * based on the "roomconfig" data stored on the
-     * {@link _converse.ChatRoom}.
-     * Stores the new configuration on the {@link _converse.ChatRoom}
-     * once completed.
-     * @private
-     * @method _converse.ChatRoomView#getAndRenderConfigurationForm
-     * @param { Event } ev - DOM event that might be passed in if this
-     *   method is called due to a user action. In this
-     *   case, auto-configure won't happen, regardless of
-     *   the settings.
-     */
-    getAndRenderConfigurationForm () {
-        if (!this.config_form || !u.isVisible(this.config_form.el)) {
-            this.showSpinner();
-            this.model
-                .fetchRoomConfiguration()
-                .then(iq => this.renderConfigurationForm(iq))
-                .catch(e => log.error(e));
-        } else {
-            this.closeForm();
-        }
-    }
-
-    hideChatRoomContents () {
-        const container_el = this.querySelector('.chatroom-body');
-        if (container_el !== null) {
-            [].forEach.call(container_el.children, child => child.classList.add('hidden'));
-        }
-    }
-
-    renderPasswordForm () {
-        this.hideChatRoomContents();
-        const message = this.model.get('password_validation_message');
-        this.model.save('password_validation_message', undefined);
-
-        if (!this.password_form) {
-            this.password_form = new _converse.MUCPasswordForm({
-                'model': new Model({
-                    'validation_message': message
-                }),
-                'chatroomview': this
-            });
-            const container_el = this.querySelector('.chatroom-body');
-            container_el.insertAdjacentElement('beforeend', this.password_form.el);
+            return html`<converse-muc-chatarea jid="${jid}"></converse-muc-chatarea>`;
         } else {
-            this.password_form.model.set('validation_message', message);
-        }
-        u.showElement(this.password_form.el);
-        this.model.session.save('connection_status', converse.ROOMSTATUS.PASSWORD_REQUIRED);
-    }
-
-    showDestroyedMessage () {
-        u.hideElement(this.querySelector('converse-muc-chatarea'));
-        sizzle('.spinner', this).forEach(u.removeElement);
-
-        const reason = this.model.get('destroyed_reason');
-        const moved_jid = this.model.get('moved_jid');
-        this.model.save({
-            'destroyed_reason': undefined,
-            'moved_jid': undefined
-        });
-        const container = this.querySelector('.disconnect-container');
-        render(tpl_muc_destroyed(moved_jid, reason), container);
-        const switch_el = container.querySelector('a.switch-chat');
-        if (switch_el) {
-            switch_el.addEventListener('click', async ev => {
-                ev.preventDefault();
-                const room = await api.rooms.get(moved_jid, null, true);
-                room.maybeShow(true);
-                this.model.destroy();
-            });
-        }
-        u.showElement(container);
-    }
-
-    showDisconnectMessage () {
-        const message = this.model.get('disconnection_message');
-        if (!message) {
-            return;
-        }
-        u.hideElement(this.querySelector('converse-muc-chatarea'));
-        sizzle('.spinner', this).forEach(u.removeElement);
-
-        const messages = [message];
-        const actor = this.model.get('disconnection_actor');
-        if (actor) {
-            messages.push(__('This action was done by %1$s.', actor));
+            return html`<converse-muc-nickname-form jid="${jid}"></converse-muc-nickname-form>`;
         }
-        const reason = this.model.get('disconnection_reason');
-        if (reason) {
-            messages.push(__('The reason given is: "%1$s".', reason));
-        }
-        this.model.save({
-            'disconnection_message': undefined,
-            'disconnection_reason': undefined,
-            'disconnection_actor': undefined
-        });
-        const container = this.querySelector('.disconnect-container');
-        render(tpl_muc_disconnect(messages), container);
-        u.showElement(container);
     }
 
-
-    /**
-     * Working backwards, get today's most recent join/leave notification
-     * from the same user (if any exists) after the most recent chat message.
-     * @private
-     * @method _converse.ChatRoomView#getPreviousJoinOrLeaveNotification
-     * @param {HTMLElement} el
-     * @param {string} nick
-     */
-    getPreviousJoinOrLeaveNotification (el, nick) { // eslint-disable-line class-methods-use-this
-        const today = new Date().toISOString().split('T')[0];
-        while (el !== null) {
-            if (!el.classList.contains('chat-info')) {
-                return;
-            }
-            // Check whether el is still from today.
-            // We don't use `Dayjs.same` here, since it's about 4 times slower.
-            const date = el.getAttribute('data-isodate');
-            if (date && date.split('T')[0] !== today) {
-                return;
-            }
-            const data = el?.dataset || {};
-            if (data.join === nick || data.leave === nick || data.leavejoin === nick || data.joinleave === nick) {
-                return el;
-            }
-            el = el.previousElementSibling;
-        }
-    }
-
-    /**
-     * Rerender the groupchat after some kind of transition. For
-     * example after the spinner has been removed or after a
-     * form has been submitted and removed.
-     * @private
-     * @method _converse.ChatRoomView#renderAfterTransition
-     */
-    renderAfterTransition () {
+    updateAfterTransition () {
         const conn_status = this.model.session.get('connection_status');
-        if (conn_status === converse.ROOMSTATUS.NICKNAME_REQUIRED) {
-            this.renderNicknameForm();
-        } else if (conn_status === converse.ROOMSTATUS.PASSWORD_REQUIRED) {
-            this.renderPasswordForm();
-        } else if (conn_status === converse.ROOMSTATUS.CONNECTING) {
-            this.showSpinner();
-        } else if (conn_status === converse.ROOMSTATUS.ENTERED) {
-            this.hideSpinner();
-            this.hideChatRoomContents();
-            u.showElement(this.querySelector('converse-muc-chatarea'));
-            this.scrollDown();
-            this.maybeFocus();
-        } else if (conn_status === converse.ROOMSTATUS.DISCONNECTED) {
-            this.showDisconnectMessage();
-        } else if (conn_status === converse.ROOMSTATUS.DESTROYED) {
-            this.showDestroyedMessage();
-        }
-    }
-
-    showSpinner () {
-        sizzle('.spinner', this).forEach(u.removeElement);
-        this.hideChatRoomContents();
-        const container_el = this.querySelector('.chatroom-body');
-        container_el.insertAdjacentElement('afterbegin', u.getElementFromTemplateResult(tpl_spinner()));
-    }
-
-    /**
-     * Check if the spinner is being shown and if so, hide it.
-     * Also make sure then that the chat area and occupants
-     * list are both visible.
-     * @private
-     * @method _converse.ChatRoomView#hideSpinner
-     */
-    hideSpinner () {
-        const spinner = this.querySelector('.spinner');
-        if (spinner !== null) {
-            u.removeElement(spinner);
-            this.renderAfterTransition();
+        if (conn_status === converse.ROOMSTATUS.CONNECTING) {
+            this.model.save({
+                'disconnection_actor': undefined,
+                'disconnection_message': undefined,
+                'disconnection_reason': undefined,
+                'moved_jid': undefined,
+                'password_validation_message': undefined,
+                'reason': undefined,
+            });
         }
-        return this;
+        this.render();
     }
 }
 

+ 25 - 0
src/plugins/muc-views/nickname-form.js

@@ -0,0 +1,25 @@
+import tpl_muc_nickname_form from './templates/muc-nickname-form.js';
+import { CustomElement } from 'components/element';
+import { _converse, api } from "@converse/headless/core";
+
+class MUCNicknameForm extends CustomElement {
+
+    static get properties () {
+        return {
+            'jid': { type: String }
+        }
+    }
+
+    connectedCallback () {
+        super.connectedCallback();
+        this.model = _converse.chatboxes.get(this.jid);
+    }
+
+    render () {
+        return tpl_muc_nickname_form(this.model);
+    }
+}
+
+api.elements.define('converse-muc-nickname-form', MUCNicknameForm);
+
+export default MUCNicknameForm;

+ 23 - 14
src/plugins/muc-views/password-form.js

@@ -1,30 +1,39 @@
 import tpl_muc_password_form from "./templates/muc-password-form.js";
-import { View } from '@converse/skeletor/src/view.js';
+import { CustomElement } from 'components/element';
+import { _converse, api } from "@converse/headless/core";
 
 
-const MUCPasswordForm = View.extend({
-    className: 'chatroom-form-container muc-password-form',
+class MUCPasswordForm extends CustomElement {
 
-    initialize (attrs) {
-        this.chatroomview = attrs.chatroomview;
-        this.listenTo(this.model, 'change:validation_message', this.render);
+    static get properties () {
+        return {
+            'jid': { type: String }
+        }
+    }
+
+    connectedCallback () {
+        super.connectedCallback();
+        this.model = _converse.chatboxes.get(this.jid);
+        this.listenTo(this.model, 'change:password_validation_message', this.render);
         this.render();
-    },
+    }
 
-    toHTML () {
+    render () {
         return tpl_muc_password_form({
             'jid': this.model.get('jid'),
             'submitPassword': ev => this.submitPassword(ev),
-            'validation_message':  this.model.get('validation_message')
+            'validation_message':  this.model.get('password_validation_message')
         });
-    },
+    }
 
     submitPassword (ev) {
         ev.preventDefault();
-        const password = this.el.querySelector('input[type=password]').value;
-        this.chatroomview.model.join(this.chatroomview.model.get('nick'), password);
-        this.model.set('validation_message', null);
+        const password = this.querySelector('input[type=password]').value;
+        this.model.join(this.model.get('nick'), password);
+        this.model.set('password_validation_message', null);
     }
-});
+}
+
+api.elements.define('converse-muc-password-form', MUCPasswordForm);
 
 export default MUCPasswordForm;

+ 1 - 1
src/plugins/muc-views/templates/muc-bottom-panel.js

@@ -16,7 +16,7 @@ export default (o) => {
         return (o.can_edit) ? tpl_can_edit() : html`<span class="muc-bottom-panel muc-bottom-panel--muted">${i18n_not_allowed}</span>`;
     } else if (conn_status == converse.ROOMSTATUS.NICKNAME_REQUIRED) {
         if (api.settings.get('muc_show_logs_before_join')) {
-            return html`<span class="muc-bottom-panel muc-bottom-panel--nickname">${tpl_muc_nickname_form(o.model.toJSON())}</span>`;
+            return html`<span class="muc-bottom-panel muc-bottom-panel--nickname">${tpl_muc_nickname_form(o.model)}</span>`;
         }
     } else {
         return '';

+ 40 - 9
src/plugins/muc-views/templates/muc-config-form.js

@@ -1,20 +1,51 @@
-import { html } from "lit-html";
+import tpl_spinner from 'templates/spinner.js';
 import { __ } from 'i18n';
+import { api, converse } from "@converse/headless/core";
+import { html } from "lit-html";
+
+const { sizzle } = converse.env;
+const u = converse.env.utils;
 
 export default (o) => {
+    const whitelist = api.settings.get('roomconfig_whitelist');
+    const config_stanza = o.model.session.get('config_stanza');
+    let fields = [];
+    let instructions = '';
+    let title;
+    if (config_stanza) {
+        const stanza = u.toStanza(config_stanza);
+        fields = sizzle('field', stanza);
+        if (whitelist.length) {
+            fields = fields.filter(f => whitelist.includes(f.getAttribute('var')));
+        }
+        const password_protected = o.model.features.get('passwordprotected');
+        const options = {
+            'new_password': !password_protected,
+            'fixed_username': o.model.get('jid')
+        };
+        fields = fields.map(f => u.xForm2TemplateResult(f, stanza, options));
+        instructions = stanza.querySelector('instructions')?.textContent;
+        title = stanza.querySelector('title')?.textContent;
+    } else {
+        title = __('Loading configuration form');
+    }
     const i18n_save = __('Save');
     const i18n_cancel = __('Cancel');
     return html`
-        <form class="converse-form chatroom-form" autocomplete="off" @submit=${o.submitConfigForm}>
+        <form class="converse-form chatroom-form ${fields.length ? '' : 'converse-form--spinner'}"
+                autocomplete="off"
+                @submit=${o.submitConfigForm}>
+
             <fieldset class="form-group">
-                <legend>${o.title}</legend>
-                ${ (o.title !== o.instructions) ? html`<p class="form-help">${o.instructions}</p>` : '' }
-                ${ o.fields }
-            </fieldset>
-            <fieldset>
-                <input type="submit" class="btn btn-primary" value="${i18n_save}">
-                <input type="button" class="btn btn-secondary button-cancel" value="${i18n_cancel}" @click=${o.closeConfigForm}>
+                <legend class="centered">${title}</legend>
+                ${ (title !== instructions) ? html`<p class="form-help">${instructions}</p>` : '' }
+                ${ fields.length ? fields : tpl_spinner({'classes': 'hor_centered'}) }
             </fieldset>
+            ${ fields.length ? html`
+                <fieldset>
+                    <input type="submit" class="btn btn-primary" value="${i18n_save}">
+                    <input type="button" class="btn btn-secondary button-cancel" value="${i18n_cancel}" @click=${o.closeConfigForm}>
+                </fieldset>` : '' }
         </form>
     `;
 }

+ 11 - 8
src/plugins/muc-views/templates/muc-destroyed.js

@@ -1,20 +1,23 @@
 import { __ } from 'i18n';
 import { html } from "lit-html";
 
-
-const tpl_moved = (jid) => {
-    const i18n_moved = __('The conversation has moved. Click below to enter.');
+const tpl_moved = (o) => {
+    const i18n_moved = __('The conversation has moved to a new address. Click the link below to enter.');
     return html`
         <p class="moved-label">${i18n_moved}</p>
-        <p class="moved-link"><a class="switch-chat" href="#">${jid}</a></p>`;
+        <p class="moved-link">
+            <a class="switch-chat" @click=${ev => o.onSwitch(ev)}>${o.moved_jid}</a>
+        </p>`;
 }
 
-export default (jid, reason) => {
+export default (o) => {
     const i18n_non_existent = __('This groupchat no longer exists');
+    const i18n_reason = __('The following reason was given: "%1$s"', o.reason || '');
     return html`
         <div class="alert alert-danger">
             <h3 class="alert-heading disconnect-msg">${i18n_non_existent}</h3>
-            ${ reason ? html`<p class="destroyed-reason">"${reason}"</p>` : '' }
-            ${ jid ? tpl_moved(jid) : '' }
-        </div>`;
+        </div>
+        ${ o.reason ? html`<p class="destroyed-reason">${i18n_reason}</p>` : '' }
+        ${ o.moved_jid ? tpl_moved(o) : '' }
+    `;
 }

+ 13 - 5
src/plugins/muc-views/templates/muc-nickname-form.js

@@ -2,25 +2,33 @@ import { __ } from 'i18n';
 import { api } from "@converse/headless/core";
 import { html } from "lit-html";
 
+function submitNickname (ev, model) {
+    ev.preventDefault();
+    const nick = ev.target.nick.value.trim();
+    nick && model.join(nick);
+}
 
-export default (o) => {
+export default (model) => {
     const i18n_nickname =  __('Nickname');
     const i18n_join = __('Enter groupchat');
     const i18n_heading = api.settings.get('muc_show_logs_before_join') ?
         __('Choose a nickname to enter') :
         __('Please choose your nickname');
 
+    const validation_message = model.get('nickname_validation_message');
+
     return html`
-        <div class="chatroom-form-container muc-nickname-form">
+        <div class="chatroom-form-container muc-nickname-form"
+                @submit=${ev => submitNickname(ev, model)}>
             <form class="converse-form chatroom-form converse-centered-form">
                 <fieldset class="form-group">
                     <label>${i18n_heading}</label>
-                    <p class="validation-message">${o.nickname_validation_message}</p>
+                    <p class="validation-message">${validation_message}</p>
                     <input type="text"
                         required="required"
                         name="nick"
-                        value="${o.nick || ''}"
-                        class="form-control ${o.nickname_validation_message ? 'error': ''}"
+                        value="${model.get('nick') || ''}"
+                        class="form-control ${validation_message ? 'error': ''}"
                         placeholder="${i18n_nickname}"/>
                 </fieldset>
                 <fieldset class="form-group">

+ 39 - 9
src/plugins/muc-views/templates/muc.js

@@ -1,15 +1,45 @@
-import '../chatarea.js';
 import '../bottom-panel.js';
+import '../chatarea.js';
+import '../config-form.js';
+import '../destroyed.js';
+import '../disconnected.js';
 import '../heading.js';
+import '../nickname-form.js';
+import '../password-form.js';
 import '../sidebar.js';
+import tpl_spinner from 'templates/spinner.js';
+import { converse } from "@converse/headless/core";
 import { html } from "lit-html";
 
-export default (o) => html`
-    <div class="flyout box-flyout">
-        <converse-dragresize></converse-dragresize>
-        <converse-muc-heading jid="${o.model.get('jid')}" class="chat-head chat-head-chatroom row no-gutters"></converse-muc-heading>
-        <div class="chat-body chatroom-body row no-gutters">
-            <converse-muc-chatarea jid="${o.model.get('jid')}"></converse-muc-chatarea>
+
+function getChatRoomBody (o) {
+    const view = o.model.session.get('view');
+    const jid = o.model.get('jid');
+    const RS = converse.ROOMSTATUS;
+    const conn_status =  o.model.session.get('connection_status');
+
+    if (view === converse.MUC.VIEWS.CONFIG) {
+        return html`<converse-muc-config-form class="muc-form-container" jid="${jid}"></converse-muc-config-form>`;
+    } else if (view === converse.MUC.VIEWS.BOOKMARK) {
+        return html`<converse-muc-bookmark-form class="muc-form-container" jid="${jid}"></converse-muc-bookmark-form>`;
+    } else {
+        return html`
+            ${ conn_status == RS.PASSWORD_REQUIRED ? html`<converse-muc-password-form class="muc-form-container" jid="${jid}"></converse-muc-password-form>` : '' }
+            ${ conn_status == RS.ENTERED ? html`<converse-muc-chatarea jid="${jid}"></converse-muc-chatarea>` : '' }
+            ${ conn_status == RS.CONNECTING ? tpl_spinner() : '' }
+            ${ conn_status == RS.NICKNAME_REQUIRED ? o.getNicknameRequiredTemplate() : '' }
+            ${ conn_status == RS.DISCONNECTED ? html`<converse-muc-disconnected jid="${jid}"></converse-muc-disconnected>` : '' }
+            ${ conn_status == RS.DESTROYED ? html`<converse-muc-destroyed jid="${jid}"></converse-muc-destroyed>` : '' }
+        `;
+    }
+}
+
+export default (o) => {
+    return html`
+        <div class="flyout box-flyout">
+            <converse-dragresize></converse-dragresize>
+            <converse-muc-heading jid="${o.model.get('jid')}" class="chat-head chat-head-chatroom row no-gutters"></converse-muc-heading>
+            <div class="chat-body chatroom-body row no-gutters">${getChatRoomBody(o)}</div>
         </div>
-    </div>
-`;
+    `;
+}

+ 0 - 1
src/plugins/muc-views/utils.js

@@ -20,7 +20,6 @@ const COMMAND_TO_ROLE = {
     'voice': 'participant'
 };
 
-
 export function getAutoCompleteListItem (text, input) {
     input = input.trim();
     const element = document.createElement('li');