Sfoglia il codice sorgente

More usage of the `stx` tagged template literal

JC Brand 10 mesi fa
parent
commit
a9e3b237b2
36 ha cambiato i file con 3184 aggiunte e 1738 eliminazioni
  1. 4 0
      karma.conf.js
  2. 10 9
      src/headless/plugins/muc/tests/muc.js
  3. 67 55
      src/plugins/muc-views/tests/actions.js
  4. 96 108
      src/plugins/muc-views/tests/autocomplete.js
  5. 939 0
      src/plugins/muc-views/tests/commands.js
  6. 0 1
      src/plugins/muc-views/tests/component.js
  7. 106 93
      src/plugins/muc-views/tests/corrections.js
  8. 266 0
      src/plugins/muc-views/tests/csn.js
  9. 37 28
      src/plugins/muc-views/tests/disco.js
  10. 11 12
      src/plugins/muc-views/tests/emojis.js
  11. 23 11
      src/plugins/muc-views/tests/hats.js
  12. 12 11
      src/plugins/muc-views/tests/http-file-upload.js
  13. 7 10
      src/plugins/muc-views/tests/info-messages.js
  14. 41 56
      src/plugins/muc-views/tests/mam.js
  15. 10 10
      src/plugins/muc-views/tests/markers.js
  16. 31 22
      src/plugins/muc-views/tests/me-messages.js
  17. 62 68
      src/plugins/muc-views/tests/member-lists.js
  18. 115 123
      src/plugins/muc-views/tests/mentions.js
  19. 71 62
      src/plugins/muc-views/tests/mep.js
  20. 48 64
      src/plugins/muc-views/tests/modtools.js
  21. 18 30
      src/plugins/muc-views/tests/muc-api.js
  22. 51 81
      src/plugins/muc-views/tests/muc-avatar.js
  23. 33 28
      src/plugins/muc-views/tests/muc-list-modal.js
  24. 8 11
      src/plugins/muc-views/tests/muc-mentions.js
  25. 118 97
      src/plugins/muc-views/tests/muc-messages.js
  26. 16 13
      src/plugins/muc-views/tests/muc-registration.js
  27. 294 344
      src/plugins/muc-views/tests/muc.js
  28. 124 0
      src/plugins/muc-views/tests/mute.js
  29. 148 124
      src/plugins/muc-views/tests/nickname.js
  30. 11 10
      src/plugins/muc-views/tests/occupants-filter.js
  31. 77 0
      src/plugins/muc-views/tests/probes.js
  32. 28 24
      src/plugins/muc-views/tests/rai.js
  33. 230 157
      src/plugins/muc-views/tests/retractions.js
  34. 19 15
      src/plugins/muc-views/tests/styling.js
  35. 41 41
      src/plugins/muc-views/tests/unfurls.js
  36. 12 20
      src/plugins/muc-views/tests/xss.js

+ 4 - 0
karma.conf.js

@@ -73,8 +73,10 @@ module.exports = function(config) {
       { pattern: "src/plugins/minimize/tests/minchats.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/actions.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/autocomplete.js", type: 'module' },
+      { pattern: "src/plugins/muc-views/tests/commands.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/component.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/corrections.js", type: 'module' },
+      { pattern: "src/plugins/muc-views/tests/csn.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/disco.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/emojis.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/hats.js", type: 'module' },
@@ -95,9 +97,11 @@ module.exports = function(config) {
       { pattern: "src/plugins/muc-views/tests/muc-messages.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/muc-registration.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/muc.js", type: 'module' },
+      { pattern: "src/plugins/muc-views/tests/mute.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/nickname.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/occupants-filter.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/occupants.js", type: 'module' },
+      { pattern: "src/plugins/muc-views/tests/probes.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/rai.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/retractions.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/styling.js", type: 'module' },

+ 10 - 9
src/headless/plugins/muc/tests/muc.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 
-const { Strophe, sizzle, u } = converse.env;
+const { Strophe, sizzle, stx, u } = converse.env;
 
 describe("Groupchats", function () {
 
@@ -13,18 +13,18 @@ describe("Groupchats", function () {
         await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], [], false, {'hidden': true});
         const model = _converse.chatboxes.get(muc_jid);
 
-        _converse.api.connection.get()._dataRecv(mock.createRequest(u.toStanza(`
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
             <message xmlns="jabber:client" type="groupchat" id="1" to="${_converse.jid}" xml:lang="en" from="${muc_jid}/juliet">
                 <body>Romeo oh romeo</body>
-            </message>`)));
+            </message>`));
         await u.waitUntil(() => model.messages.length);
         expect(model.get('num_unread_general')).toBe(1);
         expect(model.get('num_unread')).toBe(1);
 
-        _converse.api.connection.get()._dataRecv(mock.createRequest(u.toStanza(`
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
             <message xmlns="jabber:client" type="groupchat" id="2" to="${_converse.jid}" xml:lang="en" from="${muc_jid}/juliet">
                 <body>Wherefore art though?</body>
-            </message>`)));
+            </message>`));
 
         await u.waitUntil(() => model.messages.length === 2);
 
@@ -101,7 +101,7 @@ describe("Groupchats", function () {
             expect(model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED);
             model.sendMessage({'body': 'hello world'});
 
-            const stanza = u.toStanza(`
+            const stanza = stx`
                 <message xmlns='jabber:client'
                          from='${muc_jid}'
                          type='error'
@@ -109,7 +109,7 @@ describe("Groupchats", function () {
                     <error type='cancel'>
                         <not-acceptable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
                     </error>
-                </message>`);
+                </message>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
             let sent_stanzas = _converse.api.connection.get().sent_stanzas;
@@ -119,15 +119,16 @@ describe("Groupchats", function () {
                     `<ping xmlns="urn:xmpp:ping"/>`+
                 `</iq>`);
 
-            const result = u.toStanza(`
+            const result = stx`
                 <iq from='${muc_jid}'
                     id='${iq.getAttribute('id')}'
                     to='${_converse.bare_jid}'
+                    xmlns="jabber:server"
                     type='error'>
                 <error type='cancel'>
                     <not-acceptable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
                 </error>
-                </iq>`);
+                </iq>`;
             sent_stanzas = _converse.api.connection.get().sent_stanzas;
             const index = sent_stanzas.length -1;
 

+ 67 - 55
src/plugins/muc-views/tests/actions.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 
-const { $msg, u } = converse.env;
+const { Strophe, u, stx } = converse.env;
 
 describe("A Groupchat Message", function () {
 
@@ -9,16 +9,16 @@ describe("A Groupchat Message", function () {
 
         const muc_jid = 'lounge@montague.lit';
         const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-        const stanza = $pres({
-                to: 'romeo@montague.lit/_converse.js-29092160',
-                from: 'coven@chat.shakespeare.lit/newguy'
-            })
-            .c('x', {xmlns: Strophe.NS.MUC_USER})
-            .c('item', {
-                'affiliation': 'none',
-                'jid': 'newguy@montague.lit/_converse.js-290929789',
-                'role': 'participant'
-            }).tree();
+
+        const stanza = stx`
+            <presence
+                to="romeo@montague.lit/_converse.js-29092160"
+                from="coven@chat.shakespeare.lit/newguy"
+                xmlns="jabber:client">
+                <x xmlns="${Strophe.NS.MUC_USER}">
+                    <item affiliation="none" jid="newguy@montague.lit/_converse.js-290929789" role="participant"/>
+                </x>
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
         const view = _converse.chatboxviews.get(muc_jid);
@@ -28,12 +28,15 @@ describe("A Groupchat Message", function () {
 
         const firstMessageText = 'But soft, what light through yonder airlock breaks?';
         const msg_id = u.getUniqueId();
-        await model.handleMessageStanza($msg({
-                'from': 'lounge@montague.lit/newguy',
-                'to': _converse.api.connection.get().jid,
-                'type': 'groupchat',
-                'id': msg_id,
-            }).c('body').t(firstMessageText).tree());
+        await model.handleMessageStanza(stx`
+            <message
+                from="lounge@montague.lit/newguy"
+                to="${_converse.api.connection.get().jid}"
+                type="groupchat"
+                id="${msg_id}"
+                xmlns="jabber:client">
+                <body>${firstMessageText}</body>
+            </message>`);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
         let firstAction = view.querySelector('.chat-msg__action-copy');
         expect(firstAction).not.toBeNull();
@@ -61,26 +64,28 @@ describe("A Groupchat Message", function () {
 
         const muc_jid = 'lounge@montague.lit';
         const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-        const stanza = $pres({
-                to: 'romeo@montague.lit/_converse.js-29092160',
-                from: 'coven@chat.shakespeare.lit/newguy'
-            })
-            .c('x', {xmlns: Strophe.NS.MUC_USER})
-            .c('item', {
-                'affiliation': 'none',
-                'jid': 'newguy@montague.lit/_converse.js-290929789',
-                'role': 'participant'
-            }).tree();
+        const stanza = stx`
+            <presence
+                to="romeo@montague.lit/_converse.js-29092160"
+                from="coven@chat.shakespeare.lit/newguy"
+                xmlns="jabber:client">
+                <x xmlns="${Strophe.NS.MUC_USER}">
+                    <item affiliation="none" jid="newguy@montague.lit/_converse.js-290929789" role="participant"/>
+                </x>
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
         const firstMessageText = 'But soft, what light through yonder airlock breaks?';
         const msg_id = u.getUniqueId();
-        await model.handleMessageStanza($msg({
-                'from': 'lounge@montague.lit/newguy',
-                'to': _converse.api.connection.get().jid,
-                'type': 'groupchat',
-                'id': msg_id,
-            }).c('body').t(firstMessageText).tree());
+        await model.handleMessageStanza(stx`
+            <message
+                from="lounge@montague.lit/newguy"
+                to="${_converse.api.connection.get().jid}"
+                type="groupchat"
+                id="${msg_id}"
+                xmlns="jabber:client">
+                <body>${firstMessageText}</body>
+            </message>`);
 
         const view = _converse.chatboxviews.get(muc_jid);
         const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea'));
@@ -107,37 +112,44 @@ describe("A Groupchat Message", function () {
             mock.initConverse([], {}, async function (_converse) {
         const muc_jid = 'lounge@montague.lit';
         const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', ['muc_moderated']);
-        const stanza = $pres({
-                to: 'romeo@montague.lit/_converse.js-29092160',
-                from: 'coven@chat.shakespeare.lit/newguy'
-            })
-            .c('x', {xmlns: Strophe.NS.MUC_USER})
-            .c('item', {
-                'affiliation': 'none',
-                'jid': 'newguy@montague.lit/_converse.js-290929789',
-                'role': 'participant'
-            }).tree();
+        const stanza = stx`
+            <presence
+                to="romeo@montague.lit/_converse.js-29092160"
+                from="coven@chat.shakespeare.lit/newguy"
+                xmlns="jabber:client">
+                <x xmlns="${Strophe.NS.MUC_USER}">
+                    <item affiliation="none" jid="newguy@montague.lit/_converse.js-290929789" role="participant"/>
+                </x>
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
         const view = _converse.chatboxviews.get(muc_jid);
 
         const msg_id = u.getUniqueId();
-        await model.handleMessageStanza($msg({
-                'from': 'lounge@montague.lit/newguy',
-                'to': _converse.api.connection.get().jid,
-                'type': 'groupchat',
-                'id': msg_id,
-            }).c('body').t('But soft, what light through yonder airlock breaks?').tree());
+        await model.handleMessageStanza(stx`
+            <message
+                from="lounge@montague.lit/newguy"
+                to="${_converse.api.connection.get().jid}"
+                type="groupchat"
+                id="${msg_id}"
+                xmlns="jabber:client">
+                <body>But soft, what light through yonder airlock breaks?</body>
+            </message>`);
+
         await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
         // Quoting should be available before losing permission to speak
         expect(view.querySelector('.chat-msg__action-quote')).not.toBeNull();
 
-        const presence = $pres({
-                to: 'romeo@montague.lit/orchard',
-                from: `${muc_jid}/romeo`
-            }).c('x', {xmlns: Strophe.NS.MUC_USER})
-            .c('item', {'affiliation': 'none', 'role': 'visitor'}).up()
-            .c('status', {code: '110'});
+        const presence = stx`
+            <presence
+                to="romeo@montague.lit/orchard"
+                from="${muc_jid}/romeo"
+                xmlns="jabber:client">
+                <x xmlns="${Strophe.NS.MUC_USER}">
+                    <item affiliation="none" role="visitor"/>
+                </x>
+                <status code="110"/>
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
         const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});
         await u.waitUntil(() => occupant.get('role') === 'visitor');

+ 96 - 108
src/plugins/muc-views/tests/autocomplete.js

@@ -1,9 +1,6 @@
 /*global mock, converse */
 
-const $pres = converse.env.$pres;
-const $msg = converse.env.$msg;
-const Strophe = converse.env.Strophe;
-const u = converse.env.utils;
+const { Strophe, u, stx } = converse.env;
 
 describe("The nickname autocomplete feature", function () {
 
@@ -13,30 +10,29 @@ describe("The nickname autocomplete feature", function () {
 
         await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'tom');
         const view = _converse.chatboxviews.get('lounge@montague.lit');
-
         // Nicknames from presences
         ['dick', 'harry'].forEach((nick) => {
             _converse.api.connection.get()._dataRecv(mock.createRequest(
-                $pres({
-                    'to': 'tom@montague.lit/resource',
-                    'from': `lounge@montague.lit/${nick}`
-                })
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'jid': `${nick}@montague.lit/resource`,
-                    'role': 'participant'
-                })));
+                stx`<presence
+                    to="tom@montague.lit/resource"
+                    from="lounge@montague.lit/${nick}"
+                    xmlns="jabber:client">
+                    <x xmlns="${Strophe.NS.MUC_USER}">
+                        <item affiliation="none" jid="${nick}@montague.lit/resource" role="participant"/>
+                    </x>
+                </presence>`));
         });
 
         // Nicknames from messages
-        const msg = $msg({
-                from: 'lounge@montague.lit/jane',
-                id: u.getUniqueId(),
-                to: 'romeo@montague.lit',
-                type: 'groupchat'
-            }).c('body').t('Hello world').tree();
-        await view.model.handleMessageStanza(msg);
+        await view.model.handleMessageStanza(
+            stx`<message
+                    from="lounge@montague.lit/jane"
+                    id="${u.getUniqueId()}"
+                    to="romeo@montague.lit"
+                    type="groupchat"
+                    xmlns="jabber:client">
+                <body>Hello world</body>
+            </message>`.tree());
         await u.waitUntil(() => view.model.messages.last()?.get('received'));
 
         // Test that pressing @ brings up all options
@@ -81,26 +77,26 @@ describe("The nickname autocomplete feature", function () {
         // Nicknames from presences
         ['dick', 'harry'].forEach((nick) => {
             _converse.api.connection.get()._dataRecv(mock.createRequest(
-                $pres({
-                    'to': 'tom@montague.lit/resource',
-                    'from': `lounge@montague.lit/${nick}`
-                })
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'jid': `${nick}@montague.lit/resource`,
-                    'role': 'participant'
-                })));
+                stx`<presence
+                        to="tom@montague.lit/resource"
+                        from="lounge@montague.lit/${nick}"
+                        xmlns="jabber:client">
+                    <x xmlns="${Strophe.NS.MUC_USER}">
+                        <item affiliation="none" jid="${nick}@montague.lit/resource" role="participant"/>
+                    </x>
+                </presence>`));
         });
 
         // Nicknames from messages
-        const msg = $msg({
-                from: 'lounge@montague.lit/jane',
-                id: u.getUniqueId(),
-                to: 'romeo@montague.lit',
-                type: 'groupchat'
-            }).c('body').t('Hello world').tree();
-        await view.model.handleMessageStanza(msg);
+        await view.model.handleMessageStanza(
+            stx`<message
+                    from="lounge@montague.lit/jane"
+                    id="${u.getUniqueId()}"
+                    to="romeo@montague.lit"
+                    type="groupchat"
+                    xmlns="jabber:client">
+                <body>Hello world</body>
+            </message>`.tree());
         await u.waitUntil(() => view.model.messages.last()?.get('received'));
 
         // Test that pressing @ brings up all options
@@ -147,26 +143,27 @@ describe("The nickname autocomplete feature", function () {
         // Nicknames from presences
         ['dick', 'harry'].forEach((nick) => {
             _converse.api.connection.get()._dataRecv(mock.createRequest(
-                $pres({
-                    'to': 'tom@montague.lit/resource',
-                    'from': `lounge@montague.lit/${nick}`
-                })
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'jid': `${nick}@montague.lit/resource`,
-                    'role': 'participant'
-                })));
+                stx`<presence
+                    to="tom@montague.lit/resource"
+                    from="lounge@montague.lit/${nick}"
+                    xmlns="jabber:client">
+                    <x xmlns="${Strophe.NS.MUC_USER}">
+                        <item affiliation="none" jid="${nick}@montague.lit/resource" role="participant"/>
+                    </x>
+                </presence>`))
         });
 
         // Nicknames from messages
-        const msg = $msg({
-                from: 'lounge@montague.lit/jane',
-                id: u.getUniqueId(),
-                to: 'romeo@montague.lit',
-                type: 'groupchat'
-            }).c('body').t('Hello world').tree();
-        await view.model.handleMessageStanza(msg);
+        await view.model.handleMessageStanza(
+            stx`<message
+                    from="lounge@montague.lit/jane"
+                    id="${u.getUniqueId()}"
+                    to="romeo@montague.lit"
+                    type="groupchat"
+                    xmlns="jabber:client">
+                <body>Hello world</body>
+            </message>`);
+
         await u.waitUntil(() => view.model.messages.last()?.get('received'));
 
         // Test that pressing @ brings up all options
@@ -210,16 +207,14 @@ describe("The nickname autocomplete feature", function () {
             // Nicknames from presences
             ['bernard', 'naber', 'helberlo', 'john', 'jones'].forEach((nick) => {
                 _converse.api.connection.get()._dataRecv(mock.createRequest(
-                    $pres({
-                        'to': 'tom@montague.lit/resource',
-                        'from': `lounge@montague.lit/${nick}`
-                    })
-                        .c('x', { xmlns: Strophe.NS.MUC_USER })
-                        .c('item', {
-                            'affiliation': 'none',
-                            'jid': `${nick}@montague.lit/resource`,
-                            'role': 'participant'
-                        })));
+                    stx`<presence
+                        to="tom@montague.lit/resource"
+                        from="lounge@montague.lit/${nick}"
+                        xmlns="jabber:client">
+                        <x xmlns="${Strophe.NS.MUC_USER}">
+                            <item affiliation="none" jid="${nick}@montague.lit/resource" role="participant"/>
+                        </x>
+                    </presence>`));
             });
 
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
@@ -270,16 +265,14 @@ describe("The nickname autocomplete feature", function () {
         await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
         const view = _converse.chatboxviews.get('lounge@montague.lit');
         expect(view.model.occupants.length).toBe(1);
-        let presence = $pres({
-                'to': 'romeo@montague.lit/orchard',
-                'from': 'lounge@montague.lit/some1'
-            })
-            .c('x', {xmlns: Strophe.NS.MUC_USER})
-            .c('item', {
-                'affiliation': 'none',
-                'jid': 'some1@montague.lit/resource',
-                'role': 'participant'
-            });
+        let presence = stx`<presence
+                    to="romeo@montague.lit/orchard"
+                    from="lounge@montague.lit/some1"
+                    xmlns="jabber:client">
+                <x xmlns="${Strophe.NS.MUC_USER}">
+                    <item affiliation="none" jid="some1@montague.lit/resource" role="participant"/>
+                </x>
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
         expect(view.model.occupants.length).toBe(2);
 
@@ -316,16 +309,14 @@ describe("The nickname autocomplete feature", function () {
         }
         await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === true);
 
-        presence = $pres({
-                'to': 'romeo@montague.lit/orchard',
-                'from': 'lounge@montague.lit/some2'
-            })
-            .c('x', {xmlns: Strophe.NS.MUC_USER})
-            .c('item', {
-                'affiliation': 'none',
-                'jid': 'some2@montague.lit/resource',
-                'role': 'participant'
-            });
+        presence = stx`<presence
+                to="romeo@montague.lit/orchard"
+                from="lounge@montague.lit/some2"
+                xmlns="jabber:client">
+            <x xmlns="${Strophe.NS.MUC_USER}">
+                <item affiliation="none" jid="some2@montague.lit/resource" role="participant"/>
+            </x>
+        </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
 
         textarea.value = "hello s s";
@@ -356,17 +347,16 @@ describe("The nickname autocomplete feature", function () {
         expect(textarea.value).toBe('hello s @some2 ');
 
         // Test that pressing tab twice selects
-        presence = $pres({
-                'to': 'romeo@montague.lit/orchard',
-                'from': 'lounge@montague.lit/z3r0'
-            })
-            .c('x', {xmlns: Strophe.NS.MUC_USER})
-            .c('item', {
-                'affiliation': 'none',
-                'jid': 'z3r0@montague.lit/resource',
-                'role': 'participant'
-            });
-        _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
+        _converse.api.connection.get()._dataRecv(mock.createRequest(
+            stx`<presence
+                    to="romeo@montague.lit/orchard"
+                    from="lounge@montague.lit/z3r0"
+                    xmlns="jabber:client">
+                <x xmlns="${Strophe.NS.MUC_USER}">
+                    <item affiliation="none" jid="z3r0@montague.lit/resource" role="participant"/>
+                </x>
+            </presence>`));
+
         textarea.value = "hello z";
         message_form.onKeyDown(tab_event);
         message_form.onKeyUp(tab_event);
@@ -383,17 +373,15 @@ describe("The nickname autocomplete feature", function () {
         await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
         const view = _converse.chatboxviews.get('lounge@montague.lit');
         expect(view.model.occupants.length).toBe(1);
-        const presence = $pres({
-                'to': 'romeo@montague.lit/orchard',
-                'from': 'lounge@montague.lit/some1'
-            })
-            .c('x', {xmlns: Strophe.NS.MUC_USER})
-            .c('item', {
-                'affiliation': 'none',
-                'jid': 'some1@montague.lit/resource',
-                'role': 'participant'
-            });
-        _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
+        _converse.api.connection.get()._dataRecv(mock.createRequest(
+            stx`<presence
+                    to="romeo@montague.lit/orchard"
+                    from="lounge@montague.lit/some1"
+                    xmlns="jabber:client">
+                <x xmlns="${Strophe.NS.MUC_USER}">
+                    <item affiliation="none" jid="some1@montague.lit/resource" role="participant"/>
+                </x>
+            </presence>`));
         expect(view.model.occupants.length).toBe(2);
 
         const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));

+ 939 - 0
src/plugins/muc-views/tests/commands.js

@@ -0,0 +1,939 @@
+/*global mock, converse */
+
+const { Strophe, Promise, sizzle, stx, u }  = converse.env;
+
+describe("Groupchats", function () {
+    describe("Each chat groupchat can take special commands", function () {
+
+        it("takes /help to show the available commands",
+                mock.initConverse([], {}, async function (_converse) {
+
+            spyOn(window, 'confirm').and.callFake(() => true);
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+            const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
+            textarea.value = '/help';
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown(enter);
+
+            await u.waitUntil(() => sizzle('converse-chat-help .chat-info', view).length);
+            let chat_help_el = view.querySelector('converse-chat-help');
+            let info_messages = sizzle('.chat-info', chat_help_el);
+            expect(info_messages.length).toBe(19);
+            expect(info_messages.pop().textContent.trim()).toBe('/voice: Allow muted user to post messages');
+            expect(info_messages.pop().textContent.trim()).toBe('/topic: Set groupchat subject (alias for /subject)');
+            expect(info_messages.pop().textContent.trim()).toBe('/subject: Set groupchat subject');
+            expect(info_messages.pop().textContent.trim()).toBe('/revoke: Revoke the user\'s current affiliation');
+            expect(info_messages.pop().textContent.trim()).toBe('/register: Register your nickname');
+            expect(info_messages.pop().textContent.trim()).toBe('/owner: Grant ownership of this groupchat');
+            expect(info_messages.pop().textContent.trim()).toBe('/op: Grant moderator role to user');
+            expect(info_messages.pop().textContent.trim()).toBe('/nick: Change your nickname');
+            expect(info_messages.pop().textContent.trim()).toBe('/mute: Remove user\'s ability to post messages');
+            expect(info_messages.pop().textContent.trim()).toBe('/modtools: Opens up the moderator tools GUI');
+            expect(info_messages.pop().textContent.trim()).toBe('/member: Grant membership to a user');
+            expect(info_messages.pop().textContent.trim()).toBe('/me: Write in 3rd person');
+            expect(info_messages.pop().textContent.trim()).toBe('/kick: Kick user from groupchat');
+            expect(info_messages.pop().textContent.trim()).toBe('/help: Show this menu');
+            expect(info_messages.pop().textContent.trim()).toBe('/destroy: Remove this groupchat');
+            expect(info_messages.pop().textContent.trim()).toBe('/deop: Change user role to participant');
+            expect(info_messages.pop().textContent.trim()).toBe('/clear: Clear the chat area');
+            expect(info_messages.pop().textContent.trim()).toBe('/ban: Ban user by changing their affiliation to outcast');
+            expect(info_messages.pop().textContent.trim()).toBe('/admin: Change user\'s affiliation to admin');
+
+            const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});
+            occupant.set('affiliation', 'admin');
+
+            view.querySelector('.close-chat-help').click();
+            expect(view.model.get('show_help_messages')).toBe(false);
+            await u.waitUntil(() => view.querySelector('converse-chat-help') === null);
+
+            textarea.value = '/help';
+            message_form.onKeyDown(enter);
+            chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
+            info_messages = sizzle('.chat-info', chat_help_el);
+            expect(info_messages.length).toBe(18);
+            let commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
+            expect(commands).toEqual([
+                "/admin", "/ban", "/clear", "/deop", "/destroy",
+                "/help", "/kick", "/me", "/member", "/modtools", "/mute", "/nick",
+                "/op", "/register", "/revoke", "/subject", "/topic", "/voice"
+            ]);
+            occupant.set('affiliation', 'member');
+            view.querySelector('.close-chat-help').click();
+            await u.waitUntil(() => view.querySelector('converse-chat-help') === null);
+
+            textarea.value = '/help';
+            message_form.onKeyDown(enter);
+            chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
+            info_messages = sizzle('.chat-info', chat_help_el);
+            expect(info_messages.length).toBe(9);
+            commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
+            expect(commands).toEqual(["/clear", "/help", "/kick", "/me", "/modtools", "/mute", "/nick", "/register", "/voice"]);
+
+            view.querySelector('.close-chat-help').click();
+            await u.waitUntil(() => view.querySelector('converse-chat-help') === null);
+            expect(view.model.get('show_help_messages')).toBe(false);
+
+            occupant.set('role', 'participant');
+            // Role changes causes rerender, so we need to get the new textarea
+
+            textarea.value = '/help';
+            message_form.onKeyDown(enter);
+            await u.waitUntil(() => view.model.get('show_help_messages'));
+            chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
+            info_messages = sizzle('.chat-info', chat_help_el);
+            expect(info_messages.length).toBe(5);
+            commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
+            expect(commands).toEqual(["/clear", "/help", "/me", "/nick", "/register"]);
+
+            // Test that /topic is available if all users may change the subject
+            // Note: we're making a shortcut here, this value should never be set manually
+            view.model.config.set('changesubject', true);
+            view.querySelector('.close-chat-help').click();
+            await u.waitUntil(() => view.querySelector('converse-chat-help') === null);
+
+            textarea.value = '/help';
+            message_form.onKeyDown(enter);
+            chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
+            info_messages = sizzle('.chat-info', chat_help_el);
+            expect(info_messages.length).toBe(7);
+            commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
+            expect(commands).toEqual(["/clear", "/help", "/me", "/nick", "/register", "/subject", "/topic"]);
+        }));
+
+        it("takes /help to show the available commands and commands can be disabled by config",
+                mock.initConverse([], {muc_disable_slash_commands: ['mute', 'voice']}, async function (_converse) {
+
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+            const enter = { 'target': textarea, 'preventDefault': function () {}, 'keyCode': 13 };
+            spyOn(window, 'confirm').and.callFake(() => true);
+            textarea.value = '/clear';
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown(enter);
+            textarea.value = '/help';
+            message_form.onKeyDown(enter);
+
+            await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view).length);
+            const info_messages = sizzle('.chat-info:not(.chat-event)', view);
+            expect(info_messages.length).toBe(17);
+            expect(info_messages.pop().textContent.trim()).toBe('/topic: Set groupchat subject (alias for /subject)');
+            expect(info_messages.pop().textContent.trim()).toBe('/subject: Set groupchat subject');
+            expect(info_messages.pop().textContent.trim()).toBe('/revoke: Revoke the user\'s current affiliation');
+            expect(info_messages.pop().textContent.trim()).toBe('/register: Register your nickname');
+            expect(info_messages.pop().textContent.trim()).toBe('/owner: Grant ownership of this groupchat');
+            expect(info_messages.pop().textContent.trim()).toBe('/op: Grant moderator role to user');
+            expect(info_messages.pop().textContent.trim()).toBe('/nick: Change your nickname');
+            expect(info_messages.pop().textContent.trim()).toBe('/modtools: Opens up the moderator tools GUI');
+            expect(info_messages.pop().textContent.trim()).toBe('/member: Grant membership to a user');
+            expect(info_messages.pop().textContent.trim()).toBe('/me: Write in 3rd person');
+            expect(info_messages.pop().textContent.trim()).toBe('/kick: Kick user from groupchat');
+            expect(info_messages.pop().textContent.trim()).toBe('/help: Show this menu');
+            expect(info_messages.pop().textContent.trim()).toBe('/destroy: Remove this groupchat');
+            expect(info_messages.pop().textContent.trim()).toBe('/deop: Change user role to participant');
+            expect(info_messages.pop().textContent.trim()).toBe('/clear: Clear the chat area');
+            expect(info_messages.pop().textContent.trim()).toBe('/ban: Ban user by changing their affiliation to outcast');
+            expect(info_messages.pop().textContent.trim()).toBe('/admin: Change user\'s affiliation to admin');
+        }));
+
+        it("takes /member to make an occupant a member",
+                mock.initConverse([], {}, async function (_converse) {
+
+            let iq_stanza;
+            await mock.openAndEnterChatRoom(_converse, 'lounge@muc.montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@muc.montague.lit');
+
+            /* We don't show join/leave messages for existing occupants. We
+             * know about them because we receive their presences before we
+             * receive our own.
+             */
+            _converse.api.connection.get()._dataRecv(mock.createRequest(
+                stx`<presence
+                        xmlns="jabber:client"
+                        to="romeo@montague.lit/orchard"
+                        from="lounge@muc.montague.lit/marc">
+                    <x xmlns="${Strophe.NS.MUC_USER}">
+                        <item affiliation="none" jid="marc@montague.lit/_converse.js-290929789" role="participant"/>
+                    </x>
+                </presence>`
+            ));
+            expect(view.model.occupants.length).toBe(2);
+
+            const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+            let sent_stanza;
+            spyOn(_converse.api.connection.get(), 'send').and.callFake((stanza) => {
+                sent_stanza = stanza;
+            });
+
+            // First check that an error message appears when a
+            // non-existent nick is used.
+            textarea.value = '/member chris Welcome to the club!';
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            expect(_converse.api.connection.get().send).not.toHaveBeenCalled();
+            await u.waitUntil(() => view.querySelectorAll('.chat-error').length);
+            expect(view.querySelector('.chat-error').textContent.trim())
+                .toBe('Error: couldn\'t find a groupchat participant based on your arguments');
+
+            // Now test with an existing nick
+            textarea.value = '/member marc Welcome to the club!';
+            message_form.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            await u.waitUntil(() => Strophe.serialize(sent_stanza) ===
+                `<iq id="${sent_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item affiliation="member" jid="marc@montague.lit">`+
+                            `<reason>Welcome to the club!</reason>`+
+                        `</item>`+
+                    `</query>`+
+                `</iq>`);
+
+            let result = stx`<iq xmlns="jabber:client"
+                    type="result"
+                    to="romeo@montague.lit/orchard"
+                    from="lounge@muc.montague.lit"
+                    id="${sent_stanza.getAttribute('id')}"/>`;
+            _converse.api.connection.get().IQ_stanzas = [];
+            _converse.api.connection.get()._dataRecv(mock.createRequest(result));
+            iq_stanza = await u.waitUntil(() => _converse.api.connection.get().IQ_stanzas.filter(
+                iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="member"]')).pop()
+            );
+
+            expect(Strophe.serialize(iq_stanza)).toBe(
+                `<iq id="${iq_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="get" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item affiliation="member"/>`+
+                    `</query>`+
+                `</iq>`)
+            expect(view.model.occupants.length).toBe(2);
+
+            result = stx`<iq xmlns="jabber:client"
+                    type="result"
+                    to="romeo@montague.lit/orchard"
+                    from="lounge@muc.montague.lit"
+                    id="${iq_stanza.getAttribute("id")}">
+                <query xmlns="http://jabber.org/protocol/muc#admin">
+                    <item jid="marc" affiliation="member"/>
+                </query>
+            </iq>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(result));
+
+            expect(view.model.occupants.length).toBe(2);
+            iq_stanza = await u.waitUntil(() => _converse.api.connection.get().IQ_stanzas.filter(
+                iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="owner"]')).pop()
+            );
+
+            expect(Strophe.serialize(iq_stanza)).toBe(
+                `<iq id="${iq_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="get" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item affiliation="owner"/>`+
+                    `</query>`+
+                `</iq>`)
+            expect(view.model.occupants.length).toBe(2);
+
+            result = stx`<iq xmlns="jabber:client"
+                    type="result"
+                    to="romeo@montague.lit/orchard"
+                    from="lounge@muc.montague.lit"
+                    id="${iq_stanza.getAttribute("id")}">
+                <query xmlns="http://jabber.org/protocol/muc#admin">
+                    <item jid="romeo@montague.lit" affiliation="owner"/>
+                </query>
+            </iq>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(result));
+
+            expect(view.model.occupants.length).toBe(2);
+            iq_stanza = await u.waitUntil(() => _converse.api.connection.get().IQ_stanzas.filter(
+                iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="admin"]')).pop()
+            );
+
+            expect(Strophe.serialize(iq_stanza)).toBe(
+                `<iq id="${iq_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="get" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item affiliation="admin"/>`+
+                    `</query>`+
+                `</iq>`)
+            expect(view.model.occupants.length).toBe(2);
+
+            result = stx`<iq xmlns="jabber:client"
+                        type="result"
+                        to="romeo@montague.lit/orchard"
+                        from="lounge@muc.montague.lit"
+                        id="${iq_stanza.getAttribute('id')}">
+                    <query xmlns="http://jabber.org/protocol/muc#admin"></query>
+                </iq>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(result));
+            await u.waitUntil(() => view.querySelectorAll('.occupant').length, 500);
+            await u.waitUntil(() => view.querySelectorAll('.badge').length > 2);
+            expect(view.model.occupants.length).toBe(2);
+            expect(view.querySelectorAll('.occupant').length).toBe(2);
+        }));
+
+        it("takes /topic to set the groupchat topic", mock.initConverse([], {}, async function (_converse) {
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            // Check the alias /topic
+            const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+            textarea.value = '/topic This is the groupchat subject';
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            const { sent_stanzas } = _converse.api.connection.get();
+            await u.waitUntil(() => sent_stanzas.filter(s => s.textContent.trim() === 'This is the groupchat subject'));
+
+            // Check /subject
+            textarea.value = '/subject This is a new subject';
+            message_form.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+
+            let sent_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.textContent.trim() === 'This is a new subject').pop());
+            expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe(
+                '<message from="romeo@montague.lit/orchard" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">'+
+                    '<subject xmlns="jabber:client">This is a new subject</subject>'+
+                '</message>');
+
+            // Check case insensitivity
+            textarea.value = '/Subject This is yet another subject';
+            message_form.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            sent_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.textContent.trim() === 'This is yet another subject').pop());
+            expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe(
+                '<message from="romeo@montague.lit/orchard" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">'+
+                    '<subject xmlns="jabber:client">This is yet another subject</subject>'+
+                '</message>');
+
+            while (sent_stanzas.length) {
+                sent_stanzas.pop();
+            }
+            // Check unsetting the topic
+            textarea.value = '/topic';
+            message_form.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            sent_stanza = await u.waitUntil(() => sent_stanzas.pop());
+            expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe(
+                '<message from="romeo@montague.lit/orchard" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">'+
+                    '<subject xmlns="jabber:client"></subject>'+
+                '</message>');
+        }));
+
+        it("takes /clear to clear messages", mock.initConverse([], {}, async function (_converse) {
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+            textarea.value = '/clear';
+            spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(false));
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            await u.waitUntil(() => _converse.api.confirm.calls.count() === 1);
+            expect(_converse.api.confirm).toHaveBeenCalledWith('Are you sure you want to clear the messages from this conversation?');
+        }));
+
+        it("takes /owner to make a user an owner", mock.initConverse([], {}, async function (_converse) {
+            let sent_IQ, IQ_id;
+            const sendIQ = _converse.api.connection.get().sendIQ;
+            spyOn(_converse.api.connection.get(), 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_IQ = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
+
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
+
+            _converse.api.connection.get()._dataRecv(mock.createRequest(
+                stx`<presence
+                            from="lounge@montague.lit/annoyingGuy"
+                            id="27C55F89-1C6A-459A-9EB5-77690145D624"
+                            to="romeo@montague.lit/desktop"
+                            xmlns="jabber:client">
+                        <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item jid="annoyingguy@montague.lit" affiliation="member" role="participant"/>
+                        </x>
+                </presence>`));
+
+            const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+            textarea.value = '/owner';
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count());
+            const err_msg = await u.waitUntil(() => view.querySelector('.chat-error'));
+            expect(err_msg.textContent.trim()).toBe(
+                "Error: the \"owner\" command takes two arguments, the user's nickname and optionally a reason.");
+
+            const sel = 'iq[type="set"] query[xmlns="http://jabber.org/protocol/muc#admin"]';
+            const stanzas = _converse.api.connection.get().IQ_stanzas.filter(s => sizzle(sel, s).length);
+            expect(stanzas.length).toBe(0);
+
+            // XXX: Calling onFormSubmitted directly, trying
+            // again via triggering Event doesn't work for some weird
+            // reason.
+            textarea.value = '/owner nobody You\'re responsible';
+            message_form.onFormSubmitted(new Event('submit'));
+            await u.waitUntil(() => view.querySelectorAll('.chat-error').length === 2);
+            expect(Array.from(view.querySelectorAll('.chat-error')).pop().textContent.trim()).toBe(
+                "Error: couldn't find a groupchat participant based on your arguments");
+
+            expect(_converse.api.connection.get().IQ_stanzas.filter(s => sizzle(sel, s).length).length).toBe(0);
+
+            // Call now with the correct of arguments.
+            // XXX: Calling onFormSubmitted directly, trying
+            // again via triggering Event doesn't work for some weird
+            // reason.
+            textarea.value = '/owner annoyingGuy You\'re responsible';
+            message_form.onFormSubmitted(new Event('submit'));
+
+            await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 3);
+            // Check that the member list now gets updated
+            expect(Strophe.serialize(sent_IQ)).toBe(
+                `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item affiliation="owner" jid="annoyingguy@montague.lit">`+
+                            `<reason>You&apos;re responsible</reason>`+
+                        `</item>`+
+                    `</query>`+
+                `</iq>`);
+
+            _converse.api.connection.get()._dataRecv(mock.createRequest(
+                stx`<presence
+                        from="lounge@montague.lit/annoyingGuy"
+                        id="27C55F89-1C6A-459A-9EB5-77690145D628"
+                        to="romeo@montague.lit/desktop"
+                        xmlns="jabber:client">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item jid="annoyingguy@montague.lit" affiliation="owner" role="participant"/>
+                    </x>
+                </presence>`));
+            await u.waitUntil(() =>
+                Array.from(view.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
+                "annoyingGuy is now an owner of this groupchat"
+            );
+        }));
+
+        it("takes /ban to ban a user", mock.initConverse([], {}, async function (_converse) {
+            let sent_IQ, IQ_id;
+            const sendIQ = _converse.api.connection.get().sendIQ;
+            spyOn(_converse.api.connection.get(), 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_IQ = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
+
+            await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+            const view = _converse.chatboxviews.get('lounge@montague.lit');
+            spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
+
+            _converse.api.connection.get()._dataRecv(
+                mock.createRequest(
+                    stx`<presence
+                            from="lounge@montague.lit/annoyingGuy"
+                            id="27C55F89-1C6A-459A-9EB5-77690145D624"
+                            to="romeo@montague.lit/desktop"
+                            xmlns="jabber:client">
+                        <x xmlns="http://jabber.org/protocol/muc#user">
+                            <item jid="annoyingguy@montague.lit" affiliation="member" role="participant"/>
+                        </x>
+                    </presence>`));
+
+            const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+            textarea.value = '/ban';
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count());
+            await u.waitUntil(() => view.querySelector('.message:last-child')?.textContent?.trim() ===
+                "Error: the \"ban\" command takes two arguments, the user's nickname and optionally a reason.");
+
+            const sel = 'iq[type="set"] query[xmlns="http://jabber.org/protocol/muc#admin"]';
+            const stanzas = _converse.api.connection.get().IQ_stanzas.filter(s => sizzle(sel, s).length);
+            expect(stanzas.length).toBe(0);
+
+            // Call now with the correct amount of arguments.
+            // XXX: Calling onFormSubmitted directly, trying
+            // again via triggering Event doesn't work for some weird
+            // reason.
+            textarea.value = '/ban annoyingGuy You\'re annoying';
+            message_form.onFormSubmitted(new Event('submit'));
+
+            await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 2);
+            // Check that the member list now gets updated
+            expect(Strophe.serialize(sent_IQ)).toBe(
+                `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item affiliation="outcast" jid="annoyingguy@montague.lit">`+
+                            `<reason>You&apos;re annoying</reason>`+
+                        `</item>`+
+                    `</query>`+
+                `</iq>`);
+
+            _converse.api.connection.get()._dataRecv(mock.createRequest(
+                stx`<presence
+                        from='lounge@montague.lit/annoyingGuy'
+                        id='27C55F89-1C6A-459A-9EB5-77690145D628'
+                        to='romeo@montague.lit/desktop'
+                        xmlns="jabber:client">
+                    <x xmlns='http://jabber.org/protocol/muc#user'>
+                        <item jid='annoyingguy@montague.lit' affiliation='outcast' role='participant'>
+                            <actor nick='romeo'/>
+                            <reason>You're annoying</reason>
+                            <status code='301'/>
+                        </item>
+                    </x>
+                </presence>`));
+
+            await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 2);
+            expect(view.querySelectorAll('.chat-info__message')[1].textContent.trim()).toBe("annoyingGuy has been banned by romeo");
+            expect(view.querySelector('.chat-info:last-child q').textContent.trim()).toBe("You're annoying");
+            _converse.api.connection.get()._dataRecv(mock.createRequest(
+                stx`<presence
+                        from="lounge@montague.lit/joe2"
+                        id="27C55F89-1C6A-459A-9EB5-77690145D624"
+                        to="romeo@montague.lit/desktop"
+                        xmlns="jabber:client">
+                        <x xmlns="http://jabber.org/protocol/muc#user">
+                            <item jid="joe2@montague.lit" affiliation="member" role="participant"/>
+                        </x>
+                </presence>`
+            ));
+
+            textarea.value = '/ban joe22';
+            message_form.onFormSubmitted(new Event('submit'));
+            await u.waitUntil(() => view.querySelector('converse-chat-message:last-child')?.textContent?.trim() ===
+                "Error: couldn't find a groupchat participant based on your arguments");
+        }));
+
+
+        it("takes a /kick command to kick a user", mock.initConverse([], {}, async function (_converse) {
+            let sent_IQ, IQ_id;
+            const sendIQ = _converse.api.connection.get().sendIQ;
+            spyOn(_converse.api.connection.get(), 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_IQ = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
+
+            const muc_jid = 'lounge@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.chatboxviews.get(muc_jid);
+            spyOn(view.model, 'setRole').and.callThrough();
+            spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
+
+            let presence = stx`<presence
+                    from='lounge@montague.lit/annoying guy'
+                    id='27C55F89-1C6A-459A-9EB5-77690145D624'
+                    to='romeo@montague.lit/desktop'
+                    xmlns="jabber:client"> 
+                    <x xmlns='http://jabber.org/protocol/muc#user'>
+                        <item jid='annoyingguy@montague.lit' affiliation='none' role='participant'/>
+                    </x>
+                </presence>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
+
+            const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+            textarea.value = '/kick';
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+            await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count());
+            await u.waitUntil(() => view.querySelector('.message:last-child')?.textContent?.trim() ===
+                "Error: the \"kick\" command takes two arguments, the user's nickname and optionally a reason.");
+            expect(view.model.setRole).not.toHaveBeenCalled();
+            // Call now with the correct amount of arguments.
+            // XXX: Calling onFormSubmitted directly, trying
+            // again via triggering Event doesn't work for some weird
+            // reason.
+            textarea.value = '/kick @annoying guy You\'re annoying';
+            message_form.onFormSubmitted(new Event('submit'));
+
+            await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 2);
+            expect(view.model.setRole).toHaveBeenCalled();
+            expect(Strophe.serialize(sent_IQ)).toBe(
+                `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item nick="annoying guy" role="none">`+
+                            `<reason>You&apos;re annoying</reason>`+
+                        `</item>`+
+                    `</query>`+
+                `</iq>`);
+
+            presence = stx`<presence
+                    from="lounge@montague.lit/annoying guy"
+                    to="romeo@montague.lit/desktop"
+                    type="unavailable"
+                    xmlns="jabber:client">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="none" role="none">
+                            <actor nick="romeo"/>
+                            <reason>You're annoying</reason>
+                            <status code="307"/>
+                        </item>
+                    </x>
+                </presence>`;
+
+            _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
+
+            await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 2);
+            expect(view.querySelectorAll('.chat-info__message')[1].textContent.trim()).toBe("annoying guy has been kicked out by romeo");
+            expect(view.querySelector('.chat-info:last-child q').textContent.trim()).toBe("You're annoying");
+        }));
+
+
+        it("takes /op and /deop to make a user a moderator or not",
+                mock.initConverse([], {}, async function (_converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.chatboxviews.get(muc_jid);
+            let sent_IQ, IQ_id;
+            const sendIQ = _converse.api.connection.get().sendIQ;
+            spyOn(_converse.api.connection.get(), 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_IQ = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
+            spyOn(view.model, 'setRole').and.callThrough();
+            spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
+
+            // New user enters the groupchat
+            _converse.api.connection.get()._dataRecv(mock.createRequest(
+                stx`<presence
+                        from="lounge@montague.lit/trustworthyguy"
+                        id="27C55F89-1C6A-459A-9EB5-77690145D624"
+                        to="romeo@montague.lit/desktop"
+                        xmlns="jabber:client">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item jid="trustworthyguy@montague.lit" affiliation="member" role="participant"/>
+                    </x>
+                </presence>`));
+
+            await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+                "romeo and trustworthyguy have entered the groupchat");
+
+            const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+            textarea.value = '/op';
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+
+            await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count());
+            await u.waitUntil(() => view.querySelector('.message:last-child')?.textContent?.trim() ===
+                "Error: the \"op\" command takes two arguments, the user's nickname and optionally a reason.");
+
+            expect(view.model.setRole).not.toHaveBeenCalled();
+            // Call now with the correct amount of arguments.
+            // XXX: Calling onFormSubmitted directly, trying
+            // again via triggering Event doesn't work for some weird
+            // reason.
+            textarea.value = '/op trustworthyguy You\'re trustworthy';
+            message_form.onFormSubmitted(new Event('submit'));
+
+            await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 2);
+            expect(view.model.setRole).toHaveBeenCalled();
+            expect(Strophe.serialize(sent_IQ)).toBe(
+                `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item nick="trustworthyguy" role="moderator">`+
+                            `<reason>You&apos;re trustworthy</reason>`+
+                        `</item>`+
+                    `</query>`+
+                `</iq>`);
+
+            /* <presence
+             *     from='coven@chat.shakespeare.lit/thirdwitch'
+             *     to='crone1@shakespeare.lit/desktop'>
+             * <x xmlns='http://jabber.org/protocol/muc#user'>
+             *     <item affiliation='member'
+             *         jid='hag66@shakespeare.lit/pda'
+             *         role='moderator'/>
+             * </x>
+             * </presence>
+             */
+            _converse.api.connection.get()._dataRecv(mock.createRequest(
+                stx`<presence
+                        from="lounge@montague.lit/trustworthyguy"
+                        to="romeo@montague.lit/desktop"
+                        xmlns="jabber:client">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item jid="trustworthyguy@montague.lit" affiliation="member" role="moderator"/>
+                    </x>
+                </presence>`));
+            // Check now that things get restored when the user is given a voice
+            await u.waitUntil(
+                () => view.querySelector('.chat-content__notifications').textContent.split('\n', 2).pop()?.trim() ===
+                    "trustworthyguy is now a moderator");
+
+            // Call now with the correct amount of arguments.
+            // XXX: Calling onFormSubmitted directly, trying
+            // again via triggering Event doesn't work for some weird
+            // reason.
+            textarea.value = '/deop trustworthyguy Perhaps not';
+            message_form.onFormSubmitted(new Event('submit'));
+
+            await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 3);
+            expect(view.model.setRole).toHaveBeenCalled();
+            expect(Strophe.serialize(sent_IQ)).toBe(
+                `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item nick="trustworthyguy" role="participant">`+
+                            `<reason>Perhaps not</reason>`+
+                        `</item>`+
+                    `</query>`+
+                `</iq>`);
+
+            _converse.api.connection.get()._dataRecv(mock.createRequest(
+                stx`<presence
+                        from="lounge@montague.lit/trustworthyguy"
+                        to="romeo@montague.lit/desktop"
+                        xmlns="jabber:client">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item jid="trustworthyguy@montague.lit" affiliation="member" role="participant"/>
+                    </x>
+                </presence>`));
+            await u.waitUntil(() => view.querySelector('.chat-content__notifications')
+                .textContent.includes("trustworthyguy is no longer a moderator"));
+        }));
+
+        it("takes /mute and /voice to mute and unmute a user",
+            mock.initConverse([], {}, async function (_converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.chatboxviews.get(muc_jid);
+            var sent_IQ, IQ_id;
+            var sendIQ = _converse.api.connection.get().sendIQ;
+            spyOn(_converse.api.connection.get(), 'sendIQ').and.callFake(function (iq, callback, errback) {
+                sent_IQ = iq;
+                IQ_id = sendIQ.bind(this)(iq, callback, errback);
+            });
+            spyOn(view.model, 'setRole').and.callThrough();
+            spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough();
+
+            // New user enters the groupchat
+            /* <presence
+             *     from='coven@chat.shakespeare.lit/thirdwitch'
+             *     id='27C55F89-1C6A-459A-9EB5-77690145D624'
+             *     to='crone1@shakespeare.lit/desktop'>
+             * <x xmlns='http://jabber.org/protocol/muc#user'>
+             *     <item affiliation='member' role='participant'/>
+             * </x>
+             * </presence>
+             */
+            _converse.api.connection.get()._dataRecv(mock.createRequest(
+                    stx`<presence
+                            from="lounge@montague.lit/annoyingGuy"
+                            id="27C55F89-1C6A-459A-9EB5-77690145D624"
+                            to="romeo@montague.lit/desktop"
+                            xmlns="jabber:client">
+                        <x xmlns="http://jabber.org/protocol/muc#user">
+                            <item jid="annoyingguy@montague.lit" affiliation="member" role="participant"/>
+                        </x>
+                    </presence>`));
+            await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+                "romeo and annoyingGuy have entered the groupchat");
+
+            const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+            textarea.value = '/mute';
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13
+            });
+
+            await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count());
+            await u.waitUntil(() => view.querySelector('.message:last-child')?.textContent?.trim() ===
+                "Error: the \"mute\" command takes two arguments, the user's nickname and optionally a reason.");
+            expect(view.model.setRole).not.toHaveBeenCalled();
+            // Call now with the correct amount of arguments.
+            // XXX: Calling onFormSubmitted directly, trying
+            // again via triggering Event doesn't work for some weird
+            // reason.
+            textarea.value = '/mute annoyingGuy You\'re annoying';
+            message_form.onFormSubmitted(new Event('submit'));
+
+            await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 2)
+            expect(view.model.setRole).toHaveBeenCalled();
+            expect(Strophe.serialize(sent_IQ)).toBe(
+                `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item nick="annoyingGuy" role="visitor">`+
+                            `<reason>You&apos;re annoying</reason>`+
+                        `</item>`+
+                    `</query>`+
+                `</iq>`);
+
+            /* <presence
+             *     from='coven@chat.shakespeare.lit/thirdwitch'
+             *     to='crone1@shakespeare.lit/desktop'>
+             * <x xmlns='http://jabber.org/protocol/muc#user'>
+             *     <item affiliation='member'
+             *         jid='hag66@shakespeare.lit/pda'
+             *         role='visitor'/>
+             * </x>
+             * </presence>
+             */
+            _converse.api.connection.get()._dataRecv(mock.createRequest(stx`<presence
+                    from="lounge@montague.lit/annoyingGuy"
+                    to="romeo@montague.lit/desktop"
+                    xmlns="jabber:client">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item jid="annoyingguy@montague.lit" affiliation="member" role="visitor"/>
+                    </x>
+                </presence>`));
+            await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.includes("annoyingGuy has been muted"));
+
+            // Call now with the correct of arguments.
+            // XXX: Calling onFormSubmitted directly, trying
+            // again via triggering Event doesn't work for some weird
+            // reason.
+            textarea.value = '/voice annoyingGuy Now you can talk again';
+            message_form.onFormSubmitted(new Event('submit'));
+
+            await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 3);
+            expect(view.model.setRole).toHaveBeenCalled();
+            expect(Strophe.serialize(sent_IQ)).toBe(
+                `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#admin">`+
+                        `<item nick="annoyingGuy" role="participant">`+
+                            `<reason>Now you can talk again</reason>`+
+                        `</item>`+
+                    `</query>`+
+                `</iq>`);
+
+            _converse.api.connection.get()._dataRecv(mock.createRequest(
+                stx`<presence
+                        from="lounge@montague.lit/annoyingGuy"
+                        to="romeo@montague.lit/desktop"
+                        xmlns="jabber:client">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item jid="annoyingguy@montague.lit" affiliation="member" role="participant"/>
+                    </x>
+                </presence>`));
+            await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.includes("annoyingGuy has been given a voice"));
+        }));
+
+        it("takes /destroy to destroy a muc",
+                mock.initConverse([], {}, async function (_converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            const new_muc_jid = 'foyer@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            let view = _converse.chatboxviews.get(muc_jid);
+            spyOn(_converse.api, 'confirm').and.callThrough();
+            let textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+            textarea.value = '/destroy';
+            let message_form = view.querySelector('converse-muc-message-form');
+            message_form.onFormSubmitted(new Event('submit'));
+            let modal = await u.waitUntil(() => document.querySelector('.modal-dialog'));
+            await u.waitUntil(() => u.isVisible(modal));
+
+            let challenge_el = modal.querySelector('[name="challenge"]');
+            challenge_el.value = muc_jid+'e';
+            const reason_el = modal.querySelector('[name="reason"]');
+            reason_el.value = 'Moved to a new location';
+            const newjid_el = modal.querySelector('[name="newjid"]');
+            newjid_el.value = new_muc_jid;
+            let submit = modal.querySelector('[type="submit"]');
+            submit.click();
+
+            expect(u.isVisible(modal)).toBeTruthy();
+            expect(u.hasClass('error', challenge_el)).toBeTruthy();
+            challenge_el.value = muc_jid;
+            submit.click();
+
+            let sent_IQs = _converse.api.connection.get().IQ_stanzas;
+            let sent_IQ = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('destroy')).pop());
+            expect(Strophe.serialize(sent_IQ)).toBe(
+                `<iq id="${sent_IQ.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#owner">`+
+                        `<destroy jid="${new_muc_jid}">`+
+                            `<reason>`+
+                                `Moved to a new location`+
+                            `</reason>`+
+                        `</destroy>`+
+                    `</query>`+
+                `</iq>`);
+
+            let result_stanza = stx`<iq type="result"
+                id="${sent_IQ.getAttribute('id')}"
+                from="${view.model.get('jid')}"
+                to="${_converse.api.connection.get().jid}"
+                xmlns="jabber:client"/>`
+            expect(_converse.chatboxes.length).toBe(2);
+            spyOn(_converse.api, "trigger").and.callThrough();
+            _converse.api.connection.get()._dataRecv(mock.createRequest(result_stanza));
+            await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED));
+            await u.waitUntil(() => _converse.chatboxes.length === 1);
+            expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
+
+            // Try again without reason or new JID
+            _converse.api.connection.get().IQ_stanzas = [];
+            sent_IQs = _converse.api.connection.get().IQ_stanzas;
+            await mock.openAndEnterChatRoom(_converse, new_muc_jid, 'romeo');
+            view = _converse.chatboxviews.get(new_muc_jid);
+            textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+            textarea.value = '/destroy';
+            message_form = view.querySelector('converse-muc-message-form');
+            message_form.onFormSubmitted(new Event('submit'));
+            modal = await u.waitUntil(() => document.querySelector('.modal-dialog'));
+            await u.waitUntil(() => u.isVisible(modal));
+
+            challenge_el = modal.querySelector('[name="challenge"]');
+            challenge_el.value = new_muc_jid;
+            submit = modal.querySelector('[type="submit"]');
+            submit.click();
+
+            sent_IQ = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('destroy')).pop());
+            expect(Strophe.serialize(sent_IQ)).toBe(
+                `<iq id="${sent_IQ.getAttribute('id')}" to="${new_muc_jid}" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="http://jabber.org/protocol/muc#owner">`+
+                        `<destroy/>`+
+                    `</query>`+
+                `</iq>`);
+
+            result_stanza = stx`<iq type="result"
+                id="${sent_IQ.getAttribute('id')}"
+                from="${view.model.get('jid')}"
+                to="${_converse.api.connection.get().jid}"
+                xmlns="jabber:client"/>`
+            expect(_converse.chatboxes.length).toBe(2);
+            _converse.api.connection.get()._dataRecv(mock.createRequest(result_stanza));
+            await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED));
+            await u.waitUntil(() => _converse.chatboxes.length === 1);
+        }));
+    });
+});

+ 0 - 1
src/plugins/muc-views/tests/component.js

@@ -60,7 +60,6 @@ describe("The <converse-muc> component", function () {
         span_el.classList.add('conversejs');
         span_el.classList.add('converse-embedded');
 
-
         const muc_el = document.createElement('converse-muc');
         muc_el.classList.add('chatbox');
         muc_el.classList.add('chatroom');

+ 106 - 93
src/plugins/muc-views/tests/corrections.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 
-const { $msg, $pres, Strophe, u, stx } = converse.env;
+const { Strophe, u, stx } = converse.env;
 
 describe("A Groupchat Message", function () {
 
@@ -9,24 +9,26 @@ describe("A Groupchat Message", function () {
 
         const muc_jid = 'lounge@montague.lit';
         const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-        const stanza = $pres({
-                to: 'romeo@montague.lit/_converse.js-29092160',
-                from: 'coven@chat.shakespeare.lit/newguy'
-            })
-            .c('x', {xmlns: Strophe.NS.MUC_USER})
-            .c('item', {
-                'affiliation': 'none',
-                'jid': 'newguy@montague.lit/_converse.js-290929789',
-                'role': 'participant'
-            }).tree();
-        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
+            <presence
+                to="romeo@montague.lit/_converse.js-29092160"
+                from="coven@chat.shakespeare.lit/newguy"
+                xmlns="jabber:client">
+                <x xmlns="${Strophe.NS.MUC_USER}">
+                    <item affiliation="none" jid="newguy@montague.lit/_converse.js-290929789" role="participant"/>
+                </x>
+            </presence>`));
+
         const msg_id = u.getUniqueId();
-        await model.handleMessageStanza($msg({
-                'from': 'lounge@montague.lit/newguy',
-                'to': _converse.api.connection.get().jid,
-                'type': 'groupchat',
-                'id': msg_id,
-            }).c('body').t('But soft, what light through yonder airlock breaks?').tree());
+        await model.handleMessageStanza(stx`
+            <message
+                from="lounge@montague.lit/newguy"
+                to="${_converse.api.connection.get().jid}"
+                type="groupchat"
+                id="${msg_id}"
+                xmlns="jabber:client">
+                <body>But soft, what light through yonder airlock breaks?</body>
+            </message>`);
 
         const view = _converse.chatboxviews.get(muc_jid);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
@@ -34,25 +36,32 @@ describe("A Groupchat Message", function () {
         expect(view.querySelector('.chat-msg__text').textContent)
             .toBe('But soft, what light through yonder airlock breaks?');
 
-        await view.model.handleMessageStanza($msg({
-                'from': 'lounge@montague.lit/newguy',
-                'to': _converse.api.connection.get().jid,
-                'type': 'groupchat',
-                'id': u.getUniqueId(),
-            }).c('body').t('But soft, what light through yonder chimney breaks?').up()
-                .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+        await view.model.handleMessageStanza(stx`
+            <message
+                from="lounge@montague.lit/newguy"
+                to="${_converse.api.connection.get().jid}"
+                type="groupchat"
+                id="${u.getUniqueId()}"
+                xmlns="jabber:client">
+                <body>But soft, what light through yonder chimney breaks?</body>
+                <replace id="${msg_id}" xmlns="urn:xmpp:message-correct:0"/>
+            </message>`);
+
         await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent ===
             'But soft, what light through yonder chimney breaks?', 500);
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         await u.waitUntil(() => view.querySelector('.chat-msg__content .fa-edit'));
 
-        await view.model.handleMessageStanza($msg({
-                'from': 'lounge@montague.lit/newguy',
-                'to': _converse.api.connection.get().jid,
-                'type': 'groupchat',
-                'id': u.getUniqueId(),
-            }).c('body').t('But soft, what light through yonder window breaks?').up()
-                .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+        await view.model.handleMessageStanza(stx`
+            <message
+                from="lounge@montague.lit/newguy"
+                to="${_converse.api.connection.get().jid}"
+                type="groupchat"
+                id="${u.getUniqueId()}"
+                xmlns="jabber:client">
+                <body>But soft, what light through yonder window breaks?</body>
+                <replace id="${msg_id}" xmlns="urn:xmpp:message-correct:0"/>
+            </message>`);
 
         await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent ===
             'But soft, what light through yonder window breaks?', 500);
@@ -74,72 +83,74 @@ describe("A Groupchat Message", function () {
         const muc_jid = 'lounge@montague.lit';
         await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
         const view = _converse.chatboxviews.get(muc_jid);
-        const stanza = $pres({
-                to: 'romeo@montague.lit/_converse.js-29092160',
-                from: 'coven@chat.shakespeare.lit/newguy'
-            })
-            .c('x', {xmlns: Strophe.NS.MUC_USER})
-            .c('item', {
-                'affiliation': 'none',
-                'jid': 'newguy@montague.lit/_converse.js-290929789',
-                'role': 'participant'
-            }).tree();
-        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
+            <presence
+                to="romeo@montague.lit/_converse.js-29092160"
+                from="coven@chat.shakespeare.lit/newguy"
+                xmlns="jabber:client">
+                <x xmlns="${Strophe.NS.MUC_USER}">
+                    <item affiliation="none" jid="newguy@montague.lit/_converse.js-290929789" role="participant"/>
+                </x>
+            </presence>`));
+
         const msg_id = u.getUniqueId();
 
         // Receiving the first message
-        await view.model.handleMessageStanza($msg({
-                'from': 'lounge@montague.lit/newguy',
-                'to': _converse.api.connection.get().jid,
-                'type': 'groupchat',
-                'id': msg_id,
-            }).c('body').t('But soft, what light through yonder airlock breaks?').tree());
+        await view.model.handleMessageStanza(stx`
+            <message
+                xmlns="jabber:client"
+                from="lounge@montague.lit/newguy"
+                to="${_converse.api.connection.get().jid}"
+                type="groupchat"
+                id="${msg_id}">
+                <body>But soft, what light through yonder airlock breaks?</body>
+            </message>`);
 
         // Receiving own message to check order against
-        await view.model.handleMessageStanza($msg({
-            'from': 'lounge@montague.lit/romeo',
-            'to': _converse.api.connection.get().jid,
-            'type': 'groupchat',
-            'id': u.getUniqueId(),
-        }).c('body').t('But soft, what light through yonder airlock breaks?').tree());
-
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
-        expect(view.querySelectorAll('.chat-msg').length).toBe(2);
-        expect(view.querySelectorAll('.chat-msg__text')[0].textContent)
-            .toBe('But soft, what light through yonder airlock breaks?');
-        expect(view.querySelectorAll('.chat-msg__text')[1].textContent)
-        .toBe('But soft, what light through yonder airlock breaks?');
+        await view.model.handleMessageStanza(stx`
+            <message
+                xmlns="jabber:client"
+                from="lounge@montague.lit/romeo"
+                to="${_converse.api.connection.get().jid}"
+                type="groupchat"
+                id="${u.getUniqueId()}">
+                <body>But soft, what light through yonder airlock breaks?</body>
+            </message>`);
 
         // First message correction
-        await view.model.handleMessageStanza($msg({
-                'from': 'lounge@montague.lit/newguy',
-                'to': _converse.api.connection.get().jid,
-                'type': 'groupchat',
-                'id': u.getUniqueId(),
-            }).c('body').t('But soft, what light through yonder chimney breaks?').up()
-                .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
-
-        await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent ===
-            'But soft, what light through yonder chimney breaks?', 500);
-        expect(view.querySelectorAll('.chat-msg').length).toBe(2);
-        await u.waitUntil(() => view.querySelector('.chat-msg__content .fa-edit'));
+        await view.model.handleMessageStanza(stx`
+            <message
+                xmlns="jabber:client"
+                from="lounge@montague.lit/newguy"
+                to="${_converse.api.connection.get().jid}"
+                type="groupchat"
+                id="${u.getUniqueId()}">
+                <body>But soft, what light through yonder chimney breaks?</body>
+                <replace id="${msg_id}" xmlns="urn:xmpp:message-correct:0"/>
+            </message>`);
 
         // Second message correction
-        await view.model.handleMessageStanza($msg({
-                'from': 'lounge@montague.lit/newguy',
-                'to': _converse.api.connection.get().jid,
-                'type': 'groupchat',
-                'id': u.getUniqueId(),
-            }).c('body').t('But soft, what light through yonder window breaks?').up()
-                .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+        await view.model.handleMessageStanza(stx`
+            <message
+                xmlns="jabber:client"
+                from="lounge@montague.lit/newguy"
+                to="${_converse.api.connection.get().jid}"
+                type="groupchat"
+                id="${u.getUniqueId()}">
+                <body>But soft, what light through yonder window breaks?</body>
+                <replace id="${msg_id}" xmlns="urn:xmpp:message-correct:0"/>
+            </message>`);
 
         // Second own message
-        await view.model.handleMessageStanza($msg({
-            'from': 'lounge@montague.lit/romeo',
-            'to': _converse.api.connection.get().jid,
-            'type': 'groupchat',
-            'id': u.getUniqueId(),
-        }).c('body').t('But soft, what light through yonder window breaks?').tree());
+        await view.model.handleMessageStanza(stx`
+            <message
+                xmlns="jabber:client"
+                from="lounge@montague.lit/romeo"
+                to="${_converse.api.connection.get().jid}"
+                type="groupchat"
+                id="${u.getUniqueId()}">
+                <body>But soft, what light through yonder window breaks?</body>
+            </message>`);
 
         await u.waitUntil(() => view.querySelectorAll('.chat-msg__text')[0].textContent ===
             'But soft, what light through yonder window breaks?', 500);
@@ -232,12 +243,14 @@ describe("A Groupchat Message", function () {
         expect(u.hasClass('correcting', view.querySelector('.chat-msg'))).toBe(false);
 
         // Check that messages from other users are skipped
-        await view.model.handleMessageStanza($msg({
-            'from': muc_jid+'/someone-else',
-            'id': u.getUniqueId(),
-            'to': 'romeo@montague.lit',
-            'type': 'groupchat'
-        }).c('body').t('Hello world').tree());
+        await view.model.handleMessageStanza(stx`
+            <message from="${muc_jid}/someone-else"
+                    id="${u.getUniqueId()}"
+                    to="romeo@montague.lit"
+                    type="groupchat"
+                    xmlns="jabber:client">
+                <body>Hello world</body>
+            </message>`);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
         expect(view.querySelectorAll('.chat-msg').length).toBe(2);
 

+ 266 - 0
src/plugins/muc-views/tests/csn.js

@@ -0,0 +1,266 @@
+/*global mock, converse */
+
+const { Strophe, stx, u }  = converse.env;
+
+describe("Groupchats", function () {
+    describe("A XEP-0085 Chat Status Notification", function () {
+
+        it("is is not sent out to a MUC if the user is a visitor in a moderated room",
+            mock.initConverse(
+                ['chatBoxesFetched'], {},
+                async function (_converse) {
+
+            spyOn(_converse.ChatRoom.prototype, 'sendChatState').and.callThrough();
+
+            const muc_jid = 'lounge@montague.lit';
+            const features = [
+                'http://jabber.org/protocol/muc',
+                'jabber:iq:register',
+                'muc_passwordprotected',
+                'muc_hidden',
+                'muc_temporary',
+                'muc_membersonly',
+                'muc_moderated',
+                'muc_anonymous'
+            ]
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+
+            const view = _converse.chatboxviews.get(muc_jid);
+            view.model.setChatState(_converse.ACTIVE);
+
+            expect(view.model.sendChatState).toHaveBeenCalled();
+            const last_stanza = _converse.api.connection.get().sent_stanzas.pop();
+            expect(Strophe.serialize(last_stanza)).toBe(
+                `<message to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">`+
+                    `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                    `<no-store xmlns="urn:xmpp:hints"/>`+
+                    `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+                `</message>`);
+
+            // Romeo loses his voice
+            _converse.api.connection.get()._dataRecv(
+                mock.createRequest(
+                    stx`<presence
+                            xmlns="jabber:client"
+                            to="romeo@montague.lit/orchard"
+                            from="${muc_jid}/romeo">
+                        <x xmlns="${Strophe.NS.MUC_USER}">
+                            <item affiliation="none" role="visitor"/>
+                            <status code="110"/>
+                        </x>
+                    </presence>`)
+            );
+
+            const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});
+            await u.waitUntil(() => occupant.get('role') === 'visitor');
+
+            spyOn(_converse.api.connection.get(), 'send');
+            view.model.setChatState(_converse.INACTIVE);
+            expect(view.model.sendChatState.calls.count()).toBe(2);
+            expect(_converse.api.connection.get().send).not.toHaveBeenCalled();
+        }));
+
+
+        describe("A composing notification", function () {
+
+            it("will be shown if received", mock.initConverse([], {}, async function (_converse) {
+                const muc_jid = 'coven@chat.shakespeare.lit';
+                const members = [
+                    {'affiliation': 'member', 'nick': 'majortom', 'jid': 'majortom@example.org'},
+                    {'affiliation': 'admin', 'nick': 'groundcontrol', 'jid': 'groundcontrol@example.org'}
+                ];
+                await mock.openAndEnterChatRoom(_converse, muc_jid, 'some1', [], members);
+                const view = _converse.chatboxviews.get(muc_jid);
+
+                let csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
+                expect(csntext.trim()).toEqual("some1 has entered the groupchat");
+
+                _converse.api.connection.get()._dataRecv(mock.createRequest(
+                    stx`<presence to="romeo@montague.lit/_converse.js-29092160" from="coven@chat.shakespeare.lit/newguy" xmlns="jabber:client">
+                        <x xmlns="${Strophe.NS.MUC_USER}">
+                            <item affiliation="none" jid="newguy@montague.lit/_converse.js-290929789" role="participant"/>
+                        </x>
+                    </presence>`));
+                await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+                    "some1 and newguy have entered the groupchat");
+
+                _converse.api.connection.get()._dataRecv(mock.createRequest(
+                    stx`<presence to="romeo@montague.lit/_converse.js-29092160" from="coven@chat.shakespeare.lit/nomorenicks" xmlns="jabber:client">
+                        <x xmlns="${Strophe.NS.MUC_USER}">
+                            <item affiliation="none" jid="nomorenicks@montague.lit/_converse.js-290929789" role="participant"/>
+                        </x>
+                    </presence>`));
+                await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+                    "some1, newguy and nomorenicks have entered the groupchat", 1000);
+
+                // Manually clear so that we can more easily test
+                view.model.notifications.set('entered', []);
+                await u.waitUntil(() => !view.querySelector('.chat-content__notifications').textContent, 1000);
+
+                // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
+
+                const remove_notifications_timeouts = [];
+                const setTimeout = window.setTimeout;
+                spyOn(window, 'setTimeout').and.callFake((f, w) => {
+                    if (f.toString() === "() => this.removeNotification(actor, state)") {
+                        remove_notifications_timeouts.push(f)
+                    }
+                    setTimeout(f, w);
+                });
+
+                // <composing> state
+                let msg = stx`<message from="${muc_jid}/newguy" id="${u.getUniqueId()}" to="romeo@montague.lit" type="groupchat" xmlns="jabber:client">
+                        <body>
+                            <composing xmlns="${Strophe.NS.CHATSTATES}"/>
+                        </body>
+                    </message>`;
+                _converse.api.connection.get()._dataRecv(mock.createRequest(msg));
+
+                csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent, 1000);
+                expect(csntext.trim()).toEqual('newguy is typing');
+                expect(remove_notifications_timeouts.length).toBe(1);
+                expect(view.querySelector('.chat-content__notifications').textContent.trim()).toEqual('newguy is typing');
+
+                msg = stx`<message from="${muc_jid}/nomorenicks"
+                            id="${u.getUniqueId()}"
+                            to="romeo@montague.lit"
+                            type="groupchat"
+                            xmlns="jabber:client">
+                        <body>
+                            <composing xmlns="${Strophe.NS.CHATSTATES}"/>
+                        </body>
+                    </message>`;
+                await view.model.handleMessageStanza(msg.tree());
+                await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === 'newguy and nomorenicks are typing', 1000);
+
+                msg = stx`<message from="${muc_jid}/majortom"
+                            id="${u.getUniqueId()}"
+                            to="romeo@montague.lit"
+                            type="groupchat"
+                            xmlns="jabber:client">
+                        <body>
+                            <composing xmlns="${Strophe.NS.CHATSTATES}"/>
+                         </body>
+                    </message>`;
+                await view.model.handleMessageStanza(msg.tree());
+                await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === 'newguy, nomorenicks and majortom are typing', 1000);
+
+                msg = stx`<message from="${muc_jid}/groundcontrol"
+                            id="${u.getUniqueId()}"
+                            to="romeo@montague.lit"
+                            type="groupchat"
+                            xmlns="jabber:client">
+                        <body>
+                            <composing xmlns="${Strophe.NS.CHATSTATES}"/>
+                        </body>
+                    </message>`;
+                await view.model.handleMessageStanza(msg.tree());
+                await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === 'newguy, nomorenicks and others are typing', 1000);
+
+                msg = stx`<message from="${muc_jid}/some1"
+                            id="${u.getUniqueId()}"
+                            to="romeo@montague.lit"
+                            type="groupchat"
+                            xmlns="jabber:client">
+                        <body>hello world</body>
+                    </message>`;
+                await view.model.handleMessageStanza(msg.tree());
+
+                await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+                expect(view.querySelector('.chat-msg .chat-msg__text').textContent.trim()).toBe('hello world');
+
+                // Test that the composing notifications get removed via timeout.
+                if (remove_notifications_timeouts.length) {
+                    remove_notifications_timeouts[0]();
+                }
+                await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === 'nomorenicks, majortom and groundcontrol are typing', 1000);
+            }));
+        });
+
+        describe("A paused notification", function () {
+
+            it("will be shown if received", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+                const muc_jid = 'coven@chat.shakespeare.lit';
+                await mock.openAndEnterChatRoom(_converse, muc_jid, 'some1');
+                const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
+
+                /* <presence to="romeo@montague.lit/_converse.js-29092160"
+                 *           from="coven@chat.shakespeare.lit/some1">
+                 *      <x xmlns="http://jabber.org/protocol/muc#user">
+                 *          <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/>
+                 *          <status code="110"/>
+                 *      </x>
+                 *  </presence></body>
+                 */
+                _converse.api.connection.get()._dataRecv(mock.createRequest(stx`<presence
+                            to="romeo@montague.lit/_converse.js-29092160"
+                            from="coven@chat.shakespeare.lit/some1"
+                            xmlns="jabber:client">
+                        <x xmlns="${Strophe.NS.MUC_USER}">
+                            <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/>
+                        </x>
+                        <status code="110"/>
+                    </presence>`));
+                const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
+                expect(csntext.trim()).toEqual("some1 has entered the groupchat");
+
+                _converse.api.connection.get()._dataRecv(mock.createRequest(stx`<presence
+                            to="romeo@montague.lit/_converse.js-29092160"
+                            from="coven@chat.shakespeare.lit/newguy"
+                            xmlns="jabber:client">
+                        <x xmlns="${Strophe.NS.MUC_USER}">
+                            <item affiliation="none" jid="newguy@montague.lit/_converse.js-290929789" role="participant"/>
+                        </x>
+                    </presence>`));
+                await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === "some1 and newguy have entered the groupchat");
+
+                _converse.api.connection.get()._dataRecv(mock.createRequest(stx`<presence
+                            to="romeo@montague.lit/_converse.js-29092160"
+                            from="coven@chat.shakespeare.lit/nomorenicks"
+                            xmlns="jabber:client">
+                        <x xmlns="${Strophe.NS.MUC_USER}">
+                            <item affiliation="none" jid="nomorenicks@montague.lit/_converse.js-290929789" role="participant"/>
+                        </x>
+                    </presence>`));
+
+                await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+                    "some1, newguy and nomorenicks have entered the groupchat");
+
+                // Manually clear so that we can more easily test
+                view.model.notifications.set('entered', []);
+                await u.waitUntil(() => !view.querySelector('.chat-content__notifications').textContent);
+
+                // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
+
+                // <composing> state
+                let msg = stx`<message from="${muc_jid}/newguy" id="${u.getUniqueId()}" to="romeo@montague.lit" type="groupchat" xmlns="jabber:client">
+                        <body>
+                            <composing xmlns="${Strophe.NS.CHATSTATES}"/>
+                        </body>
+                    </message>`;
+                await view.model.handleMessageStanza(msg.tree());
+                await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
+                expect(view.querySelector('.chat-content__notifications').textContent.trim()).toBe('newguy is typing');
+
+                // <composing> state for a different occupant
+                msg = stx`<message from="${muc_jid}/nomorenicks" id="${u.getUniqueId()}" to="romeo@montague.lit" type="groupchat" xmlns="jabber:client">
+                        <body>
+                            <composing xmlns="${Strophe.NS.CHATSTATES}"/>
+                        </body>
+                    </message>`;
+                await view.model.handleMessageStanza(msg.tree());
+
+                await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim()  == 'newguy and nomorenicks are typing');
+
+                // <paused> state from occupant who typed first
+                msg = stx`<message from="${muc_jid}/newguy" id="${u.getUniqueId()}" to="romeo@montague.lit" type="groupchat" xmlns="jabber:client">
+                        <body>
+                            <paused xmlns="${Strophe.NS.CHATSTATES}"/>
+                        </body>
+                    </message>`;
+                await view.model.handleMessageStanza(msg.tree());
+                await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim()  == 'nomorenicks is typing\nnewguy has stopped typing');
+            }));
+        });
+    });
+});

+ 37 - 28
src/plugins/muc-views/tests/disco.js

@@ -1,9 +1,10 @@
-/*global mock, converse */
+/* global mock, converse */
+
+const { Strophe, u, stx } = converse.env;
 
 describe("Service Discovery", function () {
 
     it("can be used to set the muc_domain", mock.initConverse( ['discoInitialized'], {}, async function (_converse) {
-        const { u, $iq } = converse.env;
         const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
         const IQ_ids =  _converse.api.connection.get().IQ_ids;
         const { api } = _converse;
@@ -16,42 +17,50 @@ describe("Service Discovery", function () {
 
         let stanza = IQ_stanzas.find((iq) => iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]'));
         const info_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
-        stanza = $iq({
-            'type': 'result',
-            'from': 'montague.lit',
-            'to': 'romeo@montague.lit/orchard',
-            'id': info_IQ_id
-        }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
-            .c('identity', { 'category': 'server', 'type': 'im'}).up()
-            .c('identity', { 'category': 'conference', 'name': 'Play-Specific Chatrooms'}).up()
-            .c('feature', { 'var': 'http://jabber.org/protocol/disco#info'}).up()
-            .c('feature', { 'var': 'http://jabber.org/protocol/disco#items'}).up();
-        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
+        _converse.api.connection.get()._dataRecv(mock.createRequest(
+            stx`<iq type="result"
+                    from="montague.lit"
+                    to="romeo@montague.lit/orchard"
+                    id="${info_IQ_id}"
+                    xmlns="jabber:client">
+                <query xmlns="http://jabber.org/protocol/disco#info">
+                    <identity category="server" type="im"/>
+                    <identity category="conference" name="Play-Specific Chatrooms"/>
+                    <feature var="http://jabber.org/protocol/disco#info"/>
+                    <feature var="http://jabber.org/protocol/disco#items"/>
+                </query>
+            </iq>`));
 
         stanza = await u.waitUntil(() => IQ_stanzas.filter(
             iq => iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]')).pop()
         );
 
-        _converse.api.connection.get()._dataRecv(mock.createRequest($iq({
-            'type': 'result',
-            'from': 'montague.lit',
-            'to': 'romeo@montague.lit/orchard',
-            'id': IQ_ids[IQ_stanzas.indexOf(stanza)]
-        }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'})
-            .c('item', { 'jid': 'chat.shakespeare.lit', 'name': 'Chatroom Service'})));
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
+            <iq type="result"
+                    from="montague.lit"
+                    to="romeo@montague.lit/orchard"
+                    id="${IQ_ids[IQ_stanzas.indexOf(stanza)]}"
+                    xmlns="jabber:client">
+                <query xmlns="http://jabber.org/protocol/disco#items">
+                    <item jid="chat.shakespeare.lit" name="Chatroom Service"/>
+                </query>
+            </iq>`));
 
         stanza = await u.waitUntil(() => IQ_stanzas.filter(
             iq => iq.querySelector('iq[to="chat.shakespeare.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]')).pop()
         );
-        _converse.api.connection.get()._dataRecv(mock.createRequest($iq({
-            'type': 'result',
-            'from': 'chat.shakespeare.lit',
-            'to': 'romeo@montague.lit/orchard',
-            'id': IQ_ids[IQ_stanzas.indexOf(stanza)]
-        }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
-            .c('identity', { 'category': 'conference', 'name': 'Play-Specific Chatrooms', 'type': 'text'}).up()
-            .c('feature', { 'var': 'http://jabber.org/protocol/muc'}).up()));
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
+            <iq type="result"
+                    from="chat.shakespeare.lit"
+                    to="romeo@montague.lit/orchard"
+                    id="${IQ_ids[IQ_stanzas.indexOf(stanza)]}"
+                    xmlns="jabber:client">
+                <query xmlns="http://jabber.org/protocol/disco#info">
+                    <identity category="conference" name="Play-Specific Chatrooms" type="text"/>
+                    <feature var="http://jabber.org/protocol/muc"/>
+                </query>
+            </iq>`));
 
         const entities = await _converse.api.disco.entities.get();
         expect(entities.length).toBe(3); // We have an extra entity, which is the user's JID

+ 11 - 12
src/plugins/muc-views/tests/emojis.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 
-const { $pres, sizzle } = converse.env;
+const { sizzle, stx } = converse.env;
 const u = converse.env.utils;
 
 describe("Emojis", function () {
@@ -53,17 +53,16 @@ describe("Emojis", function () {
             await u.waitUntil(() => textarea.value === ':grimacing: ');
 
             // Test that username starting with : doesn't cause issues
-            const presence = $pres({
-                    'from': `${muc_jid}/:username`,
-                    'id': '27C55F89-1C6A-459A-9EB5-77690145D624',
-                    'to': _converse.jid
-                })
-                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                    .c('item', {
-                        'jid': 'some1@montague.lit',
-                        'affiliation': 'member',
-                        'role': 'participant'
-                    });
+            const presence = stx`
+                <presence
+                        from="${muc_jid}/:username"
+                        id="27C55F89-1C6A-459A-9EB5-77690145D624"
+                        to="${_converse.jid}"
+                        xmlns="jabber:client">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item jid="some1@montague.lit" affiliation="member" role="participant"/>
+                    </x>
+                </presence>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
 
             textarea.value = ':use';

+ 23 - 11
src/plugins/muc-views/tests/hats.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 
-const u = converse.env.utils;
+const { u, stx } = converse.env;
 
 describe("A XEP-0317 MUC Hat", function () {
 
@@ -9,10 +9,22 @@ describe("A XEP-0317 MUC Hat", function () {
         const muc_jid = 'lounge@montague.lit';
         await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
         const view = _converse.chatboxviews.get(muc_jid);
+
+
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
+            <presence
+                to="romeo@montague.lit/_converse.js-29092160"
+                from="coven@chat.shakespeare.lit/newguy"
+                xmlns="jabber:client">
+                <x xmlns="${Strophe.NS.MUC_USER}">
+                    <item affiliation="none" jid="newguy@montague.lit/_converse.js-290929789" role="participant"/>
+                </x>
+            </presence>`));
+
         const hat1_id = u.getUniqueId();
         const hat2_id = u.getUniqueId();
-        _converse.api.connection.get()._dataRecv(mock.createRequest(u.toStanza(`
-            <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}">
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
+            <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}" xmlns="jabber:client">
                 <x xmlns="http://jabber.org/protocol/muc#user">
                     <item affiliation="member" role="participant"/>
                 </x>
@@ -20,8 +32,8 @@ describe("A XEP-0317 MUC Hat", function () {
                     <hat title="Teacher&apos;s Assistant" id="${hat1_id}"/>
                     <hat title="Dark Mage" id="${hat2_id}"/>
                 </hats>
-            </presence>
-        `)));
+            </presence>`));
+
         await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
             "romeo and Terry have entered the groupchat");
 
@@ -41,8 +53,8 @@ describe("A XEP-0317 MUC Hat", function () {
         expect(badges.map(b => b.textContent.trim()).join(' ' )).toBe("Teacher's Assistant Dark Mage");
 
         const hat3_id = u.getUniqueId();
-        _converse.api.connection.get()._dataRecv(mock.createRequest(u.toStanza(`
-            <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}">
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
+            <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}" xmlns="jabber:client">
                 <x xmlns="http://jabber.org/protocol/muc#user">
                     <item affiliation="member" role="participant"/>
                 </x>
@@ -52,7 +64,7 @@ describe("A XEP-0317 MUC Hat", function () {
                     <hat title="Mad hatter" id="${hat3_id}"/>
                 </hats>
             </presence>
-        `)));
+        `));
 
         await u.waitUntil(() => view.model.getOccupant("Terry").get('hats').length === 3);
         hats = view.model.getOccupant("Terry").get('hats');
@@ -61,13 +73,13 @@ describe("A XEP-0317 MUC Hat", function () {
         badges = Array.from(view.querySelectorAll('.chat-msg .badge'));
         expect(badges.map(b => b.textContent.trim()).join(' ' )).toBe("Teacher's Assistant Dark Mage Mad hatter");
 
-        _converse.api.connection.get()._dataRecv(mock.createRequest(u.toStanza(`
-            <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}">
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
+            <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}" xmlns="jabber:client">
                 <x xmlns="http://jabber.org/protocol/muc#user">
                     <item affiliation="member" role="participant"/>
                 </x>
             </presence>
-        `)));
+        `));
         await u.waitUntil(() => view.model.getOccupant("Terry").get('hats').length === 0);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg .badge').length === 0);
     }));

+ 12 - 11
src/plugins/muc-views/tests/http-file-upload.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 
-const { Strophe, sizzle, u } = converse.env;
+const { Strophe, sizzle, u, stx } = converse.env;
 
 
 describe("XEP-0363: HTTP File Upload", function () {
@@ -87,19 +87,20 @@ describe("XEP-0363: HTTP File Upload", function () {
                         `</iq>`);
 
                     const message = base_url+"/logo/conversejs-filled.svg";
-                    const stanza = u.toStanza(`
-                        <iq from='upload.montague.tld'
-                            id="${iq.getAttribute('id')}"
-                            to='romeo@montague.lit/orchard'
-                            type='result'>
-                        <slot xmlns='urn:xmpp:http:upload:0'>
-                            <put url='https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg'>
-                            <header name='Authorization'>Basic Base64String==</header>
-                            <header name='Cookie'>foo=bar; user=romeo</header>
+                    const stanza = stx`
+                        <iq from="upload.montague.tld"
+                            id="${iq.getAttribute("id")}"
+                            to="romeo@montague.lit/orchard"
+                            type="result"
+                            xmlns="jabber:client">
+                        <slot xmlns="urn:xmpp:http:upload:0">
+                            <put url="https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg">
+                                <header name="Authorization">Basic Base64String==</header>
+                                <header name="Cookie">foo=bar; user=romeo</header>
                             </put>
                             <get url="${message}" />
                         </slot>
-                        </iq>`);
+                        </iq>`.tree();
 
                     spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async function () {
                         const message = view.model.messages.at(0);

+ 7 - 10
src/plugins/muc-views/tests/info-messages.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 
-const u = converse.env.utils;
+const { u, stx } = converse.env;
 
 describe("an info message", function () {
 
@@ -11,27 +11,25 @@ describe("an info message", function () {
         const nick = 'romeo';
         await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
         const view = _converse.chatboxviews.get(muc_jid);
-        let presence = u.toStanza(`
+        let presence = stx`
             <presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo">
                 <x xmlns="http://jabber.org/protocol/muc#user">
                     <status code="201"/>
                     <item role="moderator" affiliation="owner" jid="${_converse.jid}"/>
                     <status code="110"/>
                 </x>
-            </presence>
-        `);
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
         await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1);
 
-        presence = u.toStanza(`
+        presence = stx`
             <presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo1">
                 <x xmlns="http://jabber.org/protocol/muc#user">
                     <status code="210"/>
                     <item role="moderator" affiliation="owner" jid="${_converse.jid}"/>
                     <status code="110"/>
                 </x>
-            </presence>
-        `);
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
         await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 2);
 
@@ -46,15 +44,14 @@ describe("an info message", function () {
         const muc_jid = 'lounge@montague.lit';
         await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
         const view = _converse.chatboxviews.get(muc_jid);
-        const presence = u.toStanza(`
+        const presence = stx`
             <presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo">
                 <x xmlns="http://jabber.org/protocol/muc#user">
                     <status code="201"/>
                     <item role="moderator" affiliation="owner" jid="${_converse.jid}"/>
                     <status code="110"/>
                 </x>
-            </presence>
-        `);
+            </presence>`;
         // XXX: We wait for createInfoMessages to complete, if we don't
         // we still get two info messages due to messages
         // created from presences not being queued and run

+ 41 - 56
src/plugins/muc-views/tests/mam.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 
-const { Strophe, $msg, $pres } = converse.env;
+const { Strophe, stx } = converse.env;
 const u = converse.env.utils;
 
 describe("A MAM archived message", function () {
@@ -13,8 +13,7 @@ describe("A MAM archived message", function () {
         const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
 
         const messages = [
-            u.toStanza(`
-                <message to="${_converse.api.connection.get().jid}" from="${muc_jid}">
+            stx`<message to="${_converse.api.connection.get().jid}" from="${muc_jid}" xmlns="jabber:server">
                     <result xmlns="urn:xmpp:mam:2" queryid="c03f0f53-8501-4ed9-9261-2eddd055486c" id="9fe1a9d9-c979-488c-93a4-8a3c4dcbc63e">
                         <forwarded xmlns="urn:xmpp:forward:0">
                             <delay xmlns="urn:xmpp:delay" stamp="2021-10-13T17:51:20Z"/>
@@ -25,10 +24,9 @@ describe("A MAM archived message", function () {
                             </message>
                         </forwarded>
                     </result>
-                </message>`),
+                </message>`,
 
-            u.toStanza(`
-                <message to="${_converse.api.connection.get().jid}" from="${muc_jid}">
+            stx`<message to="${_converse.api.connection.get().jid}" from="${muc_jid}" xmlns="jabber:server">
                     <result xmlns="urn:xmpp:mam:2" queryid="c03f0f53-8501-4ed9-9261-2eddd055486c" id="64f68d52-76e6-4fa6-93ef-9fbf96bb237b">
                         <forwarded xmlns="urn:xmpp:forward:0">
                             <delay xmlns="urn:xmpp:delay" stamp="2021-10-13T17:51:25Z"/>
@@ -39,10 +37,9 @@ describe("A MAM archived message", function () {
                             </message>
                         </forwarded>
                     </result>
-                </message>`),
+                </message>`,
 
-            u.toStanza(`
-                <message to="${_converse.api.connection.get().jid}" from="${muc_jid}">
+            stx`<message to="${_converse.api.connection.get().jid}" from="${muc_jid}" xmlns="jabber:server">
                     <result xmlns="urn:xmpp:mam:2" queryid="c03f0f53-8501-4ed9-9261-2eddd055486c" id="c2c07703-b285-4529-a4b4-12594f749c58">
                         <forwarded xmlns="urn:xmpp:forward:0">
                             <delay xmlns="urn:xmpp:delay" stamp="2021-10-13T17:52:17Z"/>
@@ -64,10 +61,9 @@ describe("A MAM archived message", function () {
                             </message>
                         </forwarded>
                     </result>
-                </message>`),
+                </message>`,
 
-            u.toStanza(`
-                <message to="${_converse.api.connection.get().jid}" from="${muc_jid}">
+            stx`<message to="${_converse.api.connection.get().jid}" from="${muc_jid}" xmlns="jabber:server">
                     <result xmlns="urn:xmpp:mam:2" queryid="c03f0f53-8501-4ed9-9261-2eddd055486c" id="c2b2b039-f808-4b4c-bfbd-607173e012f9">
                         <forwarded xmlns="urn:xmpp:forward:0">
                             <delay xmlns="urn:xmpp:delay" stamp="2021-10-13T17:52:22Z"/>
@@ -78,10 +74,10 @@ describe("A MAM archived message", function () {
                             </message>
                         </forwarded>
                     </result>
-                </message>`)
+                </message>`
         ]
         spyOn(model, 'updateMessage');
-        _converse.handleMAMResult(model, { messages });
+        _converse.handleMAMResult(model, { messages: messages.map((m) => m.tree()) });
 
         await u.waitUntil(() => model.messages.length === 4);
         expect(model.messages.at(0).get('time')).toBe('2021-10-13T17:51:20.000Z');
@@ -96,7 +92,7 @@ describe("A MAM archived message", function () {
         const muc_jid = 'room@muc.example.com';
         const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
         spyOn(model, 'getDuplicateMessage').and.callThrough();
-        let stanza = u.toStanza(`
+        let stanza = stx`
             <message xmlns="jabber:client"
                      from="room@muc.example.com/some1"
                      to="${_converse.api.connection.get().jid}"
@@ -105,14 +101,14 @@ describe("A MAM archived message", function () {
                 <stanza-id xmlns="urn:xmpp:sid:0"
                            id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
                            by="room@muc.example.com"/>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         await u.waitUntil(() => model.messages.length === 1);
         await u.waitUntil(() => model.getDuplicateMessage.calls.count() === 1);
         let result = await model.getDuplicateMessage.calls.all()[0].returnValue;
         expect(result).toBe(undefined);
 
-        stanza = u.toStanza(`
+        stanza = stx`
             <message xmlns="jabber:client"
                     to="${_converse.api.connection.get().jid}"
                     from="room@muc.example.com">
@@ -124,10 +120,10 @@ describe("A MAM archived message", function () {
                         </message>
                     </forwarded>
                 </result>
-            </message>`);
+            </message>`;
 
         spyOn(model, 'updateMessage');
-        _converse.handleMAMResult(model, { 'messages': [stanza] });
+        _converse.handleMAMResult(model, { 'messages': [stanza.tree()] });
         await u.waitUntil(() => model.getDuplicateMessage.calls.count() === 2);
         result = await model.getDuplicateMessage.calls.all()[1].returnValue;
         expect(result instanceof _converse.Message).toBe(true);
@@ -144,45 +140,34 @@ describe("A MAM archived message", function () {
         const sender_jid = `${muc_jid}/romeo`;
         const impersonated_jid = `${muc_jid}/i_am_groot`
         const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-        const stanza = $pres({
-                to: 'romeo@montague.lit/_converse.js-29092160',
-                from: sender_jid
-            })
-            .c('x', {xmlns: Strophe.NS.MUC_USER})
-            .c('item', {
-                'affiliation': 'owner',
-                'jid': 'newguy@montague.lit/_converse.js-290929789',
-                'role': 'participant'
-            }).tree();
-        _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
-        /*
-         * <message to="romeo@montague.im/poezio" id="718d40df-3948-4798-a99b-35cc9f03cc4f-641" type="groupchat" from="xsf@muc.xmpp.org/romeo">
-         *     <received xmlns="urn:xmpp:carbons:2">
-         *         <forwarded xmlns="urn:xmpp:forward:0">
-         *         <message xmlns="jabber:client" to="xsf@muc.xmpp.org" type="groupchat" from="xsf@muc.xmpp.org/i_am_groot">
-         *             <body>I am groot.</body>
-         *         </message>
-         *         </forwarded>
-         *     </received>
-         * </message>
-         */
-        const msg = $msg({
-                'from': sender_jid,
-                'id': _converse.api.connection.get().getUniqueId(),
-                'to': _converse.api.connection.get().jid,
-                'type': 'groupchat',
-                'xmlns': 'jabber:client'
-            }).c('received', {'xmlns': 'urn:xmpp:carbons:2'})
-              .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
-              .c('message', {
-                    'xmlns': 'jabber:client',
-                    'from': impersonated_jid,
-                    'to': muc_jid,
-                    'type': 'groupchat'
-            }).c('body').t('I am groot').tree();
+        _converse.api.connection.get()._dataRecv(mock.createRequest(
+            stx`<presence to='romeo@montague.lit/_converse.js-29092160' from='${sender_jid}' xmlns="jabber:client">
+                    <x xmlns='${Strophe.NS.MUC_USER}'>
+                        <item affiliation='owner' jid='newguy@montague.lit/_converse.js-290929789' role='participant'/>
+                    </x>
+            </presence>`)
+        );
+
+        const msg = stx`
+            <message from="${sender_jid}"
+                     id="${_converse.api.connection.get().getUniqueId()}"
+                     to="${_converse.api.connection.get().jid}"
+                     type="groupchat"
+                     xmlns="jabber:client">
+                <received xmlns="urn:xmpp:carbons:2">
+                    <forwarded xmlns="urn:xmpp:forward:0">
+                        <message xmlns="jabber:client"
+                                 from="${impersonated_jid}"
+                                 to="${muc_jid}"
+                                 type="groupchat">
+                            <body>I am groot</body>
+                        </message>
+                    </forwarded>
+                </received>
+            </message>`;
         const view = _converse.chatboxviews.get(muc_jid);
         spyOn(converse.env.log, 'error');
-        await _converse.handleMAMResult(model, { 'messages': [msg] });
+        await _converse.handleMAMResult(model, { 'messages': [msg.tree()] });
         await u.waitUntil(() => converse.env.log.error.calls.count());
         expect(converse.env.log.error).toHaveBeenCalledWith(
             'Invalid Stanza: MUC messages SHOULD NOT be XEP-0280 carbon copied'

+ 10 - 10
src/plugins/muc-views/tests/markers.js

@@ -1,8 +1,8 @@
 /*global mock, converse */
 
-const u = converse.env.utils;
-// See: https://xmpp.org/rfcs/rfc3921.html
+const { u, stx } = converse.env;
 
+// See: https://xmpp.org/rfcs/rfc3921.html
 
 describe("A XEP-0333 Chat Marker", function () {
     it("may be returned for a MUC message",
@@ -26,41 +26,41 @@ describe("A XEP-0333 Chat Marker", function () {
             .toBe("But soft, what light through yonder airlock breaks?");
 
         const msg_obj = view.model.messages.at(0);
-        let stanza = u.toStanza(`
+        let stanza = stx`
             <message xml:lang="en" to="romeo@montague.lit/orchard"
                      from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
                 <received xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
         expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0);
 
-        stanza = u.toStanza(`
+        stanza = stx`
             <message xml:lang="en" to="romeo@montague.lit/orchard"
                      from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
                 <displayed xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0);
 
-        stanza = u.toStanza(`
+        stanza = stx`
             <message xml:lang="en" to="romeo@montague.lit/orchard"
                      from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
                 <acknowledged xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0);
 
-        stanza = u.toStanza(`
+        stanza = stx`
             <message xml:lang="en" to="romeo@montague.lit/orchard"
                      from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
                 <body>'tis I!</body>
                 <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
                 <markable xmlns="urn:xmpp:chat-markers:0"/>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
         expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0);

+ 31 - 22
src/plugins/muc-views/tests/me-messages.js

@@ -1,39 +1,43 @@
 /*global mock, converse */
 
-const { u, sizzle, $msg } = converse.env;
+const { u, sizzle, stx } = converse.env;
 
 
 describe("A Groupchat Message", function () {
 
     it("supports the /me command", mock.initConverse([], {}, async function (_converse) {
+        const muc_jid = 'lounge@montague.lit';
         await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
         await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
         await mock.waitForRoster(_converse, 'current');
-        await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-        const view = _converse.chatboxviews.get('lounge@montague.lit');
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const view = _converse.chatboxviews.get(muc_jid);
         if (!view.querySelectorAll('.chat-area').length) {
             view.renderChatArea();
         }
         let message = '/me is tired';
         const nick = mock.chatroom_names[0];
-        let msg = $msg({
-                'from': 'lounge@montague.lit/'+nick,
-                'id': u.getUniqueId(),
-                'to': 'romeo@montague.lit',
-                'type': 'groupchat'
-            }).c('body').t(message).tree();
+
+        let msg = stx`<message from="${muc_jid}/${nick}"
+            id="${u.getUniqueId()}"
+            to="romeo@montague.lit"
+            type="groupchat"
+            xmlns="jabber:client">
+            <body>${message}</body>
+        </message>`;
         await view.model.handleMessageStanza(msg);
         await u.waitUntil(() => sizzle('.chat-msg:last .chat-msg__text', view).pop());
         await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.trim() === 'is tired');
         expect(view.querySelector('.chat-msg__author').textContent.includes('**Dyon van de Wege')).toBeTruthy();
 
         message = '/me is as well';
-        msg = $msg({
-            from: 'lounge@montague.lit/Romeo Montague',
-            id: u.getUniqueId(),
-            to: 'romeo@montague.lit',
-            type: 'groupchat'
-        }).c('body').t(message).tree();
+        msg = stx`<message from="${muc_jid}/Romeo Montague"
+            id="${u.getUniqueId()}"
+            to="romeo@montague.lit"
+            type="groupchat"
+            xmlns="jabber:client">
+            <body>${message}</body>
+        </message>`;
         await view.model.handleMessageStanza(msg);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
         await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text')).pop().textContent.trim() === 'is as well');
@@ -41,13 +45,18 @@ describe("A Groupchat Message", function () {
 
         // Check rendering of a mention inside a me message
         const msg_text = "/me mentions romeo";
-        msg = $msg({
-                from: 'lounge@montague.lit/gibson',
-                id: u.getUniqueId(),
-                to: 'romeo@montague.lit',
-                type: 'groupchat'
-            }).c('body').t(msg_text).up()
-                .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'13', 'end':'19', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).nodeTree;
+        msg = stx`<message from="${muc_jid}/gibson"
+            id="${u.getUniqueId()}"
+            to="romeo@montague.lit"
+            type="groupchat"
+            xmlns="jabber:client">
+            <body>${msg_text}</body>
+            <reference xmlns="urn:xmpp:reference:0"
+                begin="13"
+                end="19"
+                type="mention"
+                uri="xmpp:romeo@montague.lit"/>
+        </message>`;
         await view.model.handleMessageStanza(msg);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
         await u.waitUntil(() => sizzle('.chat-msg__text:last', view).pop().innerHTML.replace(/<!-.*?->/g, '') ===

+ 62 - 68
src/plugins/muc-views/tests/member-lists.js

@@ -1,5 +1,5 @@
 /*global mock, converse */
-const { $iq, Strophe, u }  = converse.env;
+const { $iq, Strophe, u, stx }  = converse.env;
 
 describe("A Groupchat", function () {
 
@@ -15,6 +15,7 @@ describe("A Groupchat", function () {
             let view = _converse.chatboxviews.get(muc_jid);
             expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(3);
 
+
             // Check in reverse order that we requested all three lists
             const owner_iq = sent_IQs.pop();
             expect(Strophe.serialize(owner_iq)).toBe(
@@ -129,18 +130,16 @@ describe("A Groupchat", function () {
                 _converse.api.connection.get()._dataRecv(mock.createRequest(err_stanza));
 
                 // Now the service sends the member lists to the user
-                const member_list_stanza = $iq({
-                        'from': muc_jid,
-                        'id': member_iq.getAttribute('id'),
-                        'to': 'romeo@montague.lit/orchard',
-                        'type': 'result'
-                    }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN})
-                        .c('item', {
-                            'affiliation': 'member',
-                            'jid': 'hag66@shakespeare.lit',
-                            'nick': 'thirdwitch',
-                            'role': 'participant'
-                        });
+                const member_list_stanza = stx`
+                    <iq from="${muc_jid}"
+                        id="${member_iq.getAttribute('id')}"
+                        to="romeo@montague.lit/orchard"
+                        type="result"
+                        xmlns="jabber:client">
+                        <query xmlns="${Strophe.NS.MUC_ADMIN}">
+                            <item affiliation="member" jid="hag66@shakespeare.lit" nick="thirdwitch" role="participant"/>
+                        </query>
+                    </iq>`;
                 _converse.api.connection.get()._dataRecv(mock.createRequest(member_list_stanza));
 
                 await u.waitUntil(() => view.model.occupants.length > 1);
@@ -176,22 +175,20 @@ describe("Someone being invited to a groupchat", function () {
 
         // State that the chat is members-only via the features IQ
         const view = _converse.chatboxviews.get(muc_jid);
-        const features_stanza = $iq({
-                from: 'coven@chat.shakespeare.lit',
-                'id': stanza.getAttribute('id'),
-                'to': 'romeo@montague.lit/desktop',
-                'type': 'result'
-            })
-            .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
-                .c('identity', {
-                    'category': 'conference',
-                    'name': 'A Dark Cave',
-                    'type': 'text'
-                }).up()
-                .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
-                .c('feature', {'var': 'muc_hidden'}).up()
-                .c('feature', {'var': 'muc_temporary'}).up()
-                .c('feature', {'var': 'muc_membersonly'}).up();
+        const features_stanza = stx`
+            <iq from="coven@chat.shakespeare.lit"
+                id="${stanza.getAttribute('id')}"
+                to="romeo@montague.lit/desktop"
+                type="result"
+                xmlns="jabber:client">
+                <query xmlns="http://jabber.org/protocol/disco#info">
+                    <identity category="conference" name="A Dark Cave" type="text"/>
+                    <feature var="http://jabber.org/protocol/muc"/>
+                    <feature var="muc_hidden"/>
+                    <feature var="muc_temporary"/>
+                    <feature var="muc_membersonly"/>
+                </query>
+            </iq>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(features_stanza));
         const sent_stanzas = _converse.api.connection.get().sent_stanzas;
         await u.waitUntil(() => sent_stanzas.filter(s => s.matches(`presence[to="${muc_jid}/${nick}"]`)).pop());
@@ -231,43 +228,40 @@ describe("Someone being invited to a groupchat", function () {
             `</iq>`);
 
         // Now the service sends the member lists to the user
-        const member_list_stanza = $iq({
-                'from': 'coven@chat.shakespeare.lit',
-                'id': member_iq.getAttribute('id'),
-                'to': 'romeo@montague.lit/orchard',
-                'type': 'result'
-            }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN})
-                .c('item', {
-                    'affiliation': 'member',
-                    'jid': 'hag66@shakespeare.lit',
-                    'nick': 'thirdwitch',
-                    'role': 'participant'
-                });
+        const member_list_stanza = stx`
+            <iq from="coven@chat.shakespeare.lit"
+                id="${member_iq.getAttribute('id')}"
+                to="romeo@montague.lit/orchard"
+                type="result"
+                xmlns="jabber:client">
+                <query xmlns="${Strophe.NS.MUC_ADMIN}">
+                    <item affiliation="member" jid="hag66@shakespeare.lit" nick="thirdwitch" role="participant"/>
+                </query>
+            </iq>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(member_list_stanza));
 
-        const admin_list_stanza = $iq({
-                'from': 'coven@chat.shakespeare.lit',
-                'id': admin_iq.getAttribute('id'),
-                'to': 'romeo@montague.lit/orchard',
-                'type': 'result'
-            }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN})
-                .c('item', {
-                    'affiliation': 'admin',
-                    'jid': 'wiccarocks@shakespeare.lit',
-                    'nick': 'secondwitch'
-                });
+        const admin_list_stanza = stx`
+            <iq from="coven@chat.shakespeare.lit"
+                id="${admin_iq.getAttribute('id')}"
+                to="romeo@montague.lit/orchard"
+                type="result"
+                xmlns="jabber:client">
+                <query xmlns="${Strophe.NS.MUC_ADMIN}">
+                    <item affiliation="admin" jid="wiccarocks@shakespeare.lit" nick="secondwitch"/>
+                </query>
+            </iq>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(admin_list_stanza));
 
-        const owner_list_stanza = $iq({
-                'from': 'coven@chat.shakespeare.lit',
-                'id': owner_iq.getAttribute('id'),
-                'to': 'romeo@montague.lit/orchard',
-                'type': 'result'
-            }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN})
-                .c('item', {
-                    'affiliation': 'owner',
-                    'jid': 'crone1@shakespeare.lit',
-                });
+        const owner_list_stanza = stx`
+            <iq from="coven@chat.shakespeare.lit"
+                id="${owner_iq.getAttribute('id')}"
+                to="romeo@montague.lit/orchard"
+                type="result"
+                xmlns="jabber:client">
+                <query xmlns="${Strophe.NS.MUC_ADMIN}">
+                    <item affiliation="owner" jid="crone1@shakespeare.lit"/>
+                </query>
+            </iq>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(owner_list_stanza));
 
         // Converse puts the user on the member list
@@ -281,12 +275,12 @@ describe("Someone being invited to a groupchat", function () {
                 `</query>`+
             `</iq>`);
 
-        const result = $iq({
-                'from': 'coven@chat.shakespeare.lit',
-                'id': stanza.getAttribute('id'),
-                'to': 'romeo@montague.lit/orchard',
-                'type': 'result'
-            });
+        const result = stx`
+            <iq from="coven@chat.shakespeare.lit"
+                id="${stanza.getAttribute('id')}"
+                to="romeo@montague.lit/orchard"
+                type="result"
+                xmlns="jabber:client"/>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(result));
 
         await u.waitUntil(() => view.model.occupants.fetchMembers.calls.count());

+ 115 - 123
src/plugins/muc-views/tests/mentions.js

@@ -1,7 +1,6 @@
 /*global mock, converse */
 
-const { Strophe, $msg, $pres, sizzle } = converse.env;
-const u = converse.env.utils;
+const { Strophe, sizzle, stx, u } = converse.env;
 
 
 describe("An incoming groupchat message", function () {
@@ -14,19 +13,20 @@ describe("An incoming groupchat message", function () {
         const view = _converse.chatboxviews.get(muc_jid);
         if (!view.querySelectorAll('.chat-area').length) { view.renderChatArea(); }
         const message = 'romeo: Your attention is required';
-        const nick = mock.chatroom_names[0],
-            msg = $msg({
-                from: 'lounge@montague.lit/'+nick,
-                id: u.getUniqueId(),
-                to: 'romeo@montague.lit',
-                type: 'groupchat'
-            }).c('body').t(message).tree();
+        const nick = mock.chatroom_names[0];
+        const msg =
+            stx`<message from="lounge@montague.lit/${nick}"
+                    id="${u.getUniqueId()}"
+                    to="romeo@montague.lit"
+                    type="groupchat"
+                    xmlns="jabber:client">
+                <body>${message}</body>
+            </message>`;
         await view.model.handleMessageStanza(msg);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
         expect(u.hasClass('mentioned', view.querySelector('.chat-msg'))).toBeTruthy();
     }));
 
-
     it("highlights all users mentioned via XEP-0372 references",
             mock.initConverse([], {}, async function (_converse) {
 
@@ -35,28 +35,28 @@ describe("An incoming groupchat message", function () {
         const view = _converse.chatboxviews.get(muc_jid);
         ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
             _converse.api.connection.get()._dataRecv(mock.createRequest(
-                $pres({
-                    'to': 'tom@montague.lit/resource',
-                    'from': `lounge@montague.lit/${nick}`
-                })
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'jid': `${nick}@montague.lit/resource`,
-                    'role': 'participant'
-                }))
-            );
+                stx`<presence
+                        to="tom@montague.lit/resource"
+                        from="lounge@montague.lit/${nick}"
+                        xmlns="jabber:client">
+                    <x xmlns="${Strophe.NS.MUC_USER}">
+                        <item affiliation="none" jid="${nick}@montague.lit/resource" role="participant"/>
+                    </x>
+                </presence>`
+            ));
         });
-        let msg = $msg({
-                from: 'lounge@montague.lit/gibson',
-                id: u.getUniqueId(),
-                to: 'romeo@montague.lit',
-                type: 'groupchat'
-            }).c('body').t('hello z3r0 tom mr.robot, how are you?').up()
-                .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'6', 'end':'10', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up()
-                .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'11', 'end':'14', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up()
-                .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'15', 'end':'23', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree;
-        await view.model.handleMessageStanza(msg);
+        await view.model.handleMessageStanza(
+            stx`<message from="lounge@montague.lit/gibson"
+                    id="${u.getUniqueId()}"
+                    to="romeo@montague.lit"
+                    type="groupchat"
+                    xmlns="jabber:client">
+                <body>hello z3r0 tom mr.robot, how are you?</body>
+                <reference xmlns="urn:xmpp:reference:0" begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit"/>
+                <reference xmlns="urn:xmpp:reference:0" begin="11" end="14" type="mention" uri="xmpp:romeo@montague.lit"/>
+                <reference xmlns="urn:xmpp:reference:0" begin="15" end="23" type="mention" uri="xmpp:mr.robot@montague.lit"/>
+            </message>`.tree());
+
         await u.waitUntil(() => view.querySelector('.chat-msg__text')?.innerHTML.replace(/<!-.*?->/g, '') ===
             'hello <span class="mention" data-uri="xmpp:z3r0@montague.lit">z3r0</span> '+
             '<span class="mention mention--self badge badge-info" data-uri="xmpp:romeo@montague.lit">tom</span> '+
@@ -64,14 +64,15 @@ describe("An incoming groupchat message", function () {
         let message = view.querySelector('.chat-msg__text');
         expect(message.classList.length).toEqual(1);
 
-        msg = $msg({
-                from: 'lounge@montague.lit/sw0rdf1sh',
-                id: u.getUniqueId(),
-                to: 'romeo@montague.lit',
-                type: 'groupchat'
-            }).c('body').t('@gibson').up()
-                .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'1', 'end':'7', 'type':'mention', 'uri':'xmpp:gibson@montague.lit'}).nodeTree;
-        await view.model.handleMessageStanza(msg);
+        await view.model.handleMessageStanza(
+            stx`<message from="lounge@montague.lit/sw0rdf1sh"
+                    id="${u.getUniqueId()}"
+                    to="romeo@montague.lit"
+                    type="groupchat"
+                    xmlns="jabber:client">
+                <body>@gibson</body>
+                <reference xmlns="urn:xmpp:reference:0" begin="1" end="7" type="mention" uri="xmpp:gibson@montague.lit"/>
+            </message>`.tree());
 
         await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
 
@@ -88,17 +89,15 @@ describe("An incoming groupchat message", function () {
         await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
         const view = _converse.chatboxviews.get(muc_jid);
         _converse.api.connection.get()._dataRecv(mock.createRequest(
-            $pres({
-                'to': 'romeo@montague.lit/resource',
-                'from': `lounge@montague.lit/ThUnD3r|Gr33n`
-            })
-            .c('x', {xmlns: Strophe.NS.MUC_USER})
-            .c('item', {
-                'affiliation': 'none',
-                'jid': `${nick}@montague.lit/resource`,
-                'role': 'participant'
-            }))
-        );
+            stx`<presence
+                    to="romeo@montague.lit/resource"
+                    from="lounge@montague.lit/ThUnD3r|Gr33n"
+                    xmlns="jabber:client">
+                <x xmlns="${Strophe.NS.MUC_USER}">
+                    <item affiliation="none" jid="${nick}@montague.lit/resource" role="participant"/>
+                </x>
+            </presence>`
+        ));
         const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
         textarea.value = 'hello @ThUnD3r|Gr33n'
         const enter_event = {
@@ -136,29 +135,29 @@ describe("An incoming groupchat message", function () {
         const view = _converse.chatboxviews.get(muc_jid);
         ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
             _converse.api.connection.get()._dataRecv(mock.createRequest(
-                $pres({
-                    'to': 'tom@montague.lit/resource',
-                    'from': `lounge@montague.lit/${nick}`
-                })
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'jid': `${nick}@montague.lit/resource`,
-                    'role': 'participant'
-                }))
-            );
+                stx`<presence
+                        to="tom@montague.lit/resource"
+                        from="lounge@montague.lit/${nick}"
+                        xmlns="jabber:client">
+                    <x xmlns="${Strophe.NS.MUC_USER}">
+                        <item affiliation="none" jid="${nick}@montague.lit/resource" role="participant"/>
+                    </x>
+                </presence>`
+            ));
         });
-        const msg = $msg({
-                from: 'lounge@montague.lit/gibson',
-                id: u.getUniqueId(),
-                to: 'romeo@montague.lit',
-                type: 'groupchat'
-            }).c('body').t('>hello z3r0 tom mr.robot, how are you?').up()
-                .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'7', 'end':'11', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up()
-                .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'12', 'end':'15', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up()
-                .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'16', 'end':'24', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree;
 
-        await view.model.handleMessageStanza(msg);
+        await view.model.handleMessageStanza(
+            stx`<message from="lounge@montague.lit/gibson"
+                    id="${u.getUniqueId()}"
+                    to="romeo@montague.lit"
+                    type="groupchat"
+                    xmlns="jabber:client">
+                <body>>hello z3r0 tom mr.robot, how are you?</body>
+                <reference xmlns="urn:xmpp:reference:0" begin="7" end="11" type="mention" uri="xmpp:z3r0@montague.lit"/>
+                <reference xmlns="urn:xmpp:reference:0" begin="12" end="15" type="mention" uri="xmpp:romeo@montague.lit"/>
+                <reference xmlns="urn:xmpp:reference:0" begin="16" end="24" type="mention" uri="xmpp:mr.robot@montague.lit"/>
+            </message>`.tree());
+
         await u.waitUntil(() => view.querySelector('.chat-msg__text')?.innerHTML.replace(/<!-.*?->/g, '') ===
             '<blockquote>hello <span class="mention" data-uri="xmpp:z3r0@montague.lit">z3r0</span> '+
             '<span class="mention mention--self badge badge-info" data-uri="xmpp:romeo@montague.lit">tom</span> '+
@@ -195,26 +194,24 @@ describe("A sent groupchat message", function () {
             const view = _converse.chatboxviews.get(muc_jid);
             ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh', 'Link Mauve', 'robot'].forEach((nick) => {
                 _converse.api.connection.get()._dataRecv(mock.createRequest(
-                    $pres({
-                        'to': 'tom@montague.lit/resource',
-                        'from': `lounge@montague.lit/${nick}`
-                    })
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': `${nick.replace(/\s/g, '-')}@montague.lit/resource`,
-                        'role': 'participant'
-                    })));
+                    stx`<presence
+                            to="tom@montague.lit/resource"
+                            from="lounge@montague.lit/${nick}"
+                            xmlns="jabber:client">
+                        <x xmlns="${Strophe.NS.MUC_USER}">
+                            <item affiliation="none" jid="${nick.replace(/\s/g, '-')}@montague.lit/resource" role="participant"/>
+                        </x>
+                    </presence>`))
             });
 
             // Also check that nicks from received messages, (but for which we don't have occupant objects) can be mentioned.
-            const stanza = u.toStanza(`
+            const stanza = stx`
                 <message xmlns="jabber:client"
                         from="${muc_jid}/gh0st"
-                        to="${_converse.api.connection.get().bare_jid}"
+                        to="${_converse.session.get('bare_jid')}"
                         type="groupchat">
                     <body>Boo!</body>
-                </message>`);
+                </message>`;
             await view.model.handleMessageStanza(stanza);
 
             // Run a few unit tests for the parseTextForReferences method
@@ -310,16 +307,14 @@ describe("A sent groupchat message", function () {
             const view = _converse.chatboxviews.get(muc_jid);
             ['NotAnAdress', 'darnuria'].forEach((nick) => {
                 _converse.api.connection.get()._dataRecv(mock.createRequest(
-                    $pres({
-                        'to': 'tom@montague.lit/resource',
-                        'from': `lounge@montague.lit/${nick}`
-                    })
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': `${nick.replace(/\s/g, '-')}@montague.lit/resource`,
-                        'role': 'participant'
-                    })));
+                    stx`<presence
+                            to="tom@montague.lit/resource"
+                            from="lounge@montague.lit/${nick}"
+                            xmlns="jabber:client">
+                        <x xmlns="${Strophe.NS.MUC_USER}">
+                            <item affiliation="none" jid="${nick.replace(/\s/g, '-')}@montague.lit/resource" role="participant"/>
+                        </x>
+                    </presence>`))
             });
 
             // Test that we don't match @nick in email adresses.
@@ -340,15 +335,14 @@ describe("A sent groupchat message", function () {
             await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom');
             const view = _converse.chatboxviews.get(muc_jid);
             _converse.api.connection.get()._dataRecv(mock.createRequest(
-                $pres({
-                    'to': 'tom@montague.lit/resource',
-                    'from': `lounge@montague.lit/Link Mauve`
-                })
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'role': 'participant'
-                })));
+                stx`<presence
+                        to="tom@montague.lit/resource"
+                        from="lounge@montague.lit/Link Mauve"
+                        xmlns="jabber:client">
+                    <x xmlns="${Strophe.NS.MUC_USER}">
+                        <item affiliation="none" role="participant"/>
+                    </x>
+                </presence>`));
             await u.waitUntil(() => view.model.occupants.length === 2);
 
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
@@ -397,16 +391,15 @@ describe("A sent groupchat message", function () {
             const view = _converse.chatboxviews.get(muc_jid);
             ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
                 _converse.api.connection.get()._dataRecv(mock.createRequest(
-                    $pres({
-                        'to': 'tom@montague.lit/resource',
-                        'from': `lounge@montague.lit/${nick}`
-                    })
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': `${nick}@montague.lit/resource`,
-                        'role': 'participant'
-                    })));
+                    stx`<presence
+                            to="tom@montague.lit/resource"
+                            from="lounge@montague.lit/${nick}"
+                            xmlns="jabber:client">
+                        <x xmlns="${Strophe.NS.MUC_USER}">
+                            <item affiliation="none" jid="${nick}@montague.lit/resource" role="participant"/>
+                        </x>
+                    </presence>`
+                ));
             });
             await u.waitUntil(() => view.model.occupants.length === 5);
 
@@ -479,18 +472,17 @@ describe("A sent groupchat message", function () {
             const muc_jid = 'lounge@montague.lit';
             await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
             const view = _converse.chatboxviews.get(muc_jid);
+
             ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
                 _converse.api.connection.get()._dataRecv(mock.createRequest(
-                    $pres({
-                        'to': 'tom@montague.lit/resource',
-                        'from': `lounge@montague.lit/${nick}`
-                    })
-                    .c('x', {xmlns: Strophe.NS.MUC_USER})
-                    .c('item', {
-                        'affiliation': 'none',
-                        'jid': `${nick}@montague.lit/resource`,
-                        'role': 'participant'
-                    })));
+                    stx`<presence
+                            to="tom@montague.lit/resource"
+                            from="lounge@montague.lit/${nick}"
+                            xmlns="jabber:client">
+                        <x xmlns="${Strophe.NS.MUC_USER}">
+                            <item affiliation="none" jid="${nick}@montague.lit/resource" role="participant"/>
+                        </x>
+                    </presence>`));
             });
             await u.waitUntil(() => view.model.occupants.length === 5);
 

+ 71 - 62
src/plugins/muc-views/tests/mep.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 
-const { u, Strophe } = converse.env;
+const { u, Strophe, stx } = converse.env;
 
 describe("A XEP-0316 MEP notification", function () {
 
@@ -13,16 +13,17 @@ describe("A XEP-0316 MEP notification", function () {
         const view = _converse.chatboxviews.get(muc_jid);
         let msg = 'An anonymous user has saluted romeo';
         let reason = 'Thank you for helping me yesterday';
-        let message = u.toStanza(`
-            <message from='${muc_jid}'
-                    to='${_converse.jid}'
-                    type='headline'
-                    id='zns61f38'>
-                <event xmlns='http://jabber.org/protocol/pubsub#event'>
-                    <items node='urn:ietf:params:xml:ns:conference-info'>
-                        <item id='ehs51f40'>
-                            <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'>
-                                <activity xmlns='http://jabber.org/protocol/activity'>
+        let message = stx`
+            <message from="${muc_jid}"
+                    to="${_converse.jid}"
+                    type="headline"
+                    id="zns61f38"
+                    xmlns="jabber:client">
+                <event xmlns="http://jabber.org/protocol/pubsub#event">
+                    <items node="urn:ietf:params:xml:ns:conference-info">
+                        <item id="ehs51f40">
+                            <conference-info xmlns="urn:ietf:params:xml:ns:conference-info">
+                                <activity xmlns="http://jabber.org/protocol/activity">
                                     <other/>
                                     <text id="activity-text" xml:lang="en">${msg}</text>
                                     <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="30" end="35" type="mention" uri="xmpp:${_converse.bare_jid}"/>
@@ -32,7 +33,7 @@ describe("A XEP-0316 MEP notification", function () {
                         </item>
                     </items>
                 </event>
-            </message>`);
+            </message>`;
 
         _converse.api.connection.get()._dataRecv(mock.createRequest(message));
         await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1);
@@ -51,16 +52,17 @@ describe("A XEP-0316 MEP notification", function () {
         // Also check a MEP message of type "groupchat"
         msg = 'An anonymous user has poked romeo';
         reason = 'Can you please help me with something else?';
-        message = u.toStanza(`
-            <message from='${muc_jid}'
-                    to='${_converse.jid}'
-                    type='groupchat'
-                    id='zns61f39'>
-                <event xmlns='http://jabber.org/protocol/pubsub#event'>
-                    <items node='urn:ietf:params:xml:ns:conference-info'>
-                        <item id='ehs51f40'>
-                            <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'>
-                                <activity xmlns='http://jabber.org/protocol/activity'>
+        message = stx`
+            <message from="${muc_jid}"
+                    to="${_converse.jid}"
+                    type="groupchat"
+                    id="zns61f39"
+                    xmlns="jabber:client">
+                <event xmlns="http://jabber.org/protocol/pubsub#event">
+                    <items node="urn:ietf:params:xml:ns:conference-info">
+                        <item id="ehs51f40">
+                            <conference-info xmlns="urn:ietf:params:xml:ns:conference-info">
+                                <activity xmlns="http://jabber.org/protocol/activity">
                                     <other/>
                                     <text id="activity-text" xml:lang="en">${msg}</text>
                                     <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="28" end="33" type="mention" uri="xmpp:${_converse.bare_jid}"/>
@@ -70,7 +72,7 @@ describe("A XEP-0316 MEP notification", function () {
                         </item>
                     </items>
                 </event>
-            </message>`);
+            </message>`;
 
         _converse.api.connection.get()._dataRecv(mock.createRequest(message));
         await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 2);
@@ -98,16 +100,17 @@ describe("A XEP-0316 MEP notification", function () {
         const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], [], true, {'hidden': true});
         const msg = 'An anonymous user has saluted romeo';
         const reason = 'Thank you for helping me yesterday';
-        const message = u.toStanza(`
-            <message from='${muc_jid}'
-                    to='${_converse.jid}'
-                    type='headline'
-                    id='zns61f38'>
-                <event xmlns='http://jabber.org/protocol/pubsub#event'>
-                    <items node='urn:ietf:params:xml:ns:conference-info'>
-                        <item id='ehs51f40'>
-                            <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'>
-                                <activity xmlns='http://jabber.org/protocol/activity'>
+        const message = stx`
+            <message from="${muc_jid}"
+                    to="${_converse.jid}"
+                    type="headline"
+                    id="zns61f38"
+                    xmlns="jabber:client">
+                <event xmlns="http://jabber.org/protocol/pubsub#event">
+                    <items node="urn:ietf:params:xml:ns:conference-info">
+                        <item id="ehs51f40">
+                            <conference-info xmlns="urn:ietf:params:xml:ns:conference-info">
+                                <activity xmlns="http://jabber.org/protocol/activity">
                                     <other/>
                                     <text id="activity-text" xml:lang="en">${msg}</text>
                                     <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="30" end="35" type="mention" uri="xmpp:${_converse.bare_jid}"/>
@@ -117,7 +120,7 @@ describe("A XEP-0316 MEP notification", function () {
                         </item>
                     </items>
                 </event>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(message));
         await u.waitUntil(() => model.messages.length === 1);
         // expect(window.Notification.calls.count()).toBe(1);
@@ -136,16 +139,17 @@ describe("A XEP-0316 MEP notification", function () {
         const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], [], true);
         const msg = 'An anonymous user has waved at romeo';
         const reason = 'Check out https://conversejs.org';
-        const message = u.toStanza(`
-            <message from='${muc_jid}'
-                    to='${_converse.jid}'
-                    type='headline'
-                    id='zns61f38'>
-                <event xmlns='http://jabber.org/protocol/pubsub#event'>
-                    <items node='urn:ietf:params:xml:ns:conference-info'>
-                        <item id='ehs51f40'>
-                            <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'>
-                                <activity xmlns='http://jabber.org/protocol/activity'>
+        const message = stx`
+            <message from="${muc_jid}"
+                    to="${_converse.jid}"
+                    type="headline"
+                    id="zns61f38"
+                    xmlns="jabber:client">
+                <event xmlns="http://jabber.org/protocol/pubsub#event">
+                    <items node="urn:ietf:params:xml:ns:conference-info">
+                        <item id="ehs51f40">
+                            <conference-info xmlns="urn:ietf:params:xml:ns:conference-info">
+                                <activity xmlns="http://jabber.org/protocol/activity">
                                     <other/>
                                     <text id="activity-text" xml:lang="en">${msg}</text>
                                     <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="31" end="37" type="mention" uri="xmpp:${_converse.bare_jid}"/>
@@ -155,7 +159,7 @@ describe("A XEP-0316 MEP notification", function () {
                         </item>
                     </items>
                 </event>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(message));
         await u.waitUntil(() => model.messages.length === 1);
 
@@ -176,16 +180,17 @@ describe("A XEP-0316 MEP notification", function () {
         const view = _converse.chatboxviews.get(muc_jid);
         const msg = 'An anonymous user has saluted romeo';
         const reason = 'Thank you for helping me yesterday';
-        _converse.api.connection.get()._dataRecv(mock.createRequest(u.toStanza(`
-            <message from='${muc_jid}'
-                    to='${_converse.jid}'
-                    type='headline'
-                    id='zns61f38'>
-                <event xmlns='http://jabber.org/protocol/pubsub#event'>
-                    <items node='urn:ietf:params:xml:ns:conference-info'>
-                        <item id='ehs51f40'>
-                            <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'>
-                                <activity xmlns='http://jabber.org/protocol/activity'>
+        _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
+            <message from="${muc_jid}"
+                    to="${_converse.jid}"
+                    type="headline"
+                    id="zns61f38"
+                    xmlns="jabber:client">
+                <event xmlns="http://jabber.org/protocol/pubsub#event">
+                    <items node="urn:ietf:params:xml:ns:conference-info">
+                        <item id="ehs51f40">
+                            <conference-info xmlns="urn:ietf:params:xml:ns:conference-info">
+                                <activity xmlns="http://jabber.org/protocol/activity">
                                     <other/>
                                     <text id="activity-text" xml:lang="en">${msg}</text>
                                     <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="30" end="35" type="mention" uri="xmpp:${_converse.bare_jid}"/>
@@ -195,9 +200,9 @@ describe("A XEP-0316 MEP notification", function () {
                         </item>
                     </items>
                 </event>
-                <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
+                <stanza-id xmlns="urn:xmpp:sid:0" id="stanza-id-1" by="${muc_jid}"/>
             </message>`
-        )));
+        ));
 
         await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1);
         expect(view.querySelector('.chat-info__message converse-rich-text').textContent.trim()).toBe(msg);
@@ -226,15 +231,19 @@ describe("A XEP-0316 MEP notification", function () {
             `</iq>`);
 
         // The server responds with a retraction message
-        const retraction = u.toStanza(`
-            <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/${nick}">
+        const retraction = stx`
+            <message type="groupchat"
+                    id="retraction-id-1"
+                    from="${muc_jid}"
+                    to="${muc_jid}/${nick}"
+                    xmlns="jabber:client">
                 <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
-                    <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
-                        <retract xmlns='urn:xmpp:message-retract:0' />
+                    <moderated by="${_converse.bare_jid}" xmlns="urn:xmpp:message-moderate:0">
+                        <retract xmlns="urn:xmpp:message-retract:0" />
                         <reason></reason>
                     </moderated>
                 </apply-to>
-            </message>`);
+            </message>`;
         await view.model.handleMessageStanza(retraction);
         expect(view.model.messages.length).toBe(1);
         expect(view.model.messages.at(0).get('moderated')).toBe('retracted');

+ 48 - 64
src/plugins/muc-views/tests/modtools.js

@@ -1,12 +1,6 @@
 /*global mock, converse, _ */
 
-
-const $iq = converse.env.$iq;
-const $pres = converse.env.$pres;
-const sizzle = converse.env.sizzle;
-const Strophe = converse.env.Strophe;
-const u = converse.env.utils;
-
+const { stx, sizzle, Strophe, u } = converse.env;
 
 async function openModtools (_converse, view) {
     const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
@@ -101,7 +95,8 @@ describe("The groupchat moderator tool", function () {
             'type': 'result',
             'id': sent_IQ.getAttribute('id'),
             'from': view.model.get('jid'),
-            'to': _converse.api.connection.get().jid
+            'to': _converse.api.connection.get().jid,
+            'xmlns': 'jabber:client'
         });
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         await u.waitUntil(() => view.model.occupants.fetchMembers.calls.count());
@@ -200,58 +195,46 @@ describe("The groupchat moderator tool", function () {
         await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', []);
         const view = _converse.chatboxviews.get(muc_jid);
         _converse.api.connection.get()._dataRecv(mock.createRequest(
-            $pres({to: _converse.jid, from: `${muc_jid}/nomorenicks`})
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'jid': `nomorenicks@montague.lit`,
-                    'role': 'participant'
-                })
+            stx`<presence to="${_converse.jid}" from="${muc_jid}/nomorenicks" xmlns="jabber:client">
+                    <x xmlns="${Strophe.NS.MUC_USER}">
+                        <item affiliation="none" jid="nomorenicks@montague.lit" role="participant"/>
+                    </x>
+                </presence>`
         ));
         _converse.api.connection.get()._dataRecv(mock.createRequest(
-            $pres({to: _converse.jid, from: `${muc_jid}/newb`})
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'jid': `newb@montague.lit`,
-                    'role': 'participant'
-                })
+            stx`<presence to="${_converse.jid}" from="${muc_jid}/newb" xmlns="jabber:client">
+                    <x xmlns="${Strophe.NS.MUC_USER}">
+                        <item affiliation="none" jid="newb@montague.lit" role="participant"/>
+                    </x>
+                </presence>`
         ));
         _converse.api.connection.get()._dataRecv(mock.createRequest(
-            $pres({to: _converse.jid, from: `${muc_jid}/some1`})
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'jid': `some1@montague.lit`,
-                    'role': 'participant'
-                })
+            stx`<presence to="${_converse.jid}" from="${muc_jid}/some1" xmlns="jabber:client">
+                    <x xmlns="${Strophe.NS.MUC_USER}">
+                        <item affiliation="none" jid="some1@montague.lit" role="participant"/>
+                    </x>
+                </presence>`
         ));
         _converse.api.connection.get()._dataRecv(mock.createRequest(
-            $pres({to: _converse.jid, from: `${muc_jid}/oldhag`})
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'jid': `oldhag@montague.lit`,
-                    'role': 'participant'
-                })
+            stx`<presence to="${_converse.jid}" from="${muc_jid}/oldhag" xmlns="jabber:client">
+                    <x xmlns="${Strophe.NS.MUC_USER}">
+                        <item affiliation="none" jid="oldhag@montague.lit" role="participant"/>
+                    </x>
+                </presence>`
         ));
         _converse.api.connection.get()._dataRecv(mock.createRequest(
-            $pres({to: _converse.jid, from: `${muc_jid}/crone`})
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'jid': `crone@montague.lit`,
-                    'role': 'participant'
-                })
+            stx`<presence to="${_converse.jid}" from="${muc_jid}/crone" xmlns="jabber:client">
+                    <x xmlns="${Strophe.NS.MUC_USER}">
+                        <item affiliation="none" jid="crone@montague.lit" role="participant"/>
+                    </x>
+                </presence>`
         ));
         _converse.api.connection.get()._dataRecv(mock.createRequest(
-            $pres({to: _converse.jid, from: `${muc_jid}/tux`})
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'jid': `tux@montague.lit`,
-                    'role': 'participant'
-                })
+            stx`<presence to="${_converse.jid}" from="${muc_jid}/tux" xmlns="jabber:client">
+                    <x xmlns="${Strophe.NS.MUC_USER}">
+                        <item affiliation="none" jid="tux@montague.lit" role="participant"/>
+                    </x>
+                </presence>`
         ));
         await u.waitUntil(() => (view.model.occupants.length === 7), 1000);
 
@@ -314,6 +297,7 @@ describe("The groupchat moderator tool", function () {
         await u.waitUntil(() => (view.model.occupants.length === 5));
         const modal = await openModtools(_converse, view);
         const tab = modal.querySelector('#affiliations-tab');
+
         // Clear so that we don't match older stanzas
         _converse.api.connection.get().IQ_stanzas = [];
         const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
@@ -330,16 +314,16 @@ describe("The groupchat moderator tool", function () {
             ).length
         ).pop());
 
-        const error = u.toStanza(
-            `<iq from="${muc_jid}"
-                 id="${iq_query.getAttribute('id')}"
-                 type="error"
-                 to="${_converse.jid}">
-
+        const error =
+            stx`<iq from="${muc_jid}"
+                    id="${iq_query.getAttribute('id')}"
+                    type="error"
+                    to="${_converse.jid}"
+                    xmlns="jabber:client">
                  <error type="auth">
                     <forbidden xmlns="${Strophe.NS.STANZAS}"/>
                  </error>
-            </iq>`);
+            </iq>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(error));
         await u.waitUntil(() => !modal.loading_users_with_affiliation);
 
@@ -401,16 +385,16 @@ describe("The groupchat moderator tool", function () {
                 `</query>`+
             `</iq>`);
 
-        const error = u.toStanza(
-            `<iq from="${muc_jid}"
-                 id="${sent_IQ.getAttribute('id')}"
-                 type="error"
-                 to="${_converse.jid}">
-
+        const error =
+            stx`<iq from="${muc_jid}"
+                    id="${sent_IQ.getAttribute('id')}"
+                    type="error"
+                    to="${_converse.jid}"
+                    xmlns="jabber:client">
                  <error type="cancel">
                     <not-allowed xmlns="${Strophe.NS.STANZAS}"/>
                  </error>
-            </iq>`);
+            </iq>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(error));
 
     }));

+ 18 - 30
src/plugins/muc-views/tests/muc-api.js

@@ -1,7 +1,7 @@
 /*global mock, converse */
 
 const Model = converse.env.Model;
-const { $pres, $iq, Strophe, sizzle, u } = converse.env;
+const { Strophe, sizzle, u, stx } = converse.env;
 
 describe("Groupchats", function () {
 
@@ -173,42 +173,30 @@ describe("Groupchats", function () {
 
             // We pretend this is a new room, so no disco info is returned.
             const features_stanza = $iq({
-                    from: 'room@conference.example.org',
-                    'id': features_query.getAttribute('id'),
-                    'to': 'romeo@montague.lit/desktop',
-                    'type': 'error'
-                }).c('error', {'type': 'cancel'})
-                    .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
+                    from: "room@conference.example.org",
+                    id: features_query.getAttribute("id"),
+                    to: "romeo@montague.lit/desktop",
+                    type: "error",
+                    xmlns: "jabber:client"
+                }).c("error", {"type": "cancel"})
+                    .c("item-not-found", {"xmlns": "urn:ietf:params:xml:ns:xmpp-stanzas"});
             _converse.api.connection.get()._dataRecv(mock.createRequest(features_stanza));
 
-            /* <presence xmlns="jabber:client" to="romeo@montague.lit/pda" from="room@conference.example.org/yo">
-             *  <x xmlns="http://jabber.org/protocol/muc#user">
-             *      <item affiliation="owner" jid="romeo@montague.lit/pda" role="moderator"/>
-             *      <status code="110"/>
-             *      <status code="201"/>
-             *  </x>
-             * </presence>
-             */
-            const presence = $pres({
-                    from:'room@conference.example.org/some1',
-                    to:'romeo@montague.lit/pda'
-                })
-                .c('x', {xmlns:'http://jabber.org/protocol/muc#user'})
-                .c('item', {
-                    affiliation: 'owner',
-                    jid: 'romeo@montague.lit/pda',
-                    role: 'moderator'
-                }).up()
-                .c('status', {code:'110'}).up()
-                .c('status', {code:'201'});
-            _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
+            _converse.api.connection.get()._dataRecv(mock.createRequest(stx`
+                <presence xmlns="jabber:client" to="romeo@montague.lit/pda" from="room@conference.example.org/some1">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="owner" jid="romeo@montague.lit/pda" role="moderator"/>
+                        <status code="110"/>
+                        <status code="201"/>
+                    </x>
+                </presence>`));
 
             const iq = await u.waitUntil(() => IQ_stanzas.filter(s => s.querySelector(`query[xmlns="${Strophe.NS.MUC_OWNER}"]`)).pop());
             expect(Strophe.serialize(iq)).toBe(
                 `<iq id="${iq.getAttribute('id')}" to="room@conference.example.org" type="get" xmlns="jabber:client">`+
                 `<query xmlns="http://jabber.org/protocol/muc#owner"/></iq>`);
 
-            const node = u.toStanza(`
+            const node = stx`
                <iq xmlns="jabber:client"
                     type="result"
                     to="romeo@montague.lit/pda"
@@ -244,7 +232,7 @@ describe("Groupchats", function () {
                        <value>20</value></field>
                     </x>
                 </query>
-                </iq>`);
+                </iq>`;
 
             mucview = _converse.chatboxviews.get('room@conference.example.org');
             spyOn(mucview.model, 'sendConfiguration').and.callThrough();

+ 51 - 81
src/plugins/muc-views/tests/muc-avatar.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 
-const { u } = converse.env;
+const { u, stx } = converse.env;
 
 describe('Groupchats', () => {
     describe('A Groupchat', () => {
@@ -30,8 +30,7 @@ describe('Groupchats', () => {
             })
         );
 
-        it(
-            'has an avatar which opens a details modal when clicked',
+        it('has an avatar which opens a details modal when clicked',
             mock.initConverse(
                 ['chatBoxesFetched'],
                 {
@@ -40,7 +39,7 @@ describe('Groupchats', () => {
                     // have to mock stanza traffic.
                 },
                 async function (_converse) {
-                    const { Strophe, $iq, $pres, u } = converse.env;
+                    const { Strophe, u } = converse.env;
                     const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
                     const muc_jid = 'coven@chat.shakespeare.lit';
                     await mock.waitForRoster(_converse, 'current', 0);
@@ -51,79 +50,52 @@ describe('Groupchats', () => {
                     const features_query = await u.waitUntil(() =>
                         IQ_stanzas.filter((iq) => iq.querySelector(selector)).pop()
                     );
-                    const features_stanza = $iq({
-                        'from': 'coven@chat.shakespeare.lit',
-                        'id': features_query.getAttribute('id'),
-                        'to': 'romeo@montague.lit/desktop',
-                        'type': 'result',
-                    })
-                        .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info' })
-                        .c('identity', {
-                            'category': 'conference',
-                            'name': 'A Dark Cave',
-                            'type': 'text',
-                        })
-                        .up()
-                        .c('feature', { 'var': 'http://jabber.org/protocol/muc' })
-                        .up()
-                        .c('feature', { 'var': 'muc_passwordprotected' })
-                        .up()
-                        .c('feature', { 'var': 'muc_hidden' })
-                        .up()
-                        .c('feature', { 'var': 'muc_temporary' })
-                        .up()
-                        .c('feature', { 'var': 'muc_open' })
-                        .up()
-                        .c('feature', { 'var': 'muc_unmoderated' })
-                        .up()
-                        .c('feature', { 'var': 'muc_nonanonymous' })
-                        .up()
-                        .c('feature', { 'var': 'urn:xmpp:mam:0' })
-                        .up()
-                        .c('x', { 'xmlns': 'jabber:x:data', 'type': 'result' })
-                        .c('field', { 'var': 'FORM_TYPE', 'type': 'hidden' })
-                        .c('value')
-                        .t('http://jabber.org/protocol/muc#roominfo')
-                        .up()
-                        .up()
-                        .c('field', {
-                            'type': 'text-single',
-                            'var': 'muc#roominfo_description',
-                            'label': 'Description',
-                        })
-                        .c('value')
-                        .t('This is the description')
-                        .up()
-                        .up()
-                        .c('field', {
-                            'type': 'text-single',
-                            'var': 'muc#roominfo_occupants',
-                            'label': 'Number of occupants',
-                        })
-                        .c('value')
-                        .t(0);
+
+                    const features_stanza = stx`
+                        <iq from="coven@chat.shakespeare.lit"
+                                id="${features_query.getAttribute('id')}"
+                                to="romeo@montague.lit/desktop"
+                                type="result"
+                                xmlns="jabber:client">
+                            <query xmlns="http://jabber.org/protocol/disco#info">
+                                <identity category="conference" name="A Dark Cave" type="text"/>
+                                <feature var="http://jabber.org/protocol/muc"/>
+                                <feature var="muc_passwordprotected"/>
+                                <feature var="muc_hidden"/>
+                                <feature var="muc_temporary"/>
+                                <feature var="muc_open"/>
+                                <feature var="muc_unmoderated"/>
+                                <feature var="muc_nonanonymous"/>
+                                <feature var="urn:xmpp:mam:0"/>
+                                <x xmlns="jabber:x:data" type="result">
+                                    <field var="FORM_TYPE" type="hidden">
+                                        <value>http://jabber.org/protocol/muc#roominfo</value>
+                                    </field>
+                                    <field type="text-single" var="muc#roominfo_description" label="Description">
+                                        <value>This is the description</value>
+                                    </field>
+                                    <field type="text-single" var="muc#roominfo_occupants" label="Number of occupants">
+                                        <value>0</value>
+                                    </field>
+                                </x>
+                            </query>
+                        </iq>`;
                     _converse.api.connection.get()._dataRecv(mock.createRequest(features_stanza));
 
                     const view = _converse.chatboxviews.get(muc_jid);
                     await u.waitUntil(
                         () => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING
                     );
-                    let presence = $pres({
-                        to: _converse.api.connection.get().jid,
-                        from: 'coven@chat.shakespeare.lit/some1',
-                        id: 'DC352437-C019-40EC-B590-AF29E879AF97',
-                    })
-                        .c('x')
-                        .attrs({ xmlns: 'http://jabber.org/protocol/muc#user' })
-                        .c('item')
-                        .attrs({
-                            affiliation: 'member',
-                            jid: _converse.bare_jid,
-                            role: 'participant',
-                        })
-                        .up()
-                        .c('status')
-                        .attrs({ code: '110' });
+                    let presence = stx`
+                        <presence to="${_converse.api.connection.get().jid}"
+                                  from="coven@chat.shakespeare.lit/some1"
+                                  id="DC352437-C019-40EC-B590-AF29E879AF97"
+                                  xmlns="jabber:client">
+                            <x xmlns="http://jabber.org/protocol/muc#user">
+                                <item affiliation="member" jid="${_converse.bare_jid}" role="participant"/>
+                            </x>
+                            <status code="110"/>
+                        </presence>`;
                     _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
 
                     const avatar_el = await u.waitUntil(
@@ -163,16 +135,14 @@ describe('Groupchats', () => {
                         'Not anonymous - All other groupchat participants can see your XMPP address' +
                         'Not moderated - Participants entering this groupchat can write right away '
                     );
-                    presence = $pres({
-                        to: 'romeo@montague.lit/_converse.js-29092160',
-                        from: 'coven@chat.shakespeare.lit/newguy',
-                    })
-                        .c('x', { xmlns: Strophe.NS.MUC_USER })
-                        .c('item', {
-                            'affiliation': 'none',
-                            'jid': 'newguy@montague.lit/_converse.js-290929789',
-                            'role': 'participant',
-                        });
+                    presence = stx`
+                        <presence to="romeo@montague.lit/_converse.js-29092160"
+                                from="coven@chat.shakespeare.lit/newguy"
+                                xmlns="jabber:client">
+                            <x xmlns="http://jabber.org/protocol/muc#user">
+                                <item affiliation="none" jid="newguy@montague.lit/_converse.js-290929789" role="participant"/>
+                            </x>
+                        </presence>`;
                     _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
 
                     els = modal.querySelectorAll('p.room-info');

+ 33 - 28
src/plugins/muc-views/tests/muc-list-modal.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 
-const { $iq, Strophe, Promise, sizzle, u } = converse.env;
+const { Strophe, Promise, sizzle, u, stx } = converse.env;
 
 describe('The "Groupchats" List modal', function () {
 
@@ -36,23 +36,26 @@ describe('The "Groupchats" List modal', function () {
                         `<query xmlns="http://jabber.org/protocol/disco#items"/>` +
                     `</iq>`
             );
-            const iq = $iq({
-                'from': 'muc.montague.lit',
-                'to': 'romeo@montague.lit/pda',
-                'id': id,
-                'type': 'result',
-            })
-                .c('query')
-                .c('item', { jid: 'heath@chat.shakespeare.lit', name: 'A Lonely Heath' }).up()
-                .c('item', { jid: 'coven@chat.shakespeare.lit', name: 'A Dark Cave' }).up()
-                .c('item', { jid: 'forres@chat.shakespeare.lit', name: 'The Palace' }).up()
-                .c('item', { jid: 'inverness@chat.shakespeare.lit', name: 'Macbeth&apos;s Castle' }).up()
-                .c('item', { jid: 'orchard@chat.shakespeare.lit', name: "Capulet's Orchard" }).up()
-                .c('item', { jid: 'friar@chat.shakespeare.lit', name: "Friar Laurence's cell" }).up()
-                .c('item', { jid: 'hall@chat.shakespeare.lit', name: "Hall in Capulet's house" }).up()
-                .c('item', { jid: 'chamber@chat.shakespeare.lit', name: "Juliet's chamber" }).up()
-                .c('item', { jid: 'public@chat.shakespeare.lit', name: 'A public place' }).up()
-                .c('item', { jid: 'street@chat.shakespeare.lit', name: 'A street' }).nodeTree;
+
+            const iq = stx`
+                <iq from="muc.montague.lit"
+                        to="romeo@montague.lit/pda"
+                        id="${id}"
+                        type="result"
+                        xmlns="jabber:client">
+                    <query>
+                        <item jid="heath@chat.shakespeare.lit" name="A Lonely Heath"/>
+                        <item jid="coven@chat.shakespeare.lit" name="A Dark Cave"/>
+                        <item jid="forres@chat.shakespeare.lit" name="The Palace"/>
+                        <item jid="inverness@chat.shakespeare.lit" name="Macbeth&apos;s Castle"/>
+                        <item jid="orchard@chat.shakespeare.lit" name="Capulet's Orchard"/>
+                        <item jid="friar@chat.shakespeare.lit" name="Friar Laurence's cell"/>
+                        <item jid="hall@chat.shakespeare.lit" name="Hall in Capulet's house"/>
+                        <item jid="chamber@chat.shakespeare.lit" name="Juliet's chamber"/>
+                        <item jid="public@chat.shakespeare.lit" name="A public place"/>
+                        <item jid="street@chat.shakespeare.lit" name="A street"/>
+                    </query>
+                </iq>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(iq));
 
             await u.waitUntil(() => modal.querySelectorAll('.available-chatrooms li').length === 11);
@@ -117,16 +120,18 @@ describe('The "Groupchats" List modal', function () {
                             `<query xmlns="http://jabber.org/protocol/disco#items"/>` +
                         `</iq>`
                 );
-                const iq = $iq({
-                    from: 'muc.montague.lit',
-                    to: 'romeo@montague.lit/pda',
-                    id: sent_stanza.getAttribute('id'),
-                    type: 'result',
-                })
-                    .c('query')
-                    .c('item', { jid: 'heath@chat.shakespeare.lit', name: 'A Lonely Heath' }).up()
-                    .c('item', { jid: 'coven@chat.shakespeare.lit', name: 'A Dark Cave' }).up()
-                    .c('item', { jid: 'forres@chat.shakespeare.lit', name: 'The Palace' }).up();
+                const iq = stx`
+                    <iq from="muc.montague.lit"
+                        to="romeo@montague.lit/pda"
+                        id="${sent_stanza.getAttribute('id')}"
+                        type="result"
+                        xmlns="jabber:client">
+                        <query>
+                            <item jid="heath@chat.shakespeare.lit" name="A Lonely Heath"/>
+                            <item jid="coven@chat.shakespeare.lit" name="A Dark Cave"/>
+                            <item jid="forres@chat.shakespeare.lit" name="The Palace"/>
+                        </query>
+                    </iq>`;
                 _converse.api.connection.get()._dataRecv(mock.createRequest(iq));
 
                 await u.waitUntil(() => modal.querySelectorAll('.available-chatrooms li').length === 4);

+ 8 - 11
src/plugins/muc-views/tests/muc-mentions.js

@@ -1,9 +1,8 @@
 /*global mock, converse */
 
-const { dayjs } = converse.env;
-const u = converse.env.utils;
-// See: https://xmpp.org/rfcs/rfc3921.html
+const { dayjs, stx, u } = converse.env;
 
+// See: https://xmpp.org/rfcs/rfc3921.html
 
 describe("MUC Mention Notfications", function () {
 
@@ -34,8 +33,8 @@ describe("MUC Mention Notfications", function () {
         expect(Array.from(room_el.classList).includes('unread-msgs')).toBeFalsy();
 
         const base_time = new Date();
-        let message = u.toStanza(`
-            <message from="${muc_jid}">
+        let message = stx`
+            <message from="${muc_jid}" xmlns="jabber:client">
                 <mentions xmlns='urn:xmpp:mmn:0'>
                     <forwarded xmlns='urn:xmpp:forward:0'>
                         <delay xmlns='urn:xmpp:delay' stamp='${dayjs(base_time).subtract(5, 'minutes').toISOString()}'/>
@@ -52,15 +51,14 @@ describe("MUC Mention Notfications", function () {
                         </message>
                     </forwarded>
                 </mentions>
-            </message>
-        `);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(message));
 
         await u.waitUntil(() => Array.from(room_el.classList).includes('unread-msgs'));
         expect(room_el.querySelector('.msgs-indicator')?.textContent.trim()).toBe('1');
 
-        message = u.toStanza(`
-            <message from="${muc_jid}">
+        message = stx`
+            <message from="${muc_jid}" xmlns="jabber:client">
                 <mentions xmlns='urn:xmpp:mmn:0'>
                     <forwarded xmlns='urn:xmpp:forward:0'>
                         <delay xmlns='urn:xmpp:delay' stamp='${dayjs(base_time).subtract(4, 'minutes').toISOString()}'/>
@@ -77,8 +75,7 @@ describe("MUC Mention Notfications", function () {
                         </message>
                     </forwarded>
                 </mentions>
-            </message>
-        `);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(message));
         expect(Array.from(room_el.classList).includes('unread-msgs')).toBeTruthy();
         await u.waitUntil(() => room_el.querySelector('.msgs-indicator')?.textContent.trim() === '2');

+ 118 - 97
src/plugins/muc-views/tests/muc-messages.js

@@ -1,7 +1,7 @@
 /*global mock, converse */
 
-    const { Promise, Strophe, $msg, $pres, sizzle,u } = converse.env;
-    const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
+const { Promise, Strophe, sizzle, u, stx } = converse.env;
+const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
 
 describe("A Groupchat Message", function () {
 
@@ -30,15 +30,14 @@ describe("A Groupchat Message", function () {
 
             const msg = view.model.messages.at(0);
             const err_msg_text = "Message rejected because you're sending messages too quickly";
-            const error = u.toStanza(`
+            const error = stx`
                 <message xmlns="jabber:client" id="${msg.get('msgid')}" from="${muc_jid}" to="${_converse.jid}" type="error">
                     <error type="wait">
                         <policy-violation xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
                         <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">${err_msg_text}</text>
                     </error>
                     <body>hello world</body>
-                </message>
-            `);
+                </message>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(error));
             expect(await u.waitUntil(() => view.querySelector('.chat-msg__error')?.textContent?.trim())).toBe(err_msg_text);
             expect(view.model.messages.length).toBe(1);
@@ -58,15 +57,16 @@ describe("A Groupchat Message", function () {
         const view = _converse.chatboxviews.get(muc_jid);
         if (!view.querySelectorAll('.chat-area').length) { view.renderChatArea(); }
         const message = 'romeo: Your attention is required';
-        const nick = mock.chatroom_names[0],
-            msg = $msg({
-                from: 'lounge@montague.lit/'+nick,
-                id: u.getUniqueId(),
-                to: 'romeo@montague.lit',
-                type: 'groupchat'
-            }).c('body').t(message)
-              .c('active', {'xmlns': "http://jabber.org/protocol/chatstates"})
-              .tree();
+        const nick = mock.chatroom_names[0];
+        const msg = stx`
+                <message xmlns="jabber:client"
+                         from="lounge@montague.lit/${nick}"
+                         id="${u.getUniqueId()}"
+                         to="romeo@montague.lit"
+                         type="groupchat">
+                    <body>${message}</body>
+                    <active xmlns="http://jabber.org/protocol/chatstates"/>
+                </message>`;
         await view.model.handleMessageStanza(msg);
         const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         expect(el.textContent).toBe(message);
@@ -80,21 +80,25 @@ describe("A Groupchat Message", function () {
         const view = _converse.chatboxviews.get(muc_jid);
         if (!view.querySelectorAll('.chat-area').length) { view.renderChatArea(); }
         const id = u.getUniqueId();
-        let msg = $msg({
-                from: 'lounge@montague.lit/some1',
-                id: id,
-                to: 'romeo@montague.lit',
-                type: 'groupchat'
-            }).c('body').t('First message').tree();
+        let msg = stx`
+            <message xmlns="jabber:client"
+                     from="lounge@montague.lit/some1"
+                     id="${id}"
+                     to="romeo@montague.lit"
+                     type="groupchat">
+                <body>First message</body>
+            </message>`;
         await view.model.handleMessageStanza(msg);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
 
-        msg = $msg({
-                from: 'lounge@montague.lit/some2',
-                id: id,
-                to: 'romeo@montague.lit',
-                type: 'groupchat'
-            }).c('body').t('Another message').tree();
+        msg = stx`
+            <message xmlns="jabber:client"
+                     from="lounge@montague.lit/some2"
+                     id="${id}"
+                     to="romeo@montague.lit"
+                     type="groupchat">
+                <body>Another message</body>
+            </message>`;
         await view.model.handleMessageStanza(msg);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
         expect(view.model.messages.length).toBe(2);
@@ -107,7 +111,7 @@ describe("A Groupchat Message", function () {
         await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
         const view = _converse.chatboxviews.get(muc_jid);
         spyOn(view.model, 'getStanzaIdQueryAttrs').and.callThrough();
-        let stanza = u.toStanza(`
+        let stanza = stx`
             <message xmlns="jabber:client"
                      from="room@muc.example.com/some1"
                      to="${_converse.api.connection.get().jid}"
@@ -116,7 +120,7 @@ describe("A Groupchat Message", function () {
                 <stanza-id xmlns="urn:xmpp:sid:0"
                            id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
                            by="room@muc.example.com"/>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         await u.waitUntil(() => view.model.messages.length === 1);
         await u.waitUntil(() => view.model.getStanzaIdQueryAttrs.calls.count() === 1);
@@ -125,7 +129,7 @@ describe("A Groupchat Message", function () {
         expect(result[0] instanceof Object).toBe(true);
         expect(result[0]['stanza_id room@muc.example.com']).toBe("5f3dbc5e-e1d3-4077-a492-693f3769c7ad");
 
-        stanza = u.toStanza(`
+        stanza = stx`
             <message xmlns="jabber:client"
                      from="room@muc.example.com/some1"
                      to="${_converse.api.connection.get().jid}"
@@ -134,7 +138,7 @@ describe("A Groupchat Message", function () {
                 <stanza-id xmlns="urn:xmpp:sid:0"
                            id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
                            by="room@muc.example.com"/>
-            </message>`);
+            </message>`;
         spyOn(view.model, 'updateMessage');
         spyOn(view.model, 'getDuplicateMessage').and.callThrough();
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
@@ -151,58 +155,64 @@ describe("A Groupchat Message", function () {
         const muc_jid = 'lounge@montague.lit';
         await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
         const view = _converse.chatboxviews.get(muc_jid);
-        let msg = $msg({
-            from: 'lounge@montague.lit/romeo',
-            id: u.getUniqueId(),
-            to: 'romeo@montague.lit',
-            type: 'groupchat'
-        }).c('body').t('I wrote this message!').tree();
+        let msg = stx`
+            <message xmlns="jabber:client"
+                     from="lounge@montague.lit/romeo"
+                     id="${u.getUniqueId()}"
+                     to="romeo@montague.lit"
+                     type="groupchat">
+                <body>I wrote this message!</body>
+            </message>`;
         await view.model.handleMessageStanza(msg);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
         expect(view.model.messages.last().occupant.get('affiliation')).toBe('owner');
         expect(view.model.messages.last().occupant.get('role')).toBe('moderator');
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         expect(sizzle('.chat-msg', view).pop().classList.value.trim()).toBe('message chat-msg groupchat chat-msg--with-avatar moderator owner');
-        let presence = $pres({
-                to:'romeo@montague.lit/orchard',
-                from:'lounge@montague.lit/romeo',
-                id: u.getUniqueId()
-        }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-            .c('item').attrs({
-                affiliation: 'member',
-                jid: 'romeo@montague.lit/orchard',
-                role: 'participant'
-            }).up()
-            .c('status').attrs({code:'110'}).up()
-            .c('status').attrs({code:'210'}).nodeTree;
+        let presence = stx`
+            <presence to="romeo@montague.lit/orchard"
+                      from="lounge@montague.lit/romeo"
+                      id="${u.getUniqueId()}"
+                      xmlns="jabber:client">
+                <x xmlns="http://jabber.org/protocol/muc#user">
+                    <item affiliation="member"
+                          jid="romeo@montague.lit/orchard"
+                          role="participant"/>
+                    <status code="110"/>
+                    <status code="210"/>
+                </x>
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
 
         await u.waitUntil(() => view.model.messages.length === 4);
 
-        msg = $msg({
-            from: 'lounge@montague.lit/romeo',
-            id: u.getUniqueId(),
-            to: 'romeo@montague.lit',
-            type: 'groupchat'
-        }).c('body').t('Another message!').tree();
+        msg = stx`
+            <message xmlns="jabber:client"
+                     from="lounge@montague.lit/romeo"
+                     id="${u.getUniqueId()}"
+                     to="romeo@montague.lit"
+                     type="groupchat">
+                <body>Another message!</body>
+            </message>`;
         await view.model.handleMessageStanza(msg);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
         expect(view.model.messages.last().occupant.get('affiliation')).toBe('member');
         expect(view.model.messages.last().occupant.get('role')).toBe('participant');
         expect(sizzle('.chat-msg', view).pop().classList.value.trim()).toBe('message chat-msg groupchat chat-msg--with-avatar participant member');
 
-        presence = $pres({
-                to:'romeo@montague.lit/orchard',
-                from:'lounge@montague.lit/romeo',
-                id: u.getUniqueId()
-        }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-            .c('item').attrs({
-                affiliation: 'owner',
-                jid: 'romeo@montague.lit/orchard',
-                role: 'moderator'
-            }).up()
-            .c('status').attrs({code:'110'}).up()
-            .c('status').attrs({code:'210'}).nodeTree;
+        presence = stx`
+            <presence to="romeo@montague.lit/orchard"
+                      from="lounge@montague.lit/romeo"
+                      id="${u.getUniqueId()}"
+                      xmlns="jabber:client">
+                <x xmlns="http://jabber.org/protocol/muc#user">
+                    <item affiliation="owner"
+                          jid="romeo@montague.lit/orchard"
+                          role="moderator"/>
+                    <status code="110"/>
+                    <status code="210"/>
+                </x>
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
 
         view.model.sendMessage({'body': 'hello world'});
@@ -214,12 +224,14 @@ describe("A Groupchat Message", function () {
         expect(view.querySelectorAll('.chat-msg').length).toBe(3);
         await u.waitUntil(() => sizzle('.chat-msg', view).pop().classList.value.trim() === 'message chat-msg groupchat chat-msg--with-avatar moderator owner');
 
-        msg = $msg({
-            from: 'lounge@montague.lit/some1',
-            id: u.getUniqueId(),
-            to: 'romeo@montague.lit',
-            type: 'groupchat'
-        }).c('body').t('Message from someone not in the MUC right now').tree();
+        msg = stx`
+            <message xmlns="jabber:client"
+                     from="lounge@montague.lit/some1"
+                     id="${u.getUniqueId()}"
+                     to="romeo@montague.lit"
+                     type="groupchat">
+                <body>Message from someone not in the MUC right now</body>
+            </message>`;
         await view.model.handleMessageStanza(msg);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 4);
 
@@ -228,36 +240,45 @@ describe("A Groupchat Message", function () {
 
         // Check that the occupant gets added/removed to the message as it
         // gets removed or added.
-        presence = $pres({
-                to:'romeo@montague.lit/orchard',
-                from:'lounge@montague.lit/some1',
-                id: u.getUniqueId()
-        }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-            .c('item').attrs({jid: 'some1@montague.lit/orchard'});
+        presence = stx`
+            <presence to="romeo@montague.lit/orchard"
+                      from="lounge@montague.lit/some1"
+                      id="${u.getUniqueId()}"
+                      xmlns="jabber:client">
+                <x xmlns="http://jabber.org/protocol/muc#user">
+                    <item jid="some1@montague.lit/orchard"/>
+                </x>
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
         await u.waitUntil(() => view.model.messages.last().occupant);
         expect(view.model.messages.last().get('message')).toBe('Message from someone not in the MUC right now');
         expect(view.model.messages.last().occupant.get('nick')).toBe('some1');
         expect(view.model.messages.last().occupant.get('jid')).toBe('some1@montague.lit');
 
-        presence = $pres({
-                to:'romeo@montague.lit/orchard',
-                type: 'unavailable',
-                from:'lounge@montague.lit/some1',
-                id: u.getUniqueId()
-        }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-            .c('item').attrs({jid: 'some1@montague.lit/orchard'});
+        presence = stx`
+            <presence to="romeo@montague.lit/orchard"
+                    type="unavailable"
+                    from="lounge@montague.lit/some1"
+                    id="${u.getUniqueId()}"
+                    xmlns="jabber:client">
+                <x xmlns="http://jabber.org/protocol/muc#user">
+                    <item jid="some1@montague.lit/orchard"/>
+                </x>
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
         await u.waitUntil(() => !view.model.messages.last().occupant);
         expect(view.model.messages.last().get('message')).toBe('Message from someone not in the MUC right now');
         expect(view.model.messages.last().occupant).toBeUndefined();
 
-        presence = $pres({
-                to:'romeo@montague.lit/orchard',
-                from:'lounge@montague.lit/some1',
-                id: u.getUniqueId()
-        }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-            .c('item').attrs({jid: 'some1@montague.lit/orchard'});
+        presence = stx`
+            <presence to="romeo@montague.lit/orchard"
+                    from="lounge@montague.lit/some1"
+                    id="${u.getUniqueId()}"
+                    xmlns="jabber:client">
+                <x xmlns="http://jabber.org/protocol/muc#user">
+                    <item jid="some1@montague.lit/orchard"/>
+                </x>
+            </presence>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
         await u.waitUntil(() => view.model.messages.last().occupant);
         expect(view.model.messages.last().get('message')).toBe('Message from someone not in the MUC right now');
@@ -285,7 +306,7 @@ describe("A Groupchat Message", function () {
         expect(view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length).toBe(0);
 
         const msg_obj = view.model.messages.at(0);
-        const stanza = u.toStanza(`
+        const stanza = stx`
             <message xmlns="jabber:client"
                      from="${msg_obj.get('from')}"
                      to="${_converse.api.connection.get().jid}"
@@ -296,7 +317,7 @@ describe("A Groupchat Message", function () {
                            by="lounge@montague.lit"/>
                 <occupant-id xmlns="urn:xmpp:occupant-id:0" id="dd72603deec90a38ba552f7c68cbcc61bca202cd" />
                 <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
-            </message>`);
+            </message>`;
         await view.model.handleMessageStanza(stanza);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500);
         expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0);
@@ -328,7 +349,7 @@ describe("A Groupchat Message", function () {
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
 
         const msg_obj = view.model.messages.at(0);
-        let stanza = u.toStanza(`
+        let stanza = stx`
             <message xmlns="jabber:client"
                      from="${msg_obj.get('from')}"
                      to="${_converse.api.connection.get().jid}"
@@ -338,16 +359,16 @@ describe("A Groupchat Message", function () {
                            id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
                            by="lounge@montague.lit"/>
                 <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
-            </message>`);
+            </message>`;
         await view.model.handleMessageStanza(stanza);
         await u.waitUntil(() => view.model.messages.last().get('received'));
 
-        stanza = u.toStanza(`
+        stanza = stx`
             <message xml:lang="en" to="romeo@montague.lit/orchard"
                      from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
                 <received xmlns="urn:xmpp:receipts" id="${msg_obj.get('msgid')}"/>
                 <origin-id xmlns="urn:xmpp:sid:0" id="CE08D448-5ED8-4B6A-BB5B-07ED9DFE4FF0"/>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
     }));

+ 16 - 13
src/plugins/muc-views/tests/muc-registration.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 
-const { $iq, Strophe, sizzle, u } = converse.env;
+const { Strophe, sizzle, u, stx } = converse.env;
 
 describe("Chatrooms", function () {
 
@@ -28,18 +28,21 @@ describe("Chatrooms", function () {
                 .toBe(`<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" `+
                             `type="get" xmlns="jabber:client">`+
                         `<query xmlns="jabber:iq:register"/></iq>`);
-            const result = $iq({
-                'from': view.model.get('jid'),
-                'id': stanza.getAttribute('id'),
-                'to': _converse.bare_jid,
-                'type': 'result',
-            }).c('query', {'type': 'jabber:iq:register'})
-                .c('x', {'xmlns': 'jabber:x:data', 'type': 'form'})
-                    .c('field', {
-                        'label': 'Desired Nickname',
-                        'type': 'text-single',
-                        'var': 'muc#register_roomnick'
-                    }).c('required');
+
+            const result = stx`
+                <iq from="${view.model.get('jid')}"
+                        id="${stanza.getAttribute('id')}"
+                        to="${_converse.bare_jid}"
+                        type="result"
+                        xmlns="jabber:client">
+                    <query xmlns="jabber:iq:register">
+                        <x xmlns="jabber:x:data" type="form">
+                            <field label="Desired Nickname" type="text-single" var="muc#register_roomnick">
+                                <required/>
+                            </field>
+                        </x>
+                    </query>
+                </iq>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(result));
             stanza = await u.waitUntil(() => _converse.api.connection.get().IQ_stanzas.filter(
                 iq => sizzle(`iq[to="${muc_jid}"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length

File diff suppressed because it is too large
+ 294 - 344
src/plugins/muc-views/tests/muc.js


+ 124 - 0
src/plugins/muc-views/tests/mute.js

@@ -0,0 +1,124 @@
+/*global mock, converse */
+
+const { Strophe, Promise, stx, u }  = converse.env;
+
+describe("Groupchats", function () {
+    describe("A muted user", function () {
+
+        it("will receive a user-friendly error message when trying to send a message",
+                mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+            const muc_jid = 'trollbox@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'troll');
+            const view = _converse.chatboxviews.get(muc_jid);
+            const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea'));
+            textarea.value = 'Hello world';
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onFormSubmitted(new Event('submit'));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+            let stanza =
+                stx`<message id="${view.model.messages.at(0).get('msgid')}"
+                         xmlns="jabber:client"
+                         type="error"
+                         to="troll@montague.lit/resource"
+                         from="trollbox@montague.lit">
+                    <error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error>
+                </message>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+            await u.waitUntil(() => view.querySelector('.chat-msg__error')?.textContent.trim(), 1000);
+            expect(view.querySelector('.chat-msg__error').textContent.trim()).toBe(
+                "Your message was not delivered because you weren't allowed to send it.");
+
+            textarea.value = 'Hello again';
+            message_form.onFormSubmitted(new Event('submit'));
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+
+            stanza = stx`<message id="${view.model.messages.at(1).get('msgid')}"
+                         xmlns="jabber:client"
+                         type="error"
+                         to="troll@montague.lit/resource"
+                         from="trollbox@montague.lit">
+                    <error type="auth">
+                        <forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+                        <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">Thou shalt not!</text>
+                    </error>
+                </message>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg__error').length === 2);
+            const sel = 'converse-message-history converse-chat-message:last-child .chat-msg__error';
+            await u.waitUntil(() => view.querySelector(sel)?.textContent.trim());
+            expect(view.querySelector(sel).textContent.trim()).toBe('Thou shalt not!')
+        }));
+
+        it("will see an explanatory message instead of a textarea",
+                mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+            const features = [
+                'http://jabber.org/protocol/muc',
+                'jabber:iq:register',
+                Strophe.NS.SID,
+                'muc_moderated',
+            ]
+            const muc_jid = 'trollbox@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'troll', features);
+            const view = _converse.chatboxviews.get(muc_jid);
+            await u.waitUntil(() => view.querySelector('.chat-textarea'));
+
+            let stanza =
+                stx`<presence
+                        from="trollbox@montague.lit/troll"
+                        to="romeo@montague.lit/orchard"
+                        xmlns="jabber:client">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="none"
+                            nick="troll"
+                            role="visitor"/>
+                        <status code="110"/>
+                    </x>
+                </presence>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+
+            await u.waitUntil(() => view.querySelector('.chat-textarea') === null);
+            let bottom_panel = view.querySelector('.muc-bottom-panel');
+            expect(bottom_panel.textContent.trim()).toBe("You're not allowed to send messages in this room");
+
+            // This only applies to moderated rooms, so let's check that
+            // the textarea becomes visible when the room's
+            // configuration changes to be non-moderated
+            view.model.features.set('moderated', false);
+            await u.waitUntil(() => view.querySelector('.muc-bottom-panel') === null);
+            const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea'));
+            expect(textarea === null).toBe(false);
+
+            view.model.features.set('moderated', true);
+            await u.waitUntil(() => view.querySelector('.chat-textarea') === null);
+            bottom_panel = view.querySelector('.muc-bottom-panel');
+            expect(bottom_panel.textContent.trim()).toBe("You're not allowed to send messages in this room");
+
+            // Check now that things get restored when the user is given a voice
+            await u.waitUntil(() =>
+                Array.from(view.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
+                "troll is no longer an owner of this groupchat"
+            );
+
+            stanza = stx`<presence
+                    from="trollbox@montague.lit/troll"
+                    to="romeo@montague.lit/orchard"
+                    xmlns="jabber:client">
+                <x xmlns="http://jabber.org/protocol/muc#user">
+                    <item affiliation="none"
+                        nick="troll"
+                        role="participant"/>
+                    <status code="110"/>
+                </x>
+                </presence>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+            await u.waitUntil(() => view.querySelector('.muc-bottom-panel') === null);
+            expect(textarea === null).toBe(false);
+            // Check now that things get restored when the user is given a voice
+            await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === "troll has been given a voice");
+        }));
+    });
+});

+ 148 - 124
src/plugins/muc-views/tests/nickname.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 
-const { $pres, $iq, Strophe, sizzle, u, stx } = converse.env;
+const { Strophe, sizzle, u, stx } = converse.env;
 
 describe("A MUC", function () {
 
@@ -128,21 +128,22 @@ describe("A MUC", function () {
         const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
         expect(csntext.trim()).toEqual("oldnick has entered the groupchat");
 
-        let presence = $pres().attrs({
-                from:'lounge@montague.lit/oldnick',
-                id:'DC352437-C019-40EC-B590-AF29E879AF98',
-                to:'romeo@montague.lit/pda',
-                type:'unavailable'
-            })
-            .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-            .c('item').attrs({
-                affiliation: 'owner',
-                jid: 'romeo@montague.lit/pda',
-                nick: 'newnick',
-                role: 'moderator'
-            }).up()
-            .c('status').attrs({code:'303'}).up()
-            .c('status').attrs({code:'110'}).nodeTree;
+        let presence = stx`
+            <presence
+                    xmlns="jabber:client"
+                    from='lounge@montague.lit/oldnick'
+                    id='DC352437-C019-40EC-B590-AF29E879AF98'
+                    to='romeo@montague.lit/pda'
+                    type='unavailable'>
+                <x xmlns='http://jabber.org/protocol/muc#user'>
+                    <item affiliation='owner'
+                        jid='romeo@montague.lit/pda'
+                        nick='newnick'
+                        role='moderator'/>
+                    <status code='303'/>
+                    <status code='110'/>
+                </x>
+            </presence>`;
 
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
         await u.waitUntil(() => view.querySelectorAll('.chat-info').length);
@@ -155,18 +156,19 @@ describe("A MUC", function () {
         occupants = view.querySelector('.occupant-list');
         expect(occupants.querySelectorAll('.occupant-nick').length).toBe(1);
 
-        presence = $pres().attrs({
-                from:'lounge@montague.lit/newnick',
-                id:'5B4F27A4-25ED-43F7-A699-382C6B4AFC67',
-                to:'romeo@montague.lit/pda'
-            })
-            .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-            .c('item').attrs({
-                affiliation: 'owner',
-                jid: 'romeo@montague.lit/pda',
-                role: 'moderator'
-            }).up()
-            .c('status').attrs({code:'110'}).nodeTree;
+        presence = stx`
+            <presence
+                    from='lounge@montague.lit/newnick'
+                    id='5B4F27A4-25ED-43F7-A699-382C6B4AFC67'
+                    to='romeo@montague.lit/pda'
+                    xmlns="jabber:client">
+                <x xmlns='http://jabber.org/protocol/muc#user'>
+                    <item affiliation='owner'
+                        jid='romeo@montague.lit/pda'
+                        role='moderator'/>
+                    <status code='110'/>
+                </x>
+            </presence>`;
 
         _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
         expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED);
@@ -195,13 +197,16 @@ describe("A MUC", function () {
                 )).pop()
             );
             // We pretend this is a new room, so no disco info is returned.
-            const features_stanza = $iq({
-                    from: 'lounge@montague.lit',
-                    'id': stanza.getAttribute('id'),
-                    'to': 'romeo@montague.lit/desktop',
-                    'type': 'error'
-                }).c('error', {'type': 'cancel'})
-                    .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"});
+            const features_stanza = stx`
+                <iq from="lounge@montague.lit"
+                        id="${stanza.getAttribute("id")}"
+                        to="romeo@montague.lit/desktop"
+                        type="error"
+                        xmlns="jabber:client">
+                    <error type="cancel">
+                        <item-not-found xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+                    </error>
+                </iq>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(features_stanza));
 
 
@@ -222,45 +227,37 @@ describe("A MUC", function () {
                     `type="get" xmlns="jabber:client">`+
                         `<query node="x-roomuser-item" xmlns="http://jabber.org/protocol/disco#info"/></iq>`);
 
-            /* <iq from='coven@chat.shakespeare.lit'
-             *     id='getnick1'
-             *     to='hag66@shakespeare.lit/pda'
-             *     type='result'>
-             *     <query xmlns='http://jabber.org/protocol/disco#info'
-             *             node='x-roomuser-item'>
-             *         <identity
-             *             category='conference'
-             *             name='thirdwitch'
-             *             type='text'/>
-             *     </query>
-             * </iq>
-             */
             const view = _converse.chatboxviews.get('lounge@montague.lit');
-            stanza = $iq({
-                'type': 'result',
-                'id': iq.getAttribute('id'),
-                'from': view.model.get('jid'),
-                'to': _converse.api.connection.get().jid
-            }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info', 'node': 'x-roomuser-item'})
-            .c('identity', {'category': 'conference', 'name': 'thirdwitch', 'type': 'text'});
+            stanza = stx`
+                <iq type="result"
+                    id="${iq.getAttribute("id")}"
+                    from="${view.model.get("jid")}"
+                    to="${_converse.api.connection.get().jid}"
+                    xmlns="jabber:client">
+                    <query xmlns="http://jabber.org/protocol/disco#info" node="x-roomuser-item">
+                        <identity category="conference" name="thirdwitch" type="text"/>
+                    </query>
+                </iq>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
 
             // The user has just entered the groupchat (because join was called)
             // and receives their own presence from the server.
             // See example 24:
             // https://xmpp.org/extensions/xep-0045.html#enter-pres
-            const presence = $pres({
-                    to:'romeo@montague.lit/orchard',
-                    from:'lounge@montague.lit/thirdwitch',
-                    id:'DC352437-C019-40EC-B590-AF29E879AF97'
-            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                .c('item').attrs({
-                    affiliation: 'member',
-                    jid: 'romeo@montague.lit/orchard',
-                    role: 'participant'
-                }).up()
-                .c('status').attrs({code:'110'}).up()
-                .c('status').attrs({code:'210'}).nodeTree;
+            const presence = stx`
+                <presence
+                    to="romeo@montague.lit/orchard"
+                    from="lounge@montague.lit/thirdwitch"
+                    id="DC352437-C019-40EC-B590-AF29E879AF97"
+                    xmlns="jabber:client">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="member"
+                            jid="romeo@montague.lit/orchard"
+                            role="participant"/>
+                        <status code="110"/>
+                        <status code="210"/>
+                    </x>
+                </presence>`;
 
             _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
 
@@ -290,30 +287,36 @@ describe("A MUC", function () {
                     `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
                 )).pop());
 
-            const features_stanza = $iq({
-                    'from': muc_jid,
-                    'id': iq.getAttribute('id'),
-                    'to': 'romeo@montague.lit/desktop',
-                    'type': 'result'
-                })
-                .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
-                    .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up()
-                    .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
-                    .c('feature', {'var': 'muc_hidden'}).up()
-                    .c('feature', {'var': 'muc_temporary'}).up()
+            const features_stanza = stx`
+                <iq from="${muc_jid}"
+                        id="${iq.getAttribute('id')}"
+                        to="romeo@montague.lit/desktop"
+                        type="result"
+                        xmlns="jabber:client">
+                    <query xmlns="http://jabber.org/protocol/disco#info">
+                        <identity category="conference" name="A Dark Cave" type="text"/>
+                        <feature var="http://jabber.org/protocol/muc"/>
+                        <feature var="muc_hidden"/>
+                        <feature var="muc_temporary"/>
+                    </query>
+                </iq>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(features_stanza));
 
             const view = _converse.chatboxviews.get(muc_jid);
             await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING);
 
-            const presence = $pres().attrs({
-                    from: `${muc_jid}/romeo`,
-                    id: u.getUniqueId(),
-                    to: 'romeo@montague.lit/pda',
-                    type: 'error'
-                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
-                  .c('error').attrs({by: muc_jid, type:'cancel'})
-                      .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+            const presence = stx`
+                <presence
+                        from="${muc_jid}/romeo"
+                        id="${u.getUniqueId()}"
+                        to="romeo@montague.lit/pda"
+                        type="error"
+                        xmlns="jabber:client">
+                    <x xmlns="http://jabber.org/protocol/muc"/>
+                    <error by="${muc_jid}" type="cancel">
+                        <conflict xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+                    </error>
+                </presence>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
 
             const el = await u.waitUntil(() => view.querySelector('.muc-nickname-form .validation-message'));
@@ -340,16 +343,18 @@ describe("A MUC", function () {
              */
             api.settings.set('muc_nickname_from_jid', true);
 
-            const attrs = {
-                'from': `${muc_jid}/romeo`,
-                'id': u.getUniqueId(),
-                'to': 'romeo@montague.lit/pda',
-                'type': 'error'
-            };
-            let presence = $pres().attrs(attrs)
-                .c('x').attrs({'xmlns':'http://jabber.org/protocol/muc'}).up()
-                .c('error').attrs({'by': muc_jid, 'type':'cancel'})
-                    .c('conflict').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+            let presence = stx`
+                <presence
+                        xmlns="jabber:client"
+                        from='${muc_jid}/romeo'
+                        id='${u.getUniqueId()}'
+                        to='romeo@montague.lit/pda'
+                        type='error'>
+                    <x xmlns='http://jabber.org/protocol/muc'/>
+                    <error by='${muc_jid}' type='cancel'>
+                        <conflict xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+                    </error>
+                </presence>`;
 
             const view = _converse.chatboxviews.get(muc_jid);
             spyOn(view.model, 'join').and.callThrough();
@@ -359,22 +364,34 @@ describe("A MUC", function () {
             _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
             expect(view.model.join).toHaveBeenCalledWith('romeo-2');
 
-            attrs.from = `${muc_jid}/romeo-2`;
-            attrs.id = u.getUniqueId();
-            presence = $pres().attrs(attrs)
-                .c('x').attrs({'xmlns':'http://jabber.org/protocol/muc'}).up()
-                .c('error').attrs({'by': muc_jid, type:'cancel'})
-                    .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+            presence = stx`
+                <presence
+                        xmlns="jabber:client"
+                        from="${muc_jid}/romeo-2"
+                        id="${u.getUniqueId()}"
+                        to="romeo@montague.lit/pda"
+                        type="error">
+                    <x xmlns="http://jabber.org/protocol/muc"/>
+                    <error by="${muc_jid}" type="cancel">
+                        <conflict xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+                    </error>
+                </presence>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
 
             expect(view.model.join).toHaveBeenCalledWith('romeo-3');
 
-            attrs.from = `${muc_jid}/romeo-3`;
-            attrs.id = new Date().getTime();
-            presence = $pres().attrs(attrs)
-                .c('x').attrs({'xmlns': 'http://jabber.org/protocol/muc'}).up()
-                .c('error').attrs({'by': muc_jid, 'type': 'cancel'})
-                    .c('conflict').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+            presence = stx`
+                <presence
+                        xmlns="jabber:client"
+                        from="${muc_jid}/romeo-3"
+                        id="${u.getUniqueId()}"
+                        to="romeo@montague.lit/pda"
+                        type="error">
+                    <x xmlns="http://jabber.org/protocol/muc"/>
+                    <error by="${muc_jid}" type="cancel">
+                        <conflict xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+                    </error>
+                </presence>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
             expect(view.model.join).toHaveBeenCalledWith('romeo-4');
         }));
@@ -389,27 +406,34 @@ describe("A MUC", function () {
                 iq => iq.querySelector(
                     `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`
                 )).pop());
-            const features_stanza = $iq({
-                    'from': muc_jid,
-                    'id': iq.getAttribute('id'),
-                    'to': 'romeo@montague.lit/desktop',
-                    'type': 'result'
-                }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'})
-                    .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up()
-                    .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up()
+            const features_stanza = stx`
+                <iq from="${muc_jid}"
+                        id="${iq.getAttribute('id')}"
+                        to="romeo@montague.lit/desktop"
+                        type="result"
+                        xmlns="jabber:client">
+                    <query xmlns="http://jabber.org/protocol/disco#info">
+                        <identity category="conference" name="A Dark Cave" type="text"/>
+                        <feature var="http://jabber.org/protocol/muc"/>
+                    </query>
+                </iq>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(features_stanza));
 
             const view = _converse.chatboxviews.get(muc_jid);
             await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING));
 
-            const presence = $pres().attrs({
-                    from: `${muc_jid}/romeo`,
-                    id: u.getUniqueId(),
-                    to:'romeo@montague.lit/pda',
-                    type:'error'
-                }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up()
-                  .c('error').attrs({by:'lounge@montague.lit', type:'cancel'})
-                      .c('not-acceptable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree;
+            const presence = stx`
+                <presence
+                    xmlns="jabber:client"
+                    from='${muc_jid}/romeo'
+                    id='${u.getUniqueId()}'
+                    to='romeo@montague.lit/pda'
+                    type='error'>
+                    <x xmlns='http://jabber.org/protocol/muc'/>
+                    <error by='lounge@montague.lit' type='cancel'>
+                        <not-acceptable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+                    </error>
+                </presence>`;
 
             _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
             const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child'));

+ 11 - 10
src/plugins/muc-views/tests/occupants-filter.js

@@ -1,6 +1,6 @@
 /* global mock, converse */
 
-const { $pres, u } = converse.env;
+const { u, stx } = converse.env;
 
 describe("The MUC occupants filter", function () {
 
@@ -30,15 +30,16 @@ describe("The MUC occupants filter", function () {
             const name = mock.chatroom_names[i];
             const role = mock.chatroom_roles[name].role;
             // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres
-            const presence = $pres({
-                    to:'romeo@montague.lit/pda',
-                    from:'lounge@montague.lit/'+name
-            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-            .c('item').attrs({
-                affiliation: mock.chatroom_roles[name].affiliation,
-                jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit',
-                role: role
-            });
+            const presence = stx`
+                <presence to="romeo@montague.lit/pda"
+                        from="lounge@montague.lit/${name}"
+                        xmlns="jabber:client">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="${mock.chatroom_roles[name].affiliation}"
+                                jid="${name.replace(/ /g,'.').toLowerCase()}@montague.lit"
+                                role="${role}"/>
+                    </x>
+                </presence>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
         }
 

+ 77 - 0
src/plugins/muc-views/tests/probes.js

@@ -0,0 +1,77 @@
+/*global mock, converse */
+
+const { Strophe, stx, u }  = converse.env;
+
+describe("Groupchats", function () {
+    describe("when muc_send_probes is true", function () {
+
+        it("sends presence probes when muc_send_probes is true",
+                mock.initConverse([], {'muc_send_probes': true}, async function (_converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+
+            let stanza = stx`<message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="${muc_jid}/ralphm">
+                    <body>This message will trigger a presence probe</body>
+                </message>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+            const view = _converse.chatboxviews.get(muc_jid);
+
+            await u.waitUntil(() => view.model.messages.length);
+            let occupant = view.model.messages.at(0)?.occupant;
+            expect(occupant).toBeDefined();
+            expect(occupant.get('nick')).toBe('ralphm');
+            expect(occupant.get('affiliation')).toBeUndefined();
+            expect(occupant.get('role')).toBeUndefined();
+
+            const sent_stanzas = _converse.api.connection.get().sent_stanzas;
+            let probe = await u.waitUntil(() => sent_stanzas.filter(s => s.matches('presence[type="probe"]')).pop());
+            expect(Strophe.serialize(probe)).toBe(
+                `<presence to="${muc_jid}/ralphm" type="probe" xmlns="jabber:client">`+
+                    `<priority>0</priority>`+
+                    `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
+                `</presence>`);
+
+            let presence = stx`<presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/ralphm">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="member" jid="ralph@example.org/Conversations.ZvLu" role="participant"/>
+                    </x>
+                </presence>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
+
+            expect(occupant.get('affiliation')).toBe('member');
+            expect(occupant.get('role')).toBe('participant');
+
+            // Check that unavailable but affiliated occupants don't get destroyed
+            stanza = stx`<message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="${muc_jid}/gonePhising">
+                    <body>This message from an unavailable user will trigger a presence probe</body>
+                </message>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
+
+            await u.waitUntil(() => view.model.messages.length === 2);
+            occupant = view.model.messages.at(1)?.occupant;
+            expect(occupant).toBeDefined();
+            expect(occupant.get('nick')).toBe('gonePhising');
+            expect(occupant.get('affiliation')).toBeUndefined();
+            expect(occupant.get('role')).toBeUndefined();
+
+            probe = await u.waitUntil(() => sent_stanzas.filter(s => s.matches(`presence[to="${muc_jid}/gonePhising"]`)).pop());
+            expect(Strophe.serialize(probe)).toBe(
+                `<presence to="${muc_jid}/gonePhising" type="probe" xmlns="jabber:client">`+
+                    `<priority>0</priority>`+
+                    `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+
+                `</presence>`);
+
+            presence = stx`<presence xmlns="jabber:client" type="unavailable" to="${_converse.jid}" from="${muc_jid}/gonePhising">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item affiliation="member" jid="gonePhishing@example.org/d34dBEEF" role="participant"/>
+                    </x>
+                </presence>`;
+            _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
+
+            expect(view.model.occupants.length).toBe(3);
+            expect(occupant.get('affiliation')).toBe('member');
+            expect(occupant.get('role')).toBe('participant');
+        }));
+    });
+});

+ 28 - 24
src/plugins/muc-views/tests/rai.js

@@ -1,9 +1,8 @@
 /*global mock, converse */
 
-const { Strophe } = converse.env;
-const u = converse.env.utils;
-// See: https://xmpp.org/rfcs/rfc3921.html
+const { Strophe, u, stx } = converse.env;
 
+// See: https://xmpp.org/rfcs/rfc3921.html
 
 describe("XEP-0437 Room Activity Indicators", function () {
 
@@ -26,8 +25,8 @@ describe("XEP-0437 Room Activity Indicators", function () {
         const iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop());
         const first_msg_id = _converse.api.connection.get().getUniqueId();
         const last_msg_id = _converse.api.connection.get().getUniqueId();
-        let message = u.toStanza(
-            `<message xmlns="jabber:client"
+        let message =
+            stx`<message xmlns="jabber:client"
                     to="romeo@montague.lit/orchard"
                     from="${muc_jid}">
                 <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}">
@@ -38,11 +37,10 @@ describe("XEP-0437 Room Activity Indicators", function () {
                         </message>
                     </forwarded>
                 </result>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(message));
 
-        message = u.toStanza(
-            `<message xmlns="jabber:client"
+        message = stx`<message xmlns="jabber:client"
                     to="romeo@montague.lit/orchard"
                     from="${muc_jid}">
                 <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${last_msg_id}">
@@ -53,19 +51,21 @@ describe("XEP-0437 Room Activity Indicators", function () {
                         </message>
                     </forwarded>
                 </result>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(message));
 
-        const result = u.toStanza(
-            `<iq type='result' id='${iq_get.getAttribute('id')}'>
-                <fin xmlns='urn:xmpp:mam:2'>
-                    <set xmlns='http://jabber.org/protocol/rsm'>
-                        <first index='0'>${first_msg_id}</first>
+        const result =
+            stx`<iq type="result"
+                    id="${iq_get.getAttribute("id")}"
+                    xmlns="jabber:client">
+                <fin xmlns="urn:xmpp:mam:2">
+                    <set xmlns="http://jabber.org/protocol/rsm">
+                        <first index="0">${first_msg_id}</first>
                         <last>${last_msg_id}</last>
                         <count>2</count>
                     </set>
                 </fin>
-            </iq>`);
+            </iq>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(result));
         await u.waitUntil(() => view.model.messages.length === 2);
 
@@ -99,13 +99,14 @@ describe("XEP-0437 Room Activity Indicators", function () {
         const room_el = await u.waitUntil(() => document.querySelector("converse-rooms-list .available-chatroom"));
         expect(Array.from(room_el.classList).includes('unread-msgs')).toBeFalsy();
 
-        const activity_stanza = u.toStanza(`
-            <message from="${Strophe.getDomainFromJid(muc_jid)}">
+        const activity_stanza = stx`
+            <message from="${Strophe.getDomainFromJid(muc_jid)}"
+                    xmlns="jabber:client">
                 <rai xmlns="urn:xmpp:rai:0">
                     <activity>${muc_jid}</activity>
                 </rai>
             </message>
-        `);
+        `;
         _converse.api.connection.get()._dataRecv(mock.createRequest(activity_stanza));
 
         await u.waitUntil(() => view.model.get('has_activity'));
@@ -161,13 +162,14 @@ describe("XEP-0437 Room Activity Indicators", function () {
         const room_el = await u.waitUntil(() => document.querySelector("converse-rooms-list .available-chatroom"));
         expect(Array.from(room_el.classList).includes('unread-msgs')).toBeFalsy();
 
-        const activity_stanza = u.toStanza(`
-            <message from="${Strophe.getDomainFromJid(muc_jid)}">
+        const activity_stanza = stx`
+            <message from="${Strophe.getDomainFromJid(muc_jid)}"
+                    xmlns="jabber:client">
                 <rai xmlns="urn:xmpp:rai:0">
                     <activity>${muc_jid}</activity>
                 </rai>
             </message>
-        `);
+        `;
         _converse.api.connection.get()._dataRecv(mock.createRequest(activity_stanza));
 
         await u.waitUntil(() => model.get('has_activity'));
@@ -208,11 +210,13 @@ describe("XEP-0437 Room Activity Indicators", function () {
             `</presence>`
         );
         // If an error presence with "resource-constraint" is returned, we rejoin
-        const activity_stanza = u.toStanza(`
-            <presence type="error" from="${Strophe.getDomainFromJid(muc_jid)}">
+        const activity_stanza = stx`
+            <presence type="error"
+                    from="${Strophe.getDomainFromJid(muc_jid)}"
+                    xmlns="jabber:client">
                 <error type="wait"><resource-constraint xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error>
             </presence>
-        `);
+        `;
         _converse.api.connection.get()._dataRecv(mock.createRequest(activity_stanza));
 
         await u.waitUntil(() => model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING);

+ 230 - 157
src/plugins/muc-views/tests/retractions.js

@@ -1,14 +1,12 @@
 /*global mock, converse */
 
-const { Strophe, $iq } = converse.env;
-const u = converse.env.utils;
-
+const { Strophe, u, stx } = converse.env;
 
 async function sendAndThenRetractMessage (_converse, view) {
     view.model.sendMessage({'body': 'hello world'});
     await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1);
     const msg_obj = view.model.messages.last();
-    const reflection_stanza = u.toStanza(`
+    const reflection_stanza = stx`
         <message xmlns="jabber:client"
                 from="${msg_obj.get('from')}"
                 to="${_converse.api.connection.get().jid}"
@@ -18,7 +16,7 @@ async function sendAndThenRetractMessage (_converse, view) {
                     id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
                     by="lounge@montague.lit"/>
             <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
-        </message>`);
+        </message>`;
     await view.model.handleMessageStanza(reflection_stanza);
     await u.waitUntil(() => view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500);
 
@@ -33,7 +31,6 @@ async function sendAndThenRetractMessage (_converse, view) {
 
 
 describe("Message Retractions", function () {
-
     describe("A groupchat message retraction", function () {
 
         it("is not applied if it's not from the right author",
@@ -43,25 +40,33 @@ describe("Message Retractions", function () {
             const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
             await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
 
-            const received_stanza = u.toStanza(`
-                <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.api.connection.get().getUniqueId()}'>
+            const received_stanza = stx`
+                <message to="${_converse.jid}"
+                        from="${muc_jid}/eve"
+                        type="groupchat"
+                        id="${_converse.api.connection.get().getUniqueId()}"
+                        xmlns="jabber:client">
                     <body>Hello world</body>
-                    <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
+                    <stanza-id xmlns="urn:xmpp:sid:0" id="stanza-id-1" by="${muc_jid}"/>
                 </message>
-            `);
+            `;
             const view = _converse.chatboxviews.get(muc_jid);
             await view.model.handleMessageStanza(received_stanza);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
             expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
             expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
 
-            const retraction_stanza = u.toStanza(`
-                <message type="groupchat" id='retraction-id-1' from="${muc_jid}/mallory" to="${muc_jid}/romeo">
+            const retraction_stanza = stx`
+                <message type="groupchat"
+                        id='retraction-id-1'
+                        from="${muc_jid}/mallory"
+                        to="${muc_jid}/romeo"
+                        xmlns="jabber:client">
                     <apply-to id="stanza-id-1" xmlns="urn:xmpp:fasten:0">
                         <retract xmlns="urn:xmpp:message-retract:0" />
                     </apply-to>
                 </message>
-            `);
+            `;
             spyOn(view.model, 'handleRetraction').and.callThrough();
 
             _converse.api.connection.get()._dataRecv(mock.createRequest(retraction_stanza));
@@ -85,13 +90,17 @@ describe("Message Retractions", function () {
             const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
             await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
 
-            const retraction_stanza = u.toStanza(`
-                <message type="groupchat" id='retraction-id-1' from="${muc_jid}/eve" to="${muc_jid}/romeo">
+            const retraction_stanza = stx`
+                <message type="groupchat"
+                        id="retraction-id-1"
+                        from="${muc_jid}/eve"
+                        to="${muc_jid}/romeo"
+                        xmlns="jabber:client">
                     <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0">
                         <retract by="${muc_jid}/eve" xmlns="urn:xmpp:message-retract:0" />
                     </apply-to>
                 </message>
-            `);
+            `;
             const view = _converse.chatboxviews.get(muc_jid);
             spyOn(converse.env.log, 'warn');
             spyOn(view.model, 'handleRetraction').and.callThrough();
@@ -104,14 +113,18 @@ describe("Message Retractions", function () {
             expect(view.model.messages.at(0).get('retracted')).toBeTruthy();
             expect(view.model.messages.at(0).get('dangling_retraction')).toBe(true);
 
-            const received_stanza = u.toStanza(`
-                <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.api.connection.get().getUniqueId()}'>
+            const received_stanza = stx`
+                <message to="${_converse.jid}"
+                        from="${muc_jid}/eve"
+                        type="groupchat"
+                        id="${_converse.api.connection.get().getUniqueId()}"
+                        xmlns="jabber:client">
                     <body>Hello world</body>
-                    <delay xmlns='urn:xmpp:delay' stamp='${date}'/>
-                    <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
+                    <delay xmlns="urn:xmpp:delay" stamp="${date}"/>
+                    <stanza-id xmlns="urn:xmpp:sid:0" id="stanza-id-1" by="${muc_jid}"/>
                     <origin-id xmlns="urn:xmpp:sid:0" id="origin-id-1"/>
                 </message>
-            `);
+            `;
             _converse.api.connection.get()._dataRecv(mock.createRequest(received_stanza));
             await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1, 1000);
@@ -137,7 +150,7 @@ describe("Message Retractions", function () {
             const muc_jid = 'lounge@montague.lit';
             const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
             await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
-            const retraction_stanza = u.toStanza(`
+            const retraction_stanza = stx`
                 <message xmlns="jabber:client" from="${muc_jid}" type="groupchat" id="retraction-id-1">
                     <apply-to xmlns="urn:xmpp:fasten:0" id="stanza-id-1">
                         <moderated xmlns="urn:xmpp:message-moderate:0" by="${muc_jid}/madison">
@@ -146,7 +159,7 @@ describe("Message Retractions", function () {
                         </moderated>
                     </apply-to>
                 </message>
-            `);
+            `;
             const view = _converse.chatboxviews.get(muc_jid);
             spyOn(converse.env.log, 'warn');
             spyOn(view.model, 'handleModeration').and.callThrough();
@@ -159,14 +172,16 @@ describe("Message Retractions", function () {
             expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
             expect(view.model.messages.at(0).get('dangling_moderation')).toBe(true);
 
-            const received_stanza = u.toStanza(`
-                <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.api.connection.get().getUniqueId()}'>
+            const received_stanza = stx`
+                <message to="${_converse.jid}"
+                        from="${muc_jid}/eve"
+                        type="groupchat"
+                        id="${_converse.api.connection.get().getUniqueId()}"
+                        xmlns="jabber:client">
                     <body>Hello world</body>
-                    <delay xmlns='urn:xmpp:delay' stamp='${date}'/>
-                    <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
-                </message>
-
-            `);
+                    <delay xmlns="urn:xmpp:delay" stamp="${date}"/>
+                    <stanza-id xmlns="urn:xmpp:sid:0" id="stanza-id-1" by="${muc_jid}"/>
+                </message>`;
 
             _converse.api.connection.get()._dataRecv(mock.createRequest(received_stanza));
             await u.waitUntil(() => view.model.handleModeration.calls.count() === 2);
@@ -198,7 +213,7 @@ describe("Message Retractions", function () {
             const view = await mock.openChatBoxFor(_converse, contact_jid);
             spyOn(view.model, 'handleRetraction').and.callThrough();
 
-            const retraction_stanza =  u.toStanza(`
+            const retraction_stanza =  stx`
                 <message id="${u.getUniqueId()}"
                          to="${_converse.bare_jid}"
                          from="${contact_jid}"
@@ -208,7 +223,7 @@ describe("Message Retractions", function () {
                         <retract xmlns="urn:xmpp:message-retract:0"/>
                     </apply-to>
                 </message>
-            `);
+            `;
 
             _converse.api.connection.get()._dataRecv(mock.createRequest(retraction_stanza));
             await u.waitUntil(() => view.model.messages.length === 1);
@@ -218,7 +233,7 @@ describe("Message Retractions", function () {
             expect(message.get('retracted')).toBeTruthy();
             expect(view.querySelectorAll('.chat-msg').length).toBe(0);
 
-            const stanza = u.toStanza(`
+            const stanza = stx`
                 <message xmlns="jabber:client"
                         to="${_converse.bare_jid}"
                         type="chat"
@@ -229,7 +244,7 @@ describe("Message Retractions", function () {
                     <markable xmlns="urn:xmpp:chat-markers:0"/>
                     <origin-id xmlns="urn:xmpp:sid:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
                     <stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/>
-                </message>`);
+                </message>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
             await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2);
             expect(view.model.messages.length).toBe(1);
@@ -249,7 +264,7 @@ describe("Message Retractions", function () {
             const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
             const view = await mock.openChatBoxFor(_converse, contact_jid);
 
-            let stanza = u.toStanza(`
+            let stanza = stx`
                 <message xmlns="jabber:client"
                         to="${_converse.bare_jid}"
                         type="chat"
@@ -259,13 +274,13 @@ describe("Message Retractions", function () {
                     <markable xmlns="urn:xmpp:chat-markers:0"/>
                     <origin-id xmlns="urn:xmpp:sid:0" id="29132ea0-0121-2897-b121-36638c259554"/>
                     <stanza-id xmlns="urn:xmpp:sid:0" id="kxViLhgbnNMcWv10" by="${_converse.bare_jid}"/>
-                </message>`);
+                </message>`;
 
             _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
             await u.waitUntil(() => view.model.messages.length === 1);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
 
-            stanza = u.toStanza(`
+            stanza = stx`
                 <message xmlns="jabber:client"
                         to="${_converse.bare_jid}"
                         type="chat"
@@ -275,13 +290,13 @@ describe("Message Retractions", function () {
                     <markable xmlns="urn:xmpp:chat-markers:0"/>
                     <origin-id xmlns="urn:xmpp:sid:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
                     <stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/>
-                </message>`);
+                </message>`;
 
             _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
             await u.waitUntil(() => view.model.messages.length === 2);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
 
-            const retraction_stanza =  u.toStanza(`
+            const retraction_stanza =  stx`
                 <message id="${u.getUniqueId()}"
                          to="${_converse.bare_jid}"
                          from="${contact_jid}"
@@ -290,8 +305,7 @@ describe("Message Retractions", function () {
                     <apply-to id="2e972ea0-0050-44b7-a830-f6638a2595b3" xmlns="urn:xmpp:fasten:0">
                         <retract xmlns="urn:xmpp:message-retract:0"/>
                     </apply-to>
-                </message>
-            `);
+                </message>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(retraction_stanza));
             await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1);
 
@@ -358,26 +372,32 @@ describe("Message Retractions", function () {
             const features = [...mock.default_muc_features, Strophe.NS.MODERATE];
             await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
 
-            const received_stanza = u.toStanza(`
-                <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.api.connection.get().getUniqueId()}'>
+            const received_stanza = stx`
+            <message to="${_converse.jid}"
+                        from="${muc_jid}/eve"
+                        type="groupchat"
+                        id="${_converse.api.connection.get().getUniqueId()}"
+                        xmlns="jabber:client">
                     <body>Hello world</body>
-                    <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
-                    <origin-id xmlns='urn:xmpp:sid:0' id='origin-id-1' by='${muc_jid}'/>
-                </message>
-            `);
+                    <stanza-id xmlns="urn:xmpp:sid:0" id="stanza-id-1" by="${muc_jid}"/>
+                    <origin-id xmlns="urn:xmpp:sid:0" id="origin-id-1" by="${muc_jid}"/>
+                </message>`;
             const view = _converse.chatboxviews.get(muc_jid);
             await view.model.handleMessageStanza(received_stanza);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
             expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
             expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
 
-            const retraction_stanza = u.toStanza(`
-                <message type="groupchat" id='retraction-id-1' from="${muc_jid}/eve" to="${muc_jid}/romeo">
+            const retraction_stanza = stx`
+                <message type="groupchat"
+                        id="retraction-id-1"
+                        from="${muc_jid}/eve"
+                        to="${muc_jid}/romeo"
+                        xmlns="jabber:client">
                     <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0">
                         <retract by="${muc_jid}/eve" xmlns="urn:xmpp:message-retract:0" />
                     </apply-to>
-                </message>
-            `);
+                </message>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(retraction_stanza));
 
             // We opportunistically save the message as retracted, even before receiving the retraction message
@@ -403,12 +423,15 @@ describe("Message Retractions", function () {
             const occupant = view.model.getOwnOccupant();
             expect(occupant.get('role')).toBe('moderator');
 
-            const received_stanza = u.toStanza(`
-                <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.api.connection.get().getUniqueId()}'>
+            const received_stanza = stx`
+                <message to="${_converse.jid}"
+                        from="${muc_jid}/mallory"
+                        type="groupchat"
+                        id="${_converse.api.connection.get().getUniqueId()}"
+                        xmlns="jabber:client">
                     <body>Visit this site to get free Bitcoin!</body>
-                    <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
-                </message>
-            `);
+                    <stanza-id xmlns="urn:xmpp:sid:0" id="stanza-id-1" by="${muc_jid}"/>
+                </message>`;
             await view.model.handleMessageStanza(received_stanza);
             await u.waitUntil(() => view.model.messages.length === 1);
             expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
@@ -439,7 +462,12 @@ describe("Message Retractions", function () {
                     `</apply-to>`+
                 `</iq>`);
 
-            const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'});
+            const result_iq = stx`
+                <iq from="${muc_jid}"
+                    id="${stanza.getAttribute('id')}"
+                    to="${_converse.bare_jid}"
+                    type="result"
+                    xmlns="jabber:client"/>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(result_iq));
 
             // We opportunistically save the message as retracted, even before receiving the retraction message
@@ -458,15 +486,19 @@ describe("Message Retractions", function () {
             expect(qel.textContent.trim()).toBe('This content is inappropriate for this forum!');
 
             // The server responds with a retraction message
-            const retraction = u.toStanza(`
-                <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo">
+            const retraction = stx`
+                <message type="groupchat"
+                        id="retraction-id-1"
+                        from="${muc_jid}"
+                        to="${muc_jid}/romeo"
+                        xmlns="jabber:client">
                     <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
-                        <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
-                        <retract xmlns='urn:xmpp:message-retract:0' />
+                        <moderated by="${_converse.bare_jid}" xmlns="urn:xmpp:message-moderate:0">
+                        <retract xmlns="urn:xmpp:message-retract:0" />
                         <reason>${reason}</reason>
                         </moderated>
                     </apply-to>
-                </message>`);
+                </message>`;
             await view.model.handleMessageStanza(retraction);
             expect(view.model.messages.length).toBe(1);
             expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
@@ -484,12 +516,15 @@ describe("Message Retractions", function () {
             const occupant = view.model.getOwnOccupant();
             expect(occupant.get('role')).toBe('moderator');
 
-            const received_stanza = u.toStanza(`
-                <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.api.connection.get().getUniqueId()}'>
+            const received_stanza = stx`
+                <message to="${_converse.jid}"
+                        from="${muc_jid}/mallory"
+                        type="groupchat"
+                        id="${_converse.api.connection.get().getUniqueId()}"
+                        xmlns="jabber:client">
                     <body>Visit this site to get free Bitcoin!</body>
-                    <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
-                </message>
-            `);
+                    <stanza-id xmlns="urn:xmpp:sid:0" id="stanza-id-1" by="${muc_jid}"/>
+                </message>`;
             await view.model.handleMessageStanza(received_stanza);
             await u.waitUntil(() => view.querySelector('.chat-msg__content'));
             expect(view.querySelector('.chat-msg__content .chat-msg__action-retract')).toBe(null);
@@ -508,12 +543,15 @@ describe("Message Retractions", function () {
             const occupant = view.model.getOwnOccupant();
             expect(occupant.get('role')).toBe('moderator');
 
-            const received_stanza = u.toStanza(`
-                <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.api.connection.get().getUniqueId()}'>
+            const received_stanza = stx`
+                <message to="${_converse.jid}"
+                        from="${muc_jid}/mallory"
+                        type="groupchat"
+                        id="${_converse.api.connection.get().getUniqueId()}"
+                        xmlns="jabber:client">
                     <body>Visit this site to get free Bitcoin!</body>
-                    <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/>
-                </message>
-            `);
+                    <stanza-id xmlns="urn:xmpp:sid:0" id="stanza-id-1" by="${muc_jid}"/>
+                </message>`;
             await view.model.handleMessageStanza(received_stanza);
             await u.waitUntil(() => view.model.messages.length === 1);
             expect(view.model.messages.length).toBe(1);
@@ -533,15 +571,19 @@ describe("Message Retractions", function () {
             const message = view.model.messages.at(0);
             const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`);
             // The server responds with a retraction message
-            const retraction = u.toStanza(`
-                <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo">
+            const retraction = stx`
+                <message type="groupchat"
+                        id='retraction-id-1'
+                        from="${muc_jid}"
+                        to="${muc_jid}/romeo"
+                        xmlns="jabber:client">
                     <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
                         <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
                             <retract xmlns='urn:xmpp:message-retract:0' />
                             <reason>${reason}</reason>
                         </moderated>
                     </apply-to>
-                </message>`);
+                </message>`;
             await view.model.handleMessageStanza(retraction);
 
             await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1);
@@ -553,7 +595,12 @@ describe("Message Retractions", function () {
             const qel = view.querySelector('.chat-msg--retracted .chat-msg__message q');
             expect(qel.textContent).toBe('This content is inappropriate for this forum!');
 
-            const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'});
+            const result_iq = stx`
+                <iq from="${muc_jid}"
+                    id="${stanza.getAttribute('id')}"
+                    to="${_converse.bare_jid}"
+                    type="result"
+                    xmlns="jabber:client"/>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(result_iq));
             expect(view.model.messages.length).toBe(1);
             expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
@@ -594,12 +641,16 @@ describe("Message Retractions", function () {
 
             const stanza_id = message.get(`stanza_id ${muc_jid}`);
             // The server responds with a retraction message
-            const reflection = u.toStanza(`
-                <message type="groupchat" id="${retraction_stanza.getAttribute('id')}" from="${muc_jid}" to="${muc_jid}/romeo">
+            const reflection = stx`
+                <message type="groupchat"
+                        id="${retraction_stanza.getAttribute('id')}"
+                        from="${muc_jid}"
+                        to="${muc_jid}/romeo"
+                        xmlns="jabber:client">
                     <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
                         <retract xmlns='urn:xmpp:message-retract:0' />
                     </apply-to>
-                </message>`);
+                </message>`;
 
             spyOn(view.model, 'handleRetraction').and.callThrough();
             _converse.api.connection.get()._dataRecv(mock.createRequest(reflection));
@@ -637,15 +688,19 @@ describe("Message Retractions", function () {
             const message = view.model.messages.last();
             const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`);
             // The server responds with an error message
-            const error = u.toStanza(`
-                <message type="error" id="${retraction_stanza.getAttribute('id')}" from="${muc_jid}" to="${view.model.get('jid')}/romeo">
+            const error = stx`
+                <message type="error"
+                        id="${retraction_stanza.getAttribute('id')}"
+                        from="${muc_jid}"
+                        to="${view.model.get('jid')}/romeo"
+                        xmlns="jabber:client">
                     <error by='${muc_jid}' type='auth'>
                         <forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
                     </error>
                     <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
                         <retract xmlns='urn:xmpp:message-retract:0' />
                     </apply-to>
-                </message>`);
+                </message>`;
 
             _converse.api.connection.get()._dataRecv(mock.createRequest(error));
 
@@ -704,7 +759,7 @@ describe("Message Retractions", function () {
             await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
             const stanza_id = 'retraction-id-1';
             const msg_obj = view.model.messages.at(0);
-            const reflection_stanza = u.toStanza(`
+            const reflection_stanza = stx`
                 <message xmlns="jabber:client"
                         from="${msg_obj.get('from')}"
                         to="${_converse.api.connection.get().jid}"
@@ -714,7 +769,7 @@ describe("Message Retractions", function () {
                             id="${stanza_id}"
                             by="lounge@montague.lit"/>
                     <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
-                </message>`);
+                </message>`;
             await view.model.handleMessageStanza(reflection_stanza);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500);
             expect(view.model.messages.length).toBe(1);
@@ -722,15 +777,19 @@ describe("Message Retractions", function () {
 
             // The server responds with a retraction message
             const reason = "This content is inappropriate for this forum!"
-            const retraction = u.toStanza(`
-                <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo">
+            const retraction = stx`
+                <message type="groupchat"
+                        id="retraction-id-1"
+                        from="${muc_jid}"
+                        to="${muc_jid}/romeo"
+                        xmlns="jabber:client">
                     <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
-                        <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
-                        <retract xmlns='urn:xmpp:message-retract:0' />
+                        <moderated by="${_converse.bare_jid}" xmlns="urn:xmpp:message-moderate:0">
+                        <retract xmlns="urn:xmpp:message-retract:0" />
                         <reason>${reason}</reason>
                         </moderated>
                     </apply-to>
-                </message>`);
+                </message>`;
             await view.model.handleMessageStanza(retraction);
             expect(view.model.messages.length).toBe(1);
             await u.waitUntil(() => view.model.messages.at(0).get('moderated') === 'retracted');
@@ -759,7 +818,7 @@ describe("Message Retractions", function () {
 
             const stanza_id = 'retraction-id-1';
             const msg_obj = view.model.messages.at(0);
-            const reflection_stanza = u.toStanza(`
+            const reflection_stanza = stx`
                 <message xmlns="jabber:client"
                         from="${msg_obj.get('from')}"
                         to="${_converse.api.connection.get().jid}"
@@ -769,7 +828,7 @@ describe("Message Retractions", function () {
                             id="${stanza_id}"
                             by="lounge@montague.lit"/>
                     <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
-                </message>`);
+                </message>`;
 
             await view.model.handleMessageStanza(reflection_stanza);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500);
@@ -795,7 +854,12 @@ describe("Message Retractions", function () {
                     `</apply-to>`+
                 `</iq>`);
 
-            const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'});
+            const result_iq = stx`
+                <iq from="${muc_jid}"
+                    id="${stanza.getAttribute('id')}"
+                    to="${_converse.bare_jid}"
+                    type="result"
+                    xmlns="jabber:client"/>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(result_iq));
 
             // We opportunistically save the message as retracted, even before receiving the retraction message
@@ -812,14 +876,18 @@ describe("Message Retractions", function () {
             expect(msg_el.querySelector('q')).toBe(null);
 
             // The server responds with a retraction message
-            const retraction = u.toStanza(`
-                <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo">
+            const retraction = stx`
+                <message type="groupchat"
+                        id="retraction-id-1"
+                        from="${muc_jid}"
+                        to="${muc_jid}/romeo"
+                        xmlns="jabber:client">
                     <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">
-                        <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'>
-                        <retract xmlns='urn:xmpp:message-retract:0' />
+                        <moderated by="${_converse.bare_jid}" xmlns="urn:xmpp:message-moderate:0">
+                        <retract xmlns="urn:xmpp:message-retract:0" />
                         </moderated>
                     </apply-to>
-                </message>`);
+                </message>`;
             await view.model.handleMessageStanza(retraction);
             expect(view.model.messages.length).toBe(1);
             expect(view.model.messages.at(0).get('moderated')).toBe('retracted');
@@ -848,59 +916,60 @@ describe("Message Retractions", function () {
             const first_id = u.getUniqueId();
 
             spyOn(view.model, 'handleRetraction').and.callThrough();
-            const first_message = u.toStanza(`
-                <message id='${u.getUniqueId()}' to='${_converse.jid}'>
-                    <result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${first_id}">
-                        <forwarded xmlns='urn:xmpp:forward:0'>
-                            <delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:01:15Z'/>
+            const first_message = stx`
+                <message id="${u.getUniqueId()}" to="${_converse.jid}" xmlns="jabber:client">
+                    <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="${first_id}">
+                        <forwarded xmlns="urn:xmpp:forward:0">
+                            <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:01:15Z"/>
                             <message type="chat" from="${contact_jid}" to="${_converse.bare_jid}" id="message-id-0">
-                                <origin-id xmlns='urn:xmpp:sid:0' id="origin-id-0"/>
+                                <origin-id xmlns="urn:xmpp:sid:0" id="origin-id-0"/>
                                 <body>😊</body>
                             </message>
                         </forwarded>
                     </result>
-                </message>
-            `);
+                </message>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(first_message));
 
-            const tombstone = u.toStanza(`
-                <message id='${u.getUniqueId()}' to='${_converse.jid}'>
-                    <result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${u.getUniqueId()}">
-                        <forwarded xmlns='urn:xmpp:forward:0'>
-                            <delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:08:25Z'/>
+            const tombstone = stx`
+                <message id="${u.getUniqueId()}" to="${_converse.jid}" xmlns="jabber:client">
+                    <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="${u.getUniqueId()}">
+                        <forwarded xmlns="urn:xmpp:forward:0">
+                            <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
                             <message type="chat" from="${contact_jid}" to="${_converse.bare_jid}" id="message-id-1">
-                                <origin-id xmlns='urn:xmpp:sid:0' id="origin-id-1"/>
-                                <retracted stamp='2019-09-20T23:09:32Z' xmlns='urn:xmpp:message-retract:0'/>
+                                <origin-id xmlns="urn:xmpp:sid:0" id="origin-id-1"/>
+                                <retracted stamp="2019-09-20T23:09:32Z" xmlns="urn:xmpp:message-retract:0"/>
                             </message>
                         </forwarded>
                     </result>
-                </message>
-            `);
+                </message>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(tombstone));
 
             const last_id = u.getUniqueId();
-            const retraction = u.toStanza(`
-                <message id='${u.getUniqueId()}' to='${_converse.jid}'>
-                    <result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${last_id}">
-                        <forwarded xmlns='urn:xmpp:forward:0'>
-                            <delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:08:25Z'/>
-                            <message from="${contact_jid}" to='${_converse.bare_jid}' id='retract-message-1'>
+            const retraction = stx`
+                <message id="${u.getUniqueId()}" to="${_converse.jid}" xmlns="jabber:client">
+                    <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="${last_id}">
+                        <forwarded xmlns="urn:xmpp:forward:0">
+                            <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
+                            <message from="${contact_jid}" to="${_converse.bare_jid}" id="retract-message-1">
                                 <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0">
-                                    <retract xmlns='urn:xmpp:message-retract:0'/>
+                                    <retract xmlns="urn:xmpp:message-retract:0"/>
                                 </apply-to>
                             </message>
                         </forwarded>
                     </result>
-                </message>
-            `);
+                </message>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(retraction));
 
-            const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
-                .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
-                    .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
-                        .c('first', {'index': '0'}).t(first_id).up()
-                        .c('last').t(last_id).up()
-                        .c('count').t('2');
+            const iq_result = stx`
+                <iq type="result" id="${stanza.getAttribute('id')}" xmlns="jabber:client">
+                    <fin xmlns="urn:xmpp:mam:2">
+                        <set xmlns="http://jabber.org/protocol/rsm">
+                            <first index="0">${first_id}</first>
+                            <last>${last_id}</last>
+                            <count>2</count>
+                        </set>
+                    </fin>
+                </iq>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(iq_result));
 
             await u.waitUntil(() => view.model.handleRetraction.calls.count() === 3);
@@ -934,8 +1003,8 @@ describe("Message Retractions", function () {
             const queryid = stanza.querySelector('query').getAttribute('queryid');
 
             const first_id = u.getUniqueId();
-            const tombstone = u.toStanza(`
-                <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
+            const tombstone = stx`
+                <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}" xmlns="jabber:client">
                     <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="stanza-id">
                         <forwarded xmlns="urn:xmpp:forward:0">
                             <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
@@ -945,14 +1014,13 @@ describe("Message Retractions", function () {
                             </message>
                         </forwarded>
                     </result>
-                </message>
-            `);
+                </message>`;
             spyOn(view.model, 'handleRetraction').and.callThrough();
             _converse.api.connection.get()._dataRecv(mock.createRequest(tombstone));
 
             const last_id = u.getUniqueId();
-            const retraction = u.toStanza(`
-                <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
+            const retraction = stx`
+                <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}" xmlns="jabber:client">
                     <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="${last_id}">
                         <forwarded xmlns="urn:xmpp:forward:0">
                             <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
@@ -963,16 +1031,19 @@ describe("Message Retractions", function () {
                             </message>
                         </forwarded>
                     </result>
-                </message>
-            `);
+                </message>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(retraction));
 
-            const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
-                .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
-                    .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
-                        .c('first', {'index': '0'}).t(first_id).up()
-                        .c('last').t(last_id).up()
-                        .c('count').t('2');
+            const iq_result = stx`
+                <iq type="result" id="${stanza.getAttribute('id')}" xmlns="jabber:client">
+                    <fin xmlns="urn:xmpp:mam:2">
+                        <set xmlns="http://jabber.org/protocol/rsm">
+                            <first index="0">${first_id}</first>
+                            <last>${last_id}</last>
+                            <count>2</count>
+                        </set>
+                    </fin>
+                </iq>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(iq_result));
 
             await u.waitUntil(() => view.model.messages.length === 1);
@@ -1009,8 +1080,8 @@ describe("Message Retractions", function () {
             const queryid = stanza.querySelector('query').getAttribute('queryid');
 
             const first_id = u.getUniqueId();
-            const tombstone = u.toStanza(`
-                <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
+            const tombstone = stx`
+                <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}" xmlns="jabber:client">
                     <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="stanza-id">
                         <forwarded xmlns="urn:xmpp:forward:0">
                             <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
@@ -1022,14 +1093,13 @@ describe("Message Retractions", function () {
                             </message>
                         </forwarded>
                     </result>
-                </message>
-            `);
+                </message>`;
             spyOn(view.model, 'handleModeration').and.callThrough();
             _converse.api.connection.get()._dataRecv(mock.createRequest(tombstone));
 
             const last_id = u.getUniqueId();
-            const retraction = u.toStanza(`
-                <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}">
+            const retraction = stx`
+                <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}" xmlns="jabber:client">
                     <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="${last_id}">
                         <forwarded xmlns="urn:xmpp:forward:0">
                             <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/>
@@ -1043,16 +1113,19 @@ describe("Message Retractions", function () {
                             </message>
                         </forwarded>
                     </result>
-                </message>
-            `);
+                </message>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(retraction));
 
-            const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')})
-                .c('fin', {'xmlns': 'urn:xmpp:mam:2'})
-                    .c('set',  {'xmlns': 'http://jabber.org/protocol/rsm'})
-                        .c('first', {'index': '0'}).t(first_id).up()
-                        .c('last').t(last_id).up()
-                        .c('count').t('2');
+            const iq_result = stx`
+                <iq type="result" id="${stanza.getAttribute('id')}" xmlns="jabber:client">
+                    <fin xmlns="urn:xmpp:mam:2">
+                        <set xmlns="http://jabber.org/protocol/rsm">
+                            <first index="0">${first_id}</first>
+                            <last>${last_id}</last>
+                            <count>2</count>
+                        </set>
+                    </fin>
+                </iq>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(iq_result));
 
             await u.waitUntil(() => view.model.messages.length);

+ 19 - 15
src/plugins/muc-views/tests/styling.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 
-const { u, $msg } = converse.env;
+const { u, stx } = converse.env;
 
 describe("An incoming groupchat Message", function () {
 
@@ -12,13 +12,15 @@ describe("An incoming groupchat Message", function () {
         await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
         const view = _converse.chatboxviews.get(muc_jid);
         const msg_text = "This *message mentions romeo*";
-        const msg = $msg({
-                from: 'lounge@montague.lit/gibson',
-                id: u.getUniqueId(),
-                to: 'romeo@montague.lit',
-                type: 'groupchat'
-            }).c('body').t(msg_text).up()
-                .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'23', 'end':'29', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).nodeTree;
+        const msg = stx`
+                <message xmlns="jabber:client"
+                         from="lounge@montague.lit/gibson"
+                         id="${u.getUniqueId()}"
+                         to="romeo@montague.lit"
+                         type="groupchat">
+                    <body>${msg_text}</body>
+                    <reference xmlns="urn:xmpp:reference:0" begin="23" end="29" type="mention" uri="xmpp:romeo@montague.lit"/>
+                </message>`;
         await view.model.handleMessageStanza(msg);
         const message = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         expect(message.classList.length).toEqual(1);
@@ -39,13 +41,15 @@ describe("An incoming groupchat Message", function () {
         await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
         const view = _converse.chatboxviews.get(muc_jid);
         const msg_text = "x_y_z_ hello";
-        const msg = $msg({
-                from: 'lounge@montague.lit/gibson',
-                id: u.getUniqueId(),
-                to: 'romeo@montague.lit',
-                type: 'groupchat'
-            }).c('body').t(msg_text).up()
-                .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'0', 'end':'6', 'type':'mention', 'uri':'xmpp:xyz@montague.lit'}).nodeTree;
+        const msg = stx`
+            <message xmlns="jabber:client"
+                        from="lounge@montague.lit/gibson"
+                        id="${u.getUniqueId()}"
+                        to="romeo@montague.lit"
+                        type="groupchat">
+                <body>${msg_text}</body>
+                <reference xmlns="urn:xmpp:reference:0" begin="0" end="6" type="mention" uri="xmpp:xyz@montague.lit"/>
+            </message>`;
         await view.model.handleMessageStanza(msg);
         const message = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         expect(message.classList.length).toEqual(1);

+ 41 - 41
src/plugins/muc-views/tests/unfurls.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 
-const { Strophe, u } = converse.env;
+const { Strophe, u, stx } = converse.env;
 
 describe("A Groupchat Message", function () {
 
@@ -13,19 +13,19 @@ describe("A Groupchat Message", function () {
         const unfurl_image_src = "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg";
         const unfurl_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
 
-        const message_stanza = u.toStanza(`
+        const message_stanza = stx`
             <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
                 <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body>
                 <active xmlns="http://jabber.org/protocol/chatstates"/>
                 <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
                 <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
                 <markable xmlns="urn:xmpp:chat-markers:0"/>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(message_stanza));
         const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
 
-        const metadata_stanza = u.toStanza(`
+        const metadata_stanza = stx`
             <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
                 <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" />
@@ -42,7 +42,7 @@ describe("A Groupchat Message", function () {
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:width" content="1280" />
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:height" content="720" />
                 </apply-to>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(metadata_stanza));
 
         const unfurl = await u.waitUntil(() => view.querySelector('converse-message-unfurl'));
@@ -58,19 +58,19 @@ describe("A Groupchat Message", function () {
         await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
         const view = _converse.chatboxviews.get(muc_jid);
 
-        const message_stanza = u.toStanza(`
+        const message_stanza = stx`
             <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
                 <body>https://mempool.space</body>
                 <active xmlns="http://jabber.org/protocol/chatstates"/>
                 <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
                 <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
                 <markable xmlns="urn:xmpp:chat-markers:0"/>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(message_stanza));
         const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         expect(el.textContent).toBe('https://mempool.space');
 
-        const metadata_stanza = u.toStanza(`
+        const metadata_stanza = stx`
             <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
                 <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://conversejs.org/dist/images/custom_emojis/converse.png" />
@@ -78,7 +78,7 @@ describe("A Groupchat Message", function () {
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:width" content="1000" />
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:height" content="500" />
                 </apply-to>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(metadata_stanza));
 
         const unfurl = await u.waitUntil(() => view.querySelector('converse-message-unfurl'));
@@ -94,19 +94,19 @@ describe("A Groupchat Message", function () {
         const unfurl_url = "https://giphy.com/gifs/giphyqa-4YY4DnqeUDBXNTcYMu";
         const gif_url = "https://media4.giphy.com/media/4YY4DnqeUDBXNTcYMu/giphy.gif?foo=bar";
 
-        const message_stanza = u.toStanza(`
+        const message_stanza = stx`
             <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
                 <body>${unfurl_url}</body>
                 <active xmlns="http://jabber.org/protocol/chatstates"/>
                 <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
                 <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
                 <markable xmlns="urn:xmpp:chat-markers:0"/>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(message_stanza));
         const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         expect(el.textContent).toBe(unfurl_url);
 
-        const metadata_stanza = u.toStanza(`
+        const metadata_stanza = stx`
             <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
                 <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Animated GIF" />
@@ -117,7 +117,7 @@ describe("A Groupchat Message", function () {
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:width" content="360" />
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:height" content="302" />
                 </apply-to>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(metadata_stanza));
 
         const unfurl = await u.waitUntil(() => view.querySelector('converse-message-unfurl'));
@@ -130,19 +130,19 @@ describe("A Groupchat Message", function () {
         await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
         const view = _converse.chatboxviews.get(muc_jid);
 
-        const message_stanza = u.toStanza(`
+        const message_stanza = stx`
             <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
                 <body>Check out https://www.youtube.com/watch?v=dQw4w9WgXcQ and https://duckduckgo.com</body>
                 <active xmlns="http://jabber.org/protocol/chatstates"/>
                 <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
                 <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
                 <markable xmlns="urn:xmpp:chat-markers:0"/>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(message_stanza));
         const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         expect(el.textContent).toBe('Check out https://www.youtube.com/watch?v=dQw4w9WgXcQ and https://duckduckgo.com');
 
-        let metadata_stanza = u.toStanza(`
+        let metadata_stanza = stx`
             <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
                 <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" />
@@ -159,11 +159,11 @@ describe("A Groupchat Message", function () {
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:width" content="1280" />
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:height" content="720" />
                 </apply-to>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(metadata_stanza));
         await u.waitUntil(() => view.querySelectorAll('converse-message-unfurl').length === 1);
 
-        metadata_stanza = u.toStanza(`
+        metadata_stanza = stx`
             <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
                 <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://duckduckgo.com" />
@@ -172,7 +172,7 @@ describe("A Groupchat Message", function () {
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="DuckDuckGo - Privacy, simplified." />
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="The Internet privacy company that empowers you to seamlessly take control of your personal information online, without any tradeoffs." />
                 </apply-to>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(metadata_stanza));
 
         await u.waitUntil(() => view.querySelectorAll('converse-message-unfurl').length === 2);
@@ -184,21 +184,21 @@ describe("A Groupchat Message", function () {
         await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
         const view = _converse.chatboxviews.get(muc_jid);
 
-        const message_stanza = u.toStanza(`
+        const message_stanza = stx`
             <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
                 <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body>
                 <active xmlns="http://jabber.org/protocol/chatstates"/>
                 <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
                 <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
                 <markable xmlns="urn:xmpp:chat-markers:0"/>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(message_stanza));
         const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
 
         spyOn(view.model, 'handleMetadataFastening').and.callThrough();
 
-        const metadata_stanza = u.toStanza(`
+        const metadata_stanza = stx`
             <message xmlns="jabber:client" from="${muc_jid}/arzu" to="${_converse.jid}" type="groupchat">
                 <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" />
@@ -207,7 +207,7 @@ describe("A Groupchat Message", function () {
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" />
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&amp;#39;s official music video for &quot;Never Gonna Give You Up&quot; Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." />
                 </apply-to>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(metadata_stanza));
 
         await u.waitUntil(() => view.model.handleMetadataFastening.calls.count());
@@ -226,21 +226,21 @@ describe("A Groupchat Message", function () {
         await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
         const view = _converse.chatboxviews.get(muc_jid);
 
-        const message_stanza = u.toStanza(`
+        const message_stanza = stx`
             <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
                 <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body>
                 <active xmlns="http://jabber.org/protocol/chatstates"/>
                 <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
                 <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
                 <markable xmlns="urn:xmpp:chat-markers:0"/>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(message_stanza));
         const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
 
         spyOn(view.model, 'handleMetadataFastening').and.callThrough();
 
-        const metadata_stanza = u.toStanza(`
+        const metadata_stanza = stx`
             <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
                 <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" />
@@ -249,7 +249,7 @@ describe("A Groupchat Message", function () {
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" />
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&amp;#39;s official music video for &quot;Never Gonna Give You Up&quot; Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." />
                 </apply-to>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(metadata_stanza));
 
         expect(view.querySelector('converse-message-unfurl')).toBe(null);
@@ -273,21 +273,21 @@ describe("A Groupchat Message", function () {
         await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
         const view = _converse.chatboxviews.get(muc_jid);
 
-        const message_stanza = u.toStanza(`
+        const message_stanza = stx`
             <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
                 <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body>
                 <active xmlns="http://jabber.org/protocol/chatstates"/>
                 <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
                 <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
                 <markable xmlns="urn:xmpp:chat-markers:0"/>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(message_stanza));
         const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
 
         spyOn(view.model, 'handleMetadataFastening').and.callThrough();
 
-        const metadata_stanza = u.toStanza(`
+        const metadata_stanza = stx`
             <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
                 <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" />
@@ -296,7 +296,7 @@ describe("A Groupchat Message", function () {
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" />
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&amp;#39;s official music video for &quot;Never Gonna Give You Up&quot; Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." />
                 </apply-to>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(metadata_stanza));
         _converse.api.connection.get()._dataRecv(mock.createRequest(metadata_stanza));
         _converse.api.connection.get()._dataRecv(mock.createRequest(metadata_stanza));
@@ -318,19 +318,19 @@ describe("A Groupchat Message", function () {
         await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
         const view = _converse.chatboxviews.get(muc_jid);
 
-        const message_stanza = u.toStanza(`
+        const message_stanza = stx`
             <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
                 <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body>
                 <active xmlns="http://jabber.org/protocol/chatstates"/>
                 <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
                 <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
                 <markable xmlns="urn:xmpp:chat-markers:0"/>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(message_stanza));
         const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
 
-        const metadata_stanza = u.toStanza(`
+        const metadata_stanza = stx`
             <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
                 <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" />
@@ -339,7 +339,7 @@ describe("A Groupchat Message", function () {
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" />
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&amp;#39;s official music video for &quot;Never Gonna Give You Up&quot; Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." />
                 </apply-to>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(metadata_stanza));
 
         await u.waitUntil(() => !view.querySelector('converse-message-unfurl'));
@@ -360,19 +360,19 @@ describe("A Groupchat Message", function () {
         await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
         const view = _converse.chatboxviews.get(muc_jid);
 
-        const message_stanza = u.toStanza(`
+        const message_stanza = stx`
             <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
                 <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body>
                 <active xmlns="http://jabber.org/protocol/chatstates"/>
                 <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/>
                 <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/>
                 <markable xmlns="urn:xmpp:chat-markers:0"/>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(message_stanza));
         const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
 
-        const metadata_stanza = u.toStanza(`
+        const metadata_stanza = stx`
             <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
                 <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04">
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />
@@ -381,7 +381,7 @@ describe("A Groupchat Message", function () {
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&amp;#39;s official music video for &quot;Never Gonna Give You Up&quot; Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." />
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:type" content="video.other" />
                 </apply-to>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(metadata_stanza));
 
         await u.waitUntil(() => view.querySelector('converse-message-unfurl'));
@@ -440,7 +440,7 @@ describe("A Groupchat Message", function () {
         const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         expect(el.textContent).toBe(unfurl_url);
 
-        const metadata_stanza = u.toStanza(`
+        const metadata_stanza = stx`
             <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat">
                 <apply-to xmlns="urn:xmpp:fasten:0" id="${msg.getAttribute('id')}">
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" />
@@ -457,7 +457,7 @@ describe("A Groupchat Message", function () {
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:width" content="1280" />
                     <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:height" content="720" />
                 </apply-to>
-            </message>`);
+            </message>`;
         _converse.api.connection.get()._dataRecv(mock.createRequest(metadata_stanza));
 
         const unfurl = await u.waitUntil(() => view.querySelector('converse-message-unfurl'));

+ 12 - 20
src/plugins/muc-views/tests/xss.js

@@ -1,7 +1,6 @@
 /*global mock, converse */
 
-const $pres = converse.env.$pres;
-const u = converse.env.utils;
+const { stx, u } = converse.env;
 
 describe("XSS", function () {
     describe("A Groupchat", function () {
@@ -10,30 +9,23 @@ describe("XSS", function () {
                 mock.initConverse([], {}, async function (_converse) {
 
             await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
-            /* <presence xmlns="jabber:client" to="jc@chat.example.org/converse.js-17184538"
-             *      from="oo@conference.chat.example.org/&lt;img src=&quot;x&quot; onerror=&quot;alert(123)&quot;/&gt;">
-             *   <x xmlns="http://jabber.org/protocol/muc#user">
-             *    <item jid="jc@chat.example.org/converse.js-17184538" affiliation="owner" role="moderator"/>
-             *    <status code="110"/>
-             *   </x>
-             * </presence>"
-             */
-            const presence = $pres({
-                    to:'romeo@montague.lit/pda',
-                    from:"lounge@montague.lit/&lt;img src=&quot;x&quot; onerror=&quot;alert(123)&quot;/&gt;"
-            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                .c('item').attrs({
-                    jid: 'someone@montague.lit',
-                    role: 'moderator',
-                }).up()
-                .c('status').attrs({code:'110'}).nodeTree;
+
+            const presence = stx`
+                <presence xmlns="jabber:client"
+                          to="romeo@montague.lit/pda"
+                          from="lounge@montague.lit/&lt;img src=&quot;x&quot; onerror=&quot;alert(123)&quot;/&gt;">
+                    <x xmlns="http://jabber.org/protocol/muc#user">
+                        <item jid="someone@montague.lit" role="moderator"/>
+                        <status code="110"/>
+                    </x>
+                </presence>`;
 
             _converse.api.connection.get()._dataRecv(mock.createRequest(presence));
             const view = _converse.chatboxviews.get('lounge@montague.lit');
             await u.waitUntil(() => view.querySelectorAll('.occupant-list .occupant-nick').length === 2);
             const occupants = view.querySelectorAll('.occupant-list li .occupant-nick');
             expect(occupants.length).toBe(2);
-            expect(occupants[0].textContent.trim()).toBe("&lt;img src=&quot;x&quot; onerror=&quot;alert(123)&quot;/&gt;");
+            expect(occupants[0].textContent.trim()).toBe('<img src="x" onerror="alert(123)"/>');
         }));
 
         it("escapes the subject before rendering it, to avoid JS-injection attacks",

Some files were not shown because too many files changed in this diff