瀏覽代碼

Move MUC and stanza utils into shared and plugin-specific files

JC Brand 4 年之前
父節點
當前提交
e80afbfe39

+ 1 - 0
karma.conf.js

@@ -49,6 +49,7 @@ module.exports = function(config) {
       { pattern: "spec/corrections.js", type: 'module' },
       { pattern: "spec/corrections.js", type: 'module' },
       { pattern: "spec/styling.js", type: 'module' },
       { pattern: "spec/styling.js", type: 'module' },
       { pattern: "spec/receipts.js", type: 'module' },
       { pattern: "spec/receipts.js", type: 'module' },
+      { pattern: "spec/markers.js", type: 'module' },
       { pattern: "spec/muc_messages.js", type: 'module' },
       { pattern: "spec/muc_messages.js", type: 'module' },
       { pattern: "spec/me-messages.js", type: 'module' },
       { pattern: "spec/me-messages.js", type: 'module' },
       { pattern: "spec/mentions.js", type: 'module' },
       { pattern: "spec/mentions.js", type: 'module' },

+ 27 - 36
spec/chatbox.js

@@ -1007,19 +1007,17 @@ describe("Chatboxes", function () {
             const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
             const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
                   msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
                   msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
 
 
-            const sent_stanzas = [];
-            spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s));
-
             const view = await mock.openChatBoxFor(_converse, sender_jid)
             const view = await mock.openChatBoxFor(_converse, sender_jid)
-            spyOn(view.model, 'sendMarker').and.callThrough();
+            const sent_stanzas = [];
+            spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
             view.model.save('scrolled', true);
             view.model.save('scrolled', true);
             await _converse.handleMessageStanza(msg);
             await _converse.handleMessageStanza(msg);
             await u.waitUntil(() => view.model.messages.length);
             await u.waitUntil(() => view.model.messages.length);
             expect(view.model.get('num_unread')).toBe(1);
             expect(view.model.get('num_unread')).toBe(1);
             const msgid = view.model.messages.last().get('id');
             const msgid = view.model.messages.last().get('id');
             expect(view.model.get('first_unread_id')).toBe(msgid);
             expect(view.model.get('first_unread_id')).toBe(msgid);
-            await u.waitUntil(() => view.model.sendMarker.calls.count() === 1);
-            expect(sent_stanzas[0].nodeTree.querySelector('received')).toBeDefined();
+            await u.waitUntil(() => sent_stanzas.length);
+            expect(sent_stanzas[0].querySelector('received')).toBeDefined();
             done();
             done();
         }));
         }));
 
 
@@ -1031,15 +1029,14 @@ describe("Chatboxes", function () {
             await mock.waitForRoster(_converse, 'current', 1);
             await mock.waitForRoster(_converse, 'current', 1);
             const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
             const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
             const msg = mock.createChatMessage(_converse, sender_jid, 'This message will be read');
             const msg = mock.createChatMessage(_converse, sender_jid, 'This message will be read');
-            const sent_stanzas = [];
-            spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s));
             await mock.openChatBoxFor(_converse, sender_jid);
             await mock.openChatBoxFor(_converse, sender_jid);
+            const sent_stanzas = [];
+            spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
             const chatbox = _converse.chatboxes.get(sender_jid);
             const chatbox = _converse.chatboxes.get(sender_jid);
-            spyOn(chatbox, 'sendMarker').and.callThrough();
             await _converse.handleMessageStanza(msg);
             await _converse.handleMessageStanza(msg);
             expect(chatbox.get('num_unread')).toBe(0);
             expect(chatbox.get('num_unread')).toBe(0);
-            await u.waitUntil(() => chatbox.sendMarker.calls.count() === 2);
-            expect(sent_stanzas[1].nodeTree.querySelector('displayed')).toBeDefined();
+            await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 2);
+            expect(sent_stanzas[1].querySelector('displayed')).toBeDefined();
             done();
             done();
         }));
         }));
 
 
@@ -1053,12 +1050,10 @@ describe("Chatboxes", function () {
             const msgFactory = function () {
             const msgFactory = function () {
                 return mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
                 return mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
             };
             };
-
-            const sent_stanzas = [];
-            spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s));
             await mock.openChatBoxFor(_converse, sender_jid);
             await mock.openChatBoxFor(_converse, sender_jid);
+            const sent_stanzas = [];
+            spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
             const chatbox = _converse.chatboxes.get(sender_jid);
             const chatbox = _converse.chatboxes.get(sender_jid);
-            spyOn(chatbox, 'sendMarker').and.callThrough();
             _converse.windowState = 'hidden';
             _converse.windowState = 'hidden';
             const msg = msgFactory();
             const msg = msgFactory();
             _converse.handleMessageStanza(msg);
             _converse.handleMessageStanza(msg);
@@ -1066,8 +1061,8 @@ describe("Chatboxes", function () {
             expect(chatbox.get('num_unread')).toBe(1);
             expect(chatbox.get('num_unread')).toBe(1);
             const msgid = chatbox.messages.last().get('id');
             const msgid = chatbox.messages.last().get('id');
             expect(chatbox.get('first_unread_id')).toBe(msgid);
             expect(chatbox.get('first_unread_id')).toBe(msgid);
-            await u.waitUntil(() => chatbox.sendMarker.calls.count() === 1);
-            expect(sent_stanzas[0].nodeTree.querySelector('received')).toBeDefined();
+            await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length);
+            expect(sent_stanzas[0].querySelector('received')).toBeDefined();
             done();
             done();
         }));
         }));
 
 
@@ -1079,11 +1074,10 @@ describe("Chatboxes", function () {
             await mock.waitForRoster(_converse, 'current', 1);
             await mock.waitForRoster(_converse, 'current', 1);
             const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
             const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
             const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
             const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
-            const sent_stanzas = [];
-            spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s));
             await mock.openChatBoxFor(_converse, sender_jid);
             await mock.openChatBoxFor(_converse, sender_jid);
+            const sent_stanzas = [];
+            spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
             const chatbox = _converse.chatboxes.get(sender_jid);
             const chatbox = _converse.chatboxes.get(sender_jid);
-            spyOn(chatbox, 'sendMarker').and.callThrough();
             chatbox.save('scrolled', true);
             chatbox.save('scrolled', true);
             _converse.windowState = 'hidden';
             _converse.windowState = 'hidden';
             const msg = msgFactory();
             const msg = msgFactory();
@@ -1092,8 +1086,8 @@ describe("Chatboxes", function () {
             expect(chatbox.get('num_unread')).toBe(1);
             expect(chatbox.get('num_unread')).toBe(1);
             const msgid = chatbox.messages.last().get('id');
             const msgid = chatbox.messages.last().get('id');
             expect(chatbox.get('first_unread_id')).toBe(msgid);
             expect(chatbox.get('first_unread_id')).toBe(msgid);
-            await u.waitUntil(() => chatbox.sendMarker.calls.count() === 1);
-            expect(sent_stanzas[0].nodeTree.querySelector('received')).toBeDefined();
+            await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 1);
+            expect(sent_stanzas[0].querySelector('received')).toBeDefined();
             done();
             done();
         }));
         }));
 
 
@@ -1105,11 +1099,10 @@ describe("Chatboxes", function () {
             await mock.waitForRoster(_converse, 'current', 1);
             await mock.waitForRoster(_converse, 'current', 1);
             const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
             const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
             const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
             const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
-            const sent_stanzas = [];
-            spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s));
             await mock.openChatBoxFor(_converse, sender_jid);
             await mock.openChatBoxFor(_converse, sender_jid);
+            const sent_stanzas = [];
+            spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
             const chatbox = _converse.chatboxes.get(sender_jid);
             const chatbox = _converse.chatboxes.get(sender_jid);
-            spyOn(chatbox, 'sendMarker').and.callThrough();
             _converse.windowState = 'hidden';
             _converse.windowState = 'hidden';
             const msg = msgFactory();
             const msg = msgFactory();
             _converse.handleMessageStanza(msg);
             _converse.handleMessageStanza(msg);
@@ -1117,12 +1110,12 @@ describe("Chatboxes", function () {
             expect(chatbox.get('num_unread')).toBe(1);
             expect(chatbox.get('num_unread')).toBe(1);
             const msgid = chatbox.messages.last().get('id');
             const msgid = chatbox.messages.last().get('id');
             expect(chatbox.get('first_unread_id')).toBe(msgid);
             expect(chatbox.get('first_unread_id')).toBe(msgid);
-            await u.waitUntil(() => chatbox.sendMarker.calls.count() === 1);
-            expect(sent_stanzas[0].nodeTree.querySelector('received')).toBeDefined();
+            await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 1);
+            expect(sent_stanzas[0].querySelector('received')).toBeDefined();
             _converse.saveWindowState({'type': 'focus'});
             _converse.saveWindowState({'type': 'focus'});
             expect(chatbox.get('num_unread')).toBe(0);
             expect(chatbox.get('num_unread')).toBe(0);
-            await u.waitUntil(() => chatbox.sendMarker.calls.count() === 2);
-            expect(sent_stanzas[1].nodeTree.querySelector('displayed')).toBeDefined();
+            await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 2);
+            expect(sent_stanzas[1].querySelector('displayed')).toBeDefined();
             done();
             done();
         }));
         }));
 
 
@@ -1134,11 +1127,10 @@ describe("Chatboxes", function () {
             await mock.waitForRoster(_converse, 'current', 1);
             await mock.waitForRoster(_converse, 'current', 1);
             const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
             const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
             const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
             const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
-            const sent_stanzas = [];
-            spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s));
             await mock.openChatBoxFor(_converse, sender_jid);
             await mock.openChatBoxFor(_converse, sender_jid);
+            const sent_stanzas = [];
+            spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
             const chatbox = _converse.chatboxes.get(sender_jid);
             const chatbox = _converse.chatboxes.get(sender_jid);
-            spyOn(chatbox, 'sendMarker').and.callThrough();
             chatbox.save('scrolled', true);
             chatbox.save('scrolled', true);
             _converse.windowState = 'hidden';
             _converse.windowState = 'hidden';
             const msg = msgFactory();
             const msg = msgFactory();
@@ -1147,13 +1139,12 @@ describe("Chatboxes", function () {
             expect(chatbox.get('num_unread')).toBe(1);
             expect(chatbox.get('num_unread')).toBe(1);
             const msgid = chatbox.messages.last().get('id');
             const msgid = chatbox.messages.last().get('id');
             expect(chatbox.get('first_unread_id')).toBe(msgid);
             expect(chatbox.get('first_unread_id')).toBe(msgid);
-            await u.waitUntil(() => chatbox.sendMarker.calls.count() === 1);
-            expect(sent_stanzas[0].nodeTree.querySelector('received')).toBeDefined();
+            await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 1);
+            expect(sent_stanzas[0].querySelector('received')).toBeDefined();
             _converse.saveWindowState({'type': 'focus'});
             _converse.saveWindowState({'type': 'focus'});
             await u.waitUntil(() => chatbox.get('num_unread') === 1);
             await u.waitUntil(() => chatbox.get('num_unread') === 1);
             expect(chatbox.get('first_unread_id')).toBe(msgid);
             expect(chatbox.get('first_unread_id')).toBe(msgid);
-            await u.waitUntil(() => chatbox.sendMarker.calls.count() === 1);
-            expect(sent_stanzas[0].nodeTree.querySelector('received')).toBeDefined();
+            expect(sent_stanzas[0].querySelector('received')).toBeDefined();
             done();
             done();
         }));
         }));
     });
     });

+ 186 - 0
spec/markers.js

@@ -0,0 +1,186 @@
+/*global mock, converse */
+
+const Strophe = converse.env.Strophe;
+const u = converse.env.utils;
+// See: https://xmpp.org/rfcs/rfc3921.html
+
+
+describe("A XEP-0333 Chat Marker", function () {
+
+    it("is sent when a markable message is received from a roster contact",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current', 1);
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid);
+        const msgid = u.getUniqueId();
+        const stanza = u.toStanza(`
+            <message from='${contact_jid}'
+                id='${msgid}'
+                type="chat"
+                to='${_converse.jid}'>
+              <body>My lord, dispatch; read o'er these articles.</body>
+              <markable xmlns='urn:xmpp:chat-markers:0'/>
+            </message>`);
+
+        const sent_stanzas = [];
+        spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => sent_stanzas.length === 2);
+        expect(Strophe.serialize(sent_stanzas[0])).toBe(
+            `<message from="romeo@montague.lit/orchard" `+
+                    `id="${sent_stanzas[0].getAttribute('id')}" `+
+                    `to="${contact_jid}" type="chat" xmlns="jabber:client">`+
+            `<received id="${msgid}" xmlns="urn:xmpp:chat-markers:0"/>`+
+            `</message>`);
+        done();
+    }));
+
+    it("is not sent when a markable message is received from someone not on the roster",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {'allow_non_roster_messaging': true},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current', 0);
+        const contact_jid = 'someone@montague.lit';
+        const msgid = u.getUniqueId();
+        const stanza = u.toStanza(`
+            <message from='${contact_jid}'
+                id='${msgid}'
+                type="chat"
+                to='${_converse.jid}'>
+              <body>My lord, dispatch; read o'er these articles.</body>
+              <markable xmlns='urn:xmpp:chat-markers:0'/>
+            </message>`);
+
+        const sent_stanzas = [];
+        spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s));
+        await _converse.handleMessageStanza(stanza);
+        const sent_messages = sent_stanzas
+            .map(s => s?.nodeTree ?? s)
+            .filter(e => e.nodeName === 'message');
+
+        await u.waitUntil(() => sent_messages.length === 2);
+        expect(Strophe.serialize(sent_messages[0])).toBe(
+            `<message id="${sent_messages[0].getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+
+                `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                `<no-store xmlns="urn:xmpp:hints"/>`+
+                `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+            `</message>`
+        );
+        done();
+    }));
+
+    it("is ignored if it's a carbon copy of one that I sent from a different client",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current', 1);
+        await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]);
+
+        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+        await mock.openChatBoxFor(_converse, contact_jid);
+        const view = _converse.api.chatviews.get(contact_jid);
+
+        let stanza = u.toStanza(`
+            <message xmlns="jabber:client"
+                     to="${_converse.bare_jid}"
+                     type="chat"
+                     id="2e972ea0-0050-44b7-a830-f6638a2595b3"
+                     from="${contact_jid}">
+                <body>😊</body>
+                <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>`);
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(view.model.messages.length).toBe(1);
+
+        stanza = u.toStanza(
+            `<message xmlns="jabber:client" to="${_converse.bare_jid}" type="chat" from="${contact_jid}">
+                <sent xmlns="urn:xmpp:carbons:2">
+                    <forwarded xmlns="urn:xmpp:forward:0">
+                        <message xmlns="jabber:client" to="${contact_jid}" type="chat" from="${_converse.bare_jid}/other-resource">
+                            <received xmlns="urn:xmpp:chat-markers:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
+                            <store xmlns="urn:xmpp:hints"/>
+                            <stanza-id xmlns="urn:xmpp:sid:0" id="F4TC6CvHwzqRbeHb" by="${_converse.bare_jid}"/>
+                        </message>
+                    </forwarded>
+                </sent>
+            </message>`);
+        spyOn(_converse.api, "trigger").and.callThrough();
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => _converse.api.trigger.calls.count(), 500);
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(view.model.messages.length).toBe(1);
+        done();
+    }));
+
+
+    it("may be returned for a MUC message",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+            async function (done, _converse) {
+
+        await mock.waitForRoster(_converse, 'current');
+        const muc_jid = 'lounge@montague.lit';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        const view = _converse.api.chatviews.get(muc_jid);
+        const textarea = view.el.querySelector('textarea.chat-textarea');
+        textarea.value = 'But soft, what light through yonder airlock breaks?';
+        view.onKeyDown({
+            target: textarea,
+            preventDefault: function preventDefault () {},
+            keyCode: 13 // Enter
+        });
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(view.el.querySelector('.chat-msg .chat-msg__body').textContent.trim())
+            .toBe("But soft, what light through yonder airlock breaks?");
+
+        const msg_obj = view.model.messages.at(0);
+        let stanza = u.toStanza(`
+            <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>`);
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
+        expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+
+        stanza = u.toStanza(`
+            <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>`);
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+
+        stanza = u.toStanza(`
+            <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>`);
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+
+        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+        expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+
+        stanza = u.toStanza(`
+            <message xml:lang="en" to="romeo@montague.lit/orchard"
+                     from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
+                <body>'tis I!</body>
+                <markable xmlns="urn:xmpp:chat-markers:0"/>
+            </message>`);
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 2);
+        expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+        done();
+    }));
+});

+ 0 - 119
spec/messages.js

@@ -1548,122 +1548,3 @@ describe("A Chat Message", function () {
         }));
         }));
     });
     });
 });
 });
-
-describe("A XEP-0333 Chat Marker", function () {
-
-    it("is sent when a markable message is received from a roster contact",
-        mock.initConverse(
-            ['rosterGroupsFetched'], {},
-            async function (done, _converse) {
-
-        await mock.waitForRoster(_converse, 'current', 1);
-        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-        await mock.openChatBoxFor(_converse, contact_jid);
-        const view = _converse.api.chatviews.get(contact_jid);
-        const msgid = u.getUniqueId();
-        const stanza = u.toStanza(`
-            <message from='${contact_jid}'
-                id='${msgid}'
-                type="chat"
-                to='${_converse.jid}'>
-              <body>My lord, dispatch; read o'er these articles.</body>
-              <markable xmlns='urn:xmpp:chat-markers:0'/>
-            </message>`);
-
-        const sent_stanzas = [];
-        spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s));
-        spyOn(view.model, 'sendMarker').and.callThrough();
-        _converse.connection._dataRecv(mock.createRequest(stanza));
-        await u.waitUntil(() => view.model.sendMarker.calls.count() === 2);
-        expect(Strophe.serialize(sent_stanzas[0])).toBe(
-            `<message from="romeo@montague.lit/orchard" `+
-                    `id="${sent_stanzas[0].nodeTree.getAttribute('id')}" `+
-                    `to="${contact_jid}" type="chat" xmlns="jabber:client">`+
-            `<received id="${msgid}" xmlns="urn:xmpp:chat-markers:0"/>`+
-            `</message>`);
-        done();
-    }));
-
-    it("is not sent when a markable message is received from someone not on the roster",
-        mock.initConverse(
-            ['rosterGroupsFetched'], {'allow_non_roster_messaging': true},
-            async function (done, _converse) {
-
-        await mock.waitForRoster(_converse, 'current', 0);
-        const contact_jid = 'someone@montague.lit';
-        const msgid = u.getUniqueId();
-        const stanza = u.toStanza(`
-            <message from='${contact_jid}'
-                id='${msgid}'
-                type="chat"
-                to='${_converse.jid}'>
-              <body>My lord, dispatch; read o'er these articles.</body>
-              <markable xmlns='urn:xmpp:chat-markers:0'/>
-            </message>`);
-
-        const sent_stanzas = [];
-        spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s));
-        await _converse.handleMessageStanza(stanza);
-        const sent_messages = sent_stanzas
-            .map(s => _.isElement(s) ? s : s.nodeTree)
-            .filter(e => e.nodeName === 'message');
-
-        await u.waitUntil(() => sent_messages.length === 2);
-        expect(Strophe.serialize(sent_messages[0])).toBe(
-            `<message id="${sent_messages[0].getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+
-                `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                `<no-store xmlns="urn:xmpp:hints"/>`+
-                `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
-            `</message>`
-        );
-        done();
-    }));
-
-    it("is ignored if it's a carbon copy of one that I sent from a different client",
-        mock.initConverse(
-            ['rosterGroupsFetched'], {},
-            async function (done, _converse) {
-
-        await mock.waitForRoster(_converse, 'current', 1);
-        await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]);
-
-        const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-        await mock.openChatBoxFor(_converse, contact_jid);
-        const view = _converse.api.chatviews.get(contact_jid);
-
-        let stanza = u.toStanza(`
-            <message xmlns="jabber:client"
-                     to="${_converse.bare_jid}"
-                     type="chat"
-                     id="2e972ea0-0050-44b7-a830-f6638a2595b3"
-                     from="${contact_jid}">
-                <body>😊</body>
-                <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>`);
-        _converse.connection._dataRecv(mock.createRequest(stanza));
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
-        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-        expect(view.model.messages.length).toBe(1);
-
-        stanza = u.toStanza(
-            `<message xmlns="jabber:client" to="${_converse.bare_jid}" type="chat" from="${contact_jid}">
-                <sent xmlns="urn:xmpp:carbons:2">
-                    <forwarded xmlns="urn:xmpp:forward:0">
-                        <message xmlns="jabber:client" to="${contact_jid}" type="chat" from="${_converse.bare_jid}/other-resource">
-                            <received xmlns="urn:xmpp:chat-markers:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
-                            <store xmlns="urn:xmpp:hints"/>
-                            <stanza-id xmlns="urn:xmpp:sid:0" id="F4TC6CvHwzqRbeHb" by="${_converse.bare_jid}"/>
-                        </message>
-                    </forwarded>
-                </sent>
-            </message>`);
-        spyOn(_converse.api, "trigger").and.callThrough();
-        _converse.connection._dataRecv(mock.createRequest(stanza));
-        await u.waitUntil(() => _converse.api.trigger.calls.count(), 500);
-        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-        expect(view.model.messages.length).toBe(1);
-        done();
-    }));
-});

+ 14 - 71
spec/muc_messages.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 /*global mock, converse */
 
 
-const { Promise, Strophe, $msg, $pres, sizzle, stanza_utils } = converse.env;
+const { Promise, Strophe, $msg, $pres, sizzle } = converse.env;
 const u = converse.env.utils;
 const u = converse.env.utils;
 const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
 const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
 
 
@@ -620,86 +620,29 @@ describe("A Groupchat Message", function () {
         await new Promise(resolve => view.model.messages.once('rendered', resolve));
         await new Promise(resolve => view.model.messages.once('rendered', resolve));
         expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
         expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
 
 
-        const msg_obj = view.model.messages.at(0);
-        const stanza = u.toStanza(`
-            <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>`);
-        spyOn(stanza_utils, "parseMUCMessage").and.callThrough();
-        _converse.connection._dataRecv(mock.createRequest(stanza));
-        await u.waitUntil(() => stanza_utils.parseMUCMessage.calls.count() === 1);
-        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-        expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
-        done();
-    }));
-
-    it("can cause a chat marker to be returned",
-        mock.initConverse(
-            ['rosterGroupsFetched'], {},
-            async function (done, _converse) {
-
-        await mock.waitForRoster(_converse, 'current');
-        const muc_jid = 'lounge@montague.lit';
-        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-        const view = _converse.api.chatviews.get(muc_jid);
-        const textarea = view.el.querySelector('textarea.chat-textarea');
-        textarea.value = 'But soft, what light through yonder airlock breaks?';
-        view.onKeyDown({
-            target: textarea,
-            preventDefault: function preventDefault () {},
-            keyCode: 13 // Enter
-        });
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
-        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-        expect(view.el.querySelector('.chat-msg .chat-msg__body').textContent.trim())
-            .toBe("But soft, what light through yonder airlock breaks?");
-
         const msg_obj = view.model.messages.at(0);
         const msg_obj = view.model.messages.at(0);
         let stanza = u.toStanza(`
         let stanza = u.toStanza(`
-            <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>`);
-        const stanza_utils = converse.env.stanza_utils;
-        spyOn(stanza_utils, "getChatMarker").and.callThrough();
-        _converse.connection._dataRecv(mock.createRequest(stanza));
-        await u.waitUntil(() => stanza_utils.getChatMarker.calls.count() === 1);
-        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-        expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
-
-        stanza = u.toStanza(`
-            <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 xmlns="jabber:client"
+                     from="${msg_obj.get('from')}"
+                     to="${_converse.connection.jid}"
+                     type="groupchat">
+                <body>${msg_obj.get('message')}</body>
+                <stanza-id xmlns="urn:xmpp:sid:0"
+                           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>`);
-        _converse.connection._dataRecv(mock.createRequest(stanza));
-        await u.waitUntil(() => stanza_utils.getChatMarker.calls.count() === 2);
-        expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-        expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+        await view.model.handleMessageStanza(stanza);
+        await u.waitUntil(() => view.model.messages.last().get('received'));
 
 
         stanza = u.toStanza(`
         stanza = u.toStanza(`
             <message xml:lang="en" to="romeo@montague.lit/orchard"
             <message xml:lang="en" to="romeo@montague.lit/orchard"
                      from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
                      from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
-                <acknowledged xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/>
+                <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.connection._dataRecv(mock.createRequest(stanza));
         _converse.connection._dataRecv(mock.createRequest(stanza));
-
-        await u.waitUntil(() => stanza_utils.getChatMarker.calls.count() === 3);
         expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
         expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-        expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
-
-        stanza = u.toStanza(`
-            <message xml:lang="en" to="romeo@montague.lit/orchard"
-                     from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
-                <body>'tis I!</body>
-                <markable xmlns="urn:xmpp:chat-markers:0"/>
-            </message>`);
-        _converse.connection._dataRecv(mock.createRequest(stanza));
-        await u.waitUntil(() => stanza_utils.getChatMarker.calls.count() === 4);
-        await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 2);
-        expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
         done();
         done();
     }));
     }));
 });
 });

+ 0 - 2
src/headless/core.js

@@ -12,7 +12,6 @@ import pluggable from 'pluggable.js/src/pluggable';
 import syncDriver from 'localforage-webextensionstorage-driver/sync';
 import syncDriver from 'localforage-webextensionstorage-driver/sync';
 import localDriver from 'localforage-webextensionstorage-driver/local';
 import localDriver from 'localforage-webextensionstorage-driver/local';
 import sizzle from 'sizzle';
 import sizzle from 'sizzle';
-import stanza_utils from "@converse/headless/utils/stanza";
 import u from '@converse/headless/utils/core';
 import u from '@converse/headless/utils/core';
 import { Collection } from "@converse/skeletor/src/collection";
 import { Collection } from "@converse/skeletor/src/collection";
 import { Connection, MockConnection } from '@converse/headless/shared/connection.js';
 import { Connection, MockConnection } from '@converse/headless/shared/connection.js';
@@ -1654,7 +1653,6 @@ Object.assign(converse, {
         log,
         log,
         sizzle,
         sizzle,
         sprintf,
         sprintf,
-        stanza_utils,
         u,
         u,
     }
     }
 });
 });

+ 2 - 2
src/headless/plugins/adhoc.js

@@ -1,7 +1,7 @@
 import { converse } from "../core.js";
 import { converse } from "../core.js";
 import log from "@converse/headless/log";
 import log from "@converse/headless/log";
 import sizzle from 'sizzle';
 import sizzle from 'sizzle';
-import st from "../utils/stanza";
+import { getAttributes } from '@converse/headless/shared/parsers';
 
 
 const { Strophe } = converse.env;
 const { Strophe } = converse.env;
 let _converse, api;
 let _converse, api;
@@ -11,7 +11,7 @@ Strophe.addNamespace('ADHOC', 'http://jabber.org/protocol/commands');
 
 
 function parseForCommands (stanza) {
 function parseForCommands (stanza) {
     const items = sizzle(`query[xmlns="${Strophe.NS.DISCO_ITEMS}"][node="${Strophe.NS.ADHOC}"] item`, stanza);
     const items = sizzle(`query[xmlns="${Strophe.NS.DISCO_ITEMS}"][node="${Strophe.NS.ADHOC}"] item`, stanza);
-    return items.map(st.getAttributes)
+    return items.map(getAttributes)
 }
 }
 
 
 
 

+ 4 - 3
src/headless/plugins/chat/index.js

@@ -8,9 +8,10 @@ import MessageMixin from './message.js';
 import ModelWithContact from './model-with-contact.js';
 import ModelWithContact from './model-with-contact.js';
 import chat_api from './api.js';
 import chat_api from './api.js';
 import log from '../../log.js';
 import log from '../../log.js';
-import st from '../../utils/stanza';
 import { Collection } from "@converse/skeletor/src/collection";
 import { Collection } from "@converse/skeletor/src/collection";
 import { _converse, api, converse } from '../../core.js';
 import { _converse, api, converse } from '../../core.js';
+import { isServerMessage, } from '@converse/headless/shared/parsers';
+import { parseMessage } from './parsers.js';
 
 
 const { Strophe, sizzle, utils } = converse.env;
 const { Strophe, sizzle, utils } = converse.env;
 const u = converse.env.utils;
 const u = converse.env.utils;
@@ -74,12 +75,12 @@ converse.plugins.add('converse-chat', {
          * @param { MessageAttributes } attrs - The message attributes
          * @param { MessageAttributes } attrs - The message attributes
          */
          */
         _converse.handleMessageStanza = async function (stanza) {
         _converse.handleMessageStanza = async function (stanza) {
-            if (st.isServerMessage(stanza)) {
+            if (isServerMessage(stanza)) {
                 // Prosody sends headline messages with type `chat`, so we need to filter them out here.
                 // Prosody sends headline messages with type `chat`, so we need to filter them out here.
                 const from = stanza.getAttribute('from');
                 const from = stanza.getAttribute('from');
                 return log.info(`handleMessageStanza: Ignoring incoming server message from JID: ${from}`);
                 return log.info(`handleMessageStanza: Ignoring incoming server message from JID: ${from}`);
             }
             }
-            const attrs = await st.parseMessage(stanza, _converse);
+            const attrs = await parseMessage(stanza, _converse);
             if (u.isErrorObject(attrs)) {
             if (u.isErrorObject(attrs)) {
                 attrs.stanza && log.error(attrs.stanza);
                 attrs.stanza && log.error(attrs.stanza);
                 return log.error(attrs.message);
                 return log.error(attrs.message);

+ 10 - 26
src/headless/plugins/chat/model.js

@@ -1,10 +1,11 @@
 import ModelWithContact from './model-with-contact.js';
 import ModelWithContact from './model-with-contact.js';
 import filesize from "filesize";
 import filesize from "filesize";
-import log from "../../log.js";
-import st from "../../utils/stanza";
+import log from '@converse/headless/log';
 import { Model } from '@converse/skeletor/src/model.js';
 import { Model } from '@converse/skeletor/src/model.js';
 import { _converse, api, converse } from "../../core.js";
 import { _converse, api, converse } from "../../core.js";
 import { find, isMatch, isObject, pick } from "lodash-es";
 import { find, isMatch, isObject, pick } from "lodash-es";
+import { parseMessage } from './parsers.js';
+import { sendMarker } from '@converse/headless/shared/actions';
 
 
 const { Strophe, $msg } = converse.env;
 const { Strophe, $msg } = converse.env;
 
 
@@ -130,7 +131,7 @@ const ChatBox = ModelWithContact.extend({
 
 
     async handleErrorMessageStanza (stanza) {
     async handleErrorMessageStanza (stanza) {
         const { __ } = _converse;
         const { __ } = _converse;
-        const attrs = await st.parseMessage(stanza, _converse);
+        const attrs = await parseMessage(stanza, _converse);
         if (!await this.shouldShowErrorMessage(attrs)) {
         if (!await this.shouldShowErrorMessage(attrs)) {
             return;
             return;
         }
         }
@@ -392,7 +393,7 @@ const ChatBox = ModelWithContact.extend({
      * @private
      * @private
      * @method _converse.ChatBox#findDanglingRetraction
      * @method _converse.ChatBox#findDanglingRetraction
      * @param { object } attrs - Attributes representing a received
      * @param { object } attrs - Attributes representing a received
-     *  message, as returned by {@link st.parseMessage}
+     *  message, as returned by {@link parseMessage}
      * @returns { _converse.Message }
      * @returns { _converse.Message }
      */
      */
     findDanglingRetraction (attrs) {
     findDanglingRetraction (attrs) {
@@ -419,7 +420,7 @@ const ChatBox = ModelWithContact.extend({
      * @private
      * @private
      * @method _converse.ChatBox#handleRetraction
      * @method _converse.ChatBox#handleRetraction
      * @param { object } attrs - Attributes representing a received
      * @param { object } attrs - Attributes representing a received
-     *  message, as returned by {@link st.parseMessage}
+     *  message, as returned by {@link parseMessage}
      * @returns { Boolean } Returns `true` or `false` depending on
      * @returns { Boolean } Returns `true` or `false` depending on
      *  whether a message was retracted or not.
      *  whether a message was retracted or not.
      */
      */
@@ -459,7 +460,7 @@ const ChatBox = ModelWithContact.extend({
      * @private
      * @private
      * @method _converse.ChatBox#handleCorrection
      * @method _converse.ChatBox#handleCorrection
      * @param { object } attrs - Attributes representing a received
      * @param { object } attrs - Attributes representing a received
-     *  message, as returned by {@link st.parseMessage}
+     *  message, as returned by {@link parseMessage}
      * @returns { _converse.Message|undefined } Returns the corrected
      * @returns { _converse.Message|undefined } Returns the corrected
      *  message or `undefined` if not applicable.
      *  message or `undefined` if not applicable.
      */
      */
@@ -497,7 +498,7 @@ const ChatBox = ModelWithContact.extend({
      * @private
      * @private
      * @method _converse.ChatBox#getDuplicateMessage
      * @method _converse.ChatBox#getDuplicateMessage
      * @param { object } attrs - Attributes representing a received
      * @param { object } attrs - Attributes representing a received
-     *  message, as returned by {@link st.parseMessage}
+     *  message, as returned by {@link parseMessage}
      * @returns {Promise<_converse.Message>}
      * @returns {Promise<_converse.Message>}
      */
      */
     getDuplicateMessage (attrs) {
     getDuplicateMessage (attrs) {
@@ -604,27 +605,10 @@ const ChatBox = ModelWithContact.extend({
         if (!msg) return;
         if (!msg) return;
         if (msg?.get('is_markable') || force) {
         if (msg?.get('is_markable') || force) {
             const from_jid = Strophe.getBareJidFromJid(msg.get('from'));
             const from_jid = Strophe.getBareJidFromJid(msg.get('from'));
-            this.sendMarker(from_jid, msg.get('msgid'), type, msg.get('type'));
+            sendMarker(from_jid, msg.get('msgid'), type, msg.get('type'));
         }
         }
     },
     },
 
 
-    /**
-     * Send out a XEP-0333 chat marker
-     * @param { String } to_jid
-     * @param { String } id - The id of the message being marked
-     * @param { String } type - The marker type
-     * @param { String } msg_type
-     */
-    sendMarker (to_jid, id, type, msg_type) {
-        const stanza = $msg({
-            'from': _converse.connection.jid,
-            'id': u.getUniqueId(),
-            'to': to_jid,
-            'type': msg_type ? msg_type : 'chat'
-        }).c(type, {'xmlns': Strophe.NS.MARKERS, 'id': id});
-        api.send(stanza);
-    },
-
     handleChatMarker (attrs) {
     handleChatMarker (attrs) {
         const to_bare_jid = Strophe.getBareJidFromJid(attrs.to);
         const to_bare_jid = Strophe.getBareJidFromJid(attrs.to);
         if (to_bare_jid !== _converse.bare_jid) {
         if (to_bare_jid !== _converse.bare_jid) {
@@ -632,7 +616,7 @@ const ChatBox = ModelWithContact.extend({
         }
         }
         if (attrs.is_markable) {
         if (attrs.is_markable) {
             if (this.contact && !attrs.is_archived && !attrs.is_carbon) {
             if (this.contact && !attrs.is_archived && !attrs.is_carbon) {
-                this.sendMarker(attrs.from, attrs.msgid, 'received');
+                sendMarker(attrs.from, attrs.msgid, 'received');
             }
             }
             return false;
             return false;
         } else if (attrs.marker_id) {
         } else if (attrs.marker_id) {

+ 219 - 0
src/headless/plugins/chat/parsers.js

@@ -0,0 +1,219 @@
+import dayjs from 'dayjs';
+import log from '@converse/headless/log';
+import u from '@converse/headless/utils/core';
+import { api, converse } from '@converse/headless/core';
+import { rejectMessage } from '@converse/headless/shared/actions';
+
+import {
+    StanzaParseError,
+    getChatMarker,
+    getChatState,
+    getCorrectionAttributes,
+    getEncryptionAttributes,
+    getErrorAttributes,
+    getOutOfBandAttributes,
+    getReceiptId,
+    getReferences,
+    getRetractionAttributes,
+    getSpoilerAttributes,
+    getStanzaIDs,
+    isArchived,
+    isCarbon,
+    isHeadline,
+    isServerMessage,
+    isValidReceiptRequest,
+    rejectUnencapsulatedForward,
+} from '@converse/headless/shared/parsers';
+
+const { Strophe, sizzle } = converse.env;
+
+
+/**
+ * Parses a passed in message stanza and returns an object of attributes.
+ * @method st#parseMessage
+ * @param { XMLElement } stanza - The message stanza
+ * @param { _converse } _converse
+ * @returns { (MessageAttributes|Error) }
+ */
+export async function parseMessage (stanza, _converse) {
+    const err = rejectUnencapsulatedForward(stanza);
+    if (err) {
+        return err;
+    }
+
+    let to_jid = stanza.getAttribute('to');
+    const to_resource = Strophe.getResourceFromJid(to_jid);
+    if (api.settings.get('filter_by_resource') && to_resource && to_resource !== _converse.resource) {
+        return new StanzaParseError(
+            `Ignoring incoming message intended for a different resource: ${to_jid}`,
+            stanza
+        );
+    }
+
+    const original_stanza = stanza;
+    let from_jid = stanza.getAttribute('from') || _converse.bare_jid;
+    if (isCarbon(stanza)) {
+        if (from_jid === _converse.bare_jid) {
+            const selector = `[xmlns="${Strophe.NS.CARBONS}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`;
+            stanza = sizzle(selector, stanza).pop();
+            to_jid = stanza.getAttribute('to');
+            from_jid = stanza.getAttribute('from');
+        } else {
+            // Prevent message forging via carbons: https://xmpp.org/extensions/xep-0280.html#security
+            rejectMessage(stanza, 'Rejecting carbon from invalid JID');
+            return new StanzaParseError(`Rejecting carbon from invalid JID ${to_jid}`, stanza);
+        }
+    }
+
+    const is_archived = isArchived(stanza);
+    if (is_archived) {
+        if (from_jid === _converse.bare_jid) {
+            const selector = `[xmlns="${Strophe.NS.MAM}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`;
+            stanza = sizzle(selector, stanza).pop();
+            to_jid = stanza.getAttribute('to');
+            from_jid = stanza.getAttribute('from');
+        } else {
+            return new StanzaParseError(
+                `Invalid Stanza: alleged MAM message from ${stanza.getAttribute('from')}`,
+                stanza
+            );
+        }
+    }
+
+    const from_bare_jid = Strophe.getBareJidFromJid(from_jid);
+    const is_me = from_bare_jid === _converse.bare_jid;
+    if (is_me && to_jid === null) {
+        return new StanzaParseError(
+            `Don't know how to handle message stanza without 'to' attribute. ${stanza.outerHTML}`,
+            stanza
+        );
+    }
+
+    const is_headline = isHeadline(stanza);
+    const is_server_message = isServerMessage(stanza);
+    let contact, contact_jid;
+    if (!is_headline && !is_server_message) {
+        contact_jid = is_me ? Strophe.getBareJidFromJid(to_jid) : from_bare_jid;
+        contact = await api.contacts.get(contact_jid);
+        if (contact === undefined && !api.settings.get('allow_non_roster_messaging')) {
+            log.error(stanza);
+            return new StanzaParseError(
+                `Blocking messaging with a JID not in our roster because allow_non_roster_messaging is false.`,
+                stanza
+            );
+        }
+    }
+    /**
+     * @typedef { Object } MessageAttributes
+     * The object which {@link parseMessage} returns
+     * @property { ('me'|'them') } sender - Whether the message was sent by the current user or someone else
+     * @property { Array<Object> } references - A list of objects representing XEP-0372 references
+     * @property { Boolean } editable - Is this message editable via XEP-0308?
+     * @property { Boolean } is_archived -  Is this message from a XEP-0313 MAM archive?
+     * @property { Boolean } is_carbon - Is this message a XEP-0280 Carbon?
+     * @property { Boolean } is_delayed - Was delivery of this message was delayed as per XEP-0203?
+     * @property { Boolean } is_encrypted -  Is this message XEP-0384  encrypted?
+     * @property { Boolean } is_error - Whether an error was received for this message
+     * @property { Boolean } is_headline - Is this a "headline" message?
+     * @property { Boolean } is_markable - Can this message be marked with a XEP-0333 chat marker?
+     * @property { Boolean } is_marker - Is this message a XEP-0333 Chat Marker?
+     * @property { Boolean } is_only_emojis - Does the message body contain only emojis?
+     * @property { Boolean } is_spoiler - Is this a XEP-0382 spoiler message?
+     * @property { Boolean } is_tombstone - Is this a XEP-0424 tombstone?
+     * @property { Boolean } is_unstyled - Whether XEP-0393 styling hints should be ignored
+     * @property { Boolean } is_valid_receipt_request - Does this message request a XEP-0184 receipt (and is not from us or a carbon or archived message)
+     * @property { Object } encrypted -  XEP-0384 encryption payload attributes
+     * @property { String } body - The contents of the <body> tag of the message stanza
+     * @property { String } chat_state - The XEP-0085 chat state notification contained in this message
+     * @property { String } contact_jid - The JID of the other person or entity
+     * @property { String } edited - An ISO8601 string recording the time that the message was edited per XEP-0308
+     * @property { String } error_condition - The defined error condition
+     * @property { String } error_text - The error text received from the server
+     * @property { String } error_type - The type of error received from the server
+     * @property { String } from - The sender JID
+     * @property { String } fullname - The full name of the sender
+     * @property { String } marker - The XEP-0333 Chat Marker value
+     * @property { String } marker_id - The `id` attribute of a XEP-0333 chat marker
+     * @property { String } msgid - The root `id` attribute of the stanza
+     * @property { String } nick - The roster nickname of the sender
+     * @property { String } oob_desc - The description of the XEP-0066 out of band data
+     * @property { String } oob_url - The URL of the XEP-0066 out of band data
+     * @property { String } origin_id - The XEP-0359 Origin ID
+     * @property { String } receipt_id - The `id` attribute of a XEP-0184 <receipt> element
+     * @property { String } received - An ISO8601 string recording the time that the message was received
+     * @property { String } replace_id - The `id` attribute of a XEP-0308 <replace> element
+     * @property { String } retracted - An ISO8601 string recording the time that the message was retracted
+     * @property { String } retracted_id - The `id` attribute of a XEP-424 <retracted> element
+     * @property { String } spoiler_hint  The XEP-0382 spoiler hint
+     * @property { String } stanza_id - The XEP-0359 Stanza ID. Note: the key is actualy `stanza_id ${by_jid}` and there can be multiple.
+     * @property { String } subject - The <subject> element value
+     * @property { String } thread - The <thread> element value
+     * @property { String } time - The time (in ISO8601 format), either given by the XEP-0203 <delay> element, or of receipt.
+     * @property { String } to - The recipient JID
+     * @property { String } type - The type of message
+     */
+    const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
+    const marker = getChatMarker(stanza);
+    const now = new Date().toISOString();
+    let attrs = Object.assign(
+        {
+            contact_jid,
+            is_archived,
+            is_headline,
+            is_server_message,
+            'body': stanza.querySelector('body')?.textContent?.trim(),
+            'chat_state': getChatState(stanza),
+            'from': Strophe.getBareJidFromJid(stanza.getAttribute('from')),
+            'is_carbon': isCarbon(original_stanza),
+            'is_delayed': !!delay,
+            'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length,
+            'is_marker': !!marker,
+            'is_unstyled': !!sizzle(`unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length,
+            'marker_id': marker && marker.getAttribute('id'),
+            'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'),
+            'nick': contact?.attributes?.nickname,
+            'receipt_id': getReceiptId(stanza),
+            'received': new Date().toISOString(),
+            'references': getReferences(stanza),
+            'sender': is_me ? 'me' : 'them',
+            'subject': stanza.querySelector('subject')?.textContent,
+            'thread': stanza.querySelector('thread')?.textContent,
+            'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : now,
+            'to': stanza.getAttribute('to'),
+            'type': stanza.getAttribute('type')
+        },
+        getErrorAttributes(stanza),
+        getOutOfBandAttributes(stanza),
+        getSpoilerAttributes(stanza),
+        getCorrectionAttributes(stanza, original_stanza),
+        getStanzaIDs(stanza, original_stanza),
+        getRetractionAttributes(stanza, original_stanza),
+        getEncryptionAttributes(stanza, _converse)
+    );
+
+    if (attrs.is_archived) {
+        const from = original_stanza.getAttribute('from');
+        if (from && from !== _converse.bare_jid) {
+            return new StanzaParseError(`Invalid Stanza: Forged MAM message from ${from}`, stanza);
+        }
+    }
+    await api.emojis.initialize();
+    attrs = Object.assign(
+        {
+            'message': attrs.body || attrs.error, // TODO: Remove and use body and error attributes instead
+            'is_only_emojis': attrs.body ? u.isOnlyEmojis(attrs.body) : false,
+            'is_valid_receipt_request': isValidReceiptRequest(stanza, attrs)
+        },
+        attrs
+    );
+
+    // We prefer to use one of the XEP-0359 unique and stable stanza IDs
+    // as the Model id, to avoid duplicates.
+    attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${attrs.from}`] || u.getUniqueId();
+
+    /**
+     * *Hook* which allows plugins to add additional parsing
+     * @event _converse#parseMessage
+     */
+    return api.hook('parseMessage', stanza, attrs);
+}

+ 4 - 3
src/headless/plugins/headlines.js

@@ -4,7 +4,8 @@
  * @description XEP-0045 Multi-User Chat Views
  * @description XEP-0045 Multi-User Chat Views
  */
  */
 import { _converse, api, converse } from "@converse/headless/core";
 import { _converse, api, converse } from "@converse/headless/core";
-import st from "../utils/stanza";
+import { isHeadline, isServerMessage } from '@converse/headless/shared/parsers';
+import { parseMessage } from '@converse/headless/plugins/chat/parsers';
 
 
 
 
 converse.plugins.add('converse-headlines', {
 converse.plugins.add('converse-headlines', {
@@ -79,7 +80,7 @@ converse.plugins.add('converse-headlines', {
 
 
         async function onHeadlineMessage (stanza) {
         async function onHeadlineMessage (stanza) {
             // Handler method for all incoming messages of type "headline".
             // Handler method for all incoming messages of type "headline".
-            if (st.isHeadline(stanza) || st.isServerMessage(stanza)) {
+            if (isHeadline(stanza) || isServerMessage(stanza)) {
                 const from_jid = stanza.getAttribute('from');
                 const from_jid = stanza.getAttribute('from');
                 if (from_jid.includes('@') &&
                 if (from_jid.includes('@') &&
                         !_converse.roster.get(from_jid) &&
                         !_converse.roster.get(from_jid) &&
@@ -96,7 +97,7 @@ converse.plugins.add('converse-headlines', {
                     'type': _converse.HEADLINES_TYPE,
                     'type': _converse.HEADLINES_TYPE,
                     'from': from_jid
                     'from': from_jid
                 });
                 });
-                const attrs = await st.parseMessage(stanza, _converse);
+                const attrs = await parseMessage(stanza, _converse);
                 await chatbox.createMessage(attrs);
                 await chatbox.createMessage(attrs);
                 api.trigger('message', {chatbox, stanza, attrs});
                 api.trigger('message', {chatbox, stanza, attrs});
             }
             }

+ 5 - 4
src/headless/plugins/mam.js

@@ -5,11 +5,12 @@
  * @license Mozilla Public License (MPLv2)
  * @license Mozilla Public License (MPLv2)
  */
  */
 import "./disco";
 import "./disco";
-import { _converse, api, converse } from "@converse/headless/core";
-import log from "../log.js";
+import log from '@converse/headless/log';
 import sizzle from "sizzle";
 import sizzle from "sizzle";
-import st from "../utils/stanza";
+import { parseMessage } from '@converse/headless/plugins/chat/parsers';
+import { parseMUCMessage } from '@converse/headless/plugins/muc/parsers';
 import { RSM } from '@converse/headless/shared/rsm';
 import { RSM } from '@converse/headless/shared/rsm';
+import { _converse, api, converse } from "@converse/headless/core";
 
 
 const { Strophe, $iq, dayjs } = converse.env;
 const { Strophe, $iq, dayjs } = converse.env;
 const { NS } = Strophe;
 const { NS } = Strophe;
@@ -49,7 +50,7 @@ const MAMEnabledChat = {
         await api.emojis.initialize();
         await api.emojis.initialize();
         const is_muc = this.get('type') === _converse.CHATROOMS_TYPE;
         const is_muc = this.get('type') === _converse.CHATROOMS_TYPE;
         result.messages = result.messages.map(
         result.messages = result.messages.map(
-            s => (is_muc ? st.parseMUCMessage(s, this, _converse) : st.parseMessage(s, _converse))
+            s => (is_muc ? parseMUCMessage(s, this, _converse) : parseMessage(s, _converse))
         );
         );
 
 
         /**
         /**

+ 1 - 1
src/headless/plugins/muc/index.js

@@ -13,7 +13,7 @@ import ChatRoomOccupant from './occupant.js';
 import ChatRoomOccupants from './occupants.js';
 import ChatRoomOccupants from './occupants.js';
 import log from '../../log';
 import log from '../../log';
 import muc_api from './api.js';
 import muc_api from './api.js';
-import muc_utils from '../../utils/muc';
+import muc_utils from './utils.js';
 import u from '../../utils/form';
 import u from '../../utils/form';
 import { Collection } from '@converse/skeletor/src/collection';
 import { Collection } from '@converse/skeletor/src/collection';
 import { Model } from '@converse/skeletor/src/model.js';
 import { Model } from '@converse/skeletor/src/model.js';

+ 14 - 13
src/headless/plugins/muc/muc.js

@@ -1,13 +1,15 @@
 import log from '../../log';
 import log from '../../log';
-import { Model } from '@converse/skeletor/src/model.js';
-import muc_utils from '../../utils/muc';
+import muc_utils from './utils.js';
 import p from '../../utils/parse-helpers';
 import p from '../../utils/parse-helpers';
 import sizzle from 'sizzle';
 import sizzle from 'sizzle';
-import st from '../../utils/stanza';
 import u from '../../utils/form';
 import u from '../../utils/form';
+import { Model } from '@converse/skeletor/src/model.js';
 import { Strophe, $build, $iq, $msg, $pres } from 'strophe.js/src/strophe';
 import { Strophe, $build, $iq, $msg, $pres } from 'strophe.js/src/strophe';
 import { _converse, api, converse } from '../../core.js';
 import { _converse, api, converse } from '../../core.js';
 import { debounce, intersection, invoke, isElement, pick, zipObject } from 'lodash-es';
 import { debounce, intersection, invoke, isElement, pick, zipObject } from 'lodash-es';
+import { isArchived } from '@converse/headless/shared/parsers';
+import { parseMemberListIQ, parseMUCMessage, parseMUCPresence } from './parsers.js';
+import { sendMarker } from '@converse/headless/shared/actions';
 
 
 const ACTION_INFO_CODES = ['301', '303', '333', '307', '321', '322'];
 const ACTION_INFO_CODES = ['301', '303', '333', '307', '321', '322'];
 
 
@@ -194,7 +196,7 @@ const ChatRoomMixin = {
                 return;
                 return;
             }
             }
             const from_jid = Strophe.getBareJidFromJid(msg.get('from'));
             const from_jid = Strophe.getBareJidFromJid(msg.get('from'));
-            this.sendMarker(from_jid, id, type, msg.get('type'));
+            sendMarker(from_jid, id, type, msg.get('type'));
         }
         }
     },
     },
 
 
@@ -365,7 +367,7 @@ const ChatRoomMixin = {
 
 
     async handleErrorMessageStanza (stanza) {
     async handleErrorMessageStanza (stanza) {
         const { __ } = _converse;
         const { __ } = _converse;
-        const attrs = await st.parseMUCMessage(stanza, this, _converse);
+        const attrs = await parseMUCMessage(stanza, this, _converse);
         if (!(await this.shouldShowErrorMessage(attrs))) {
         if (!(await this.shouldShowErrorMessage(attrs))) {
             return;
             return;
         }
         }
@@ -414,7 +416,7 @@ const ChatRoomMixin = {
      * @param { XMLElement } stanza
      * @param { XMLElement } stanza
      */
      */
     async handleMessageStanza (stanza) {
     async handleMessageStanza (stanza) {
-        if (st.isArchived(stanza)) {
+        if (isArchived(stanza)) {
             // MAM messages are handled in converse-mam.
             // MAM messages are handled in converse-mam.
             // We shouldn't get MAM messages here because
             // We shouldn't get MAM messages here because
             // they shouldn't have a `type` attribute.
             // they shouldn't have a `type` attribute.
@@ -431,7 +433,7 @@ const ChatRoomMixin = {
          * @property { MUCMessageAttributes } attrs
          * @property { MUCMessageAttributes } attrs
          * @property { ChatRoom } chatbox
          * @property { ChatRoom } chatbox
          */
          */
-        const attrs = await st.parseMUCMessage(stanza, this, _converse);
+        const attrs = await parseMUCMessage(stanza, this, _converse);
         const data = { stanza, attrs, 'chatbox': this };
         const data = { stanza, attrs, 'chatbox': this };
         /**
         /**
          * Triggered when a groupchat message stanza has been received and parsed.
          * Triggered when a groupchat message stanza has been received and parsed.
@@ -1305,8 +1307,7 @@ const ChatRoomMixin = {
             log.warn(result);
             log.warn(result);
             return err;
             return err;
         }
         }
-        return muc_utils
-            .parseMemberListIQ(result)
+        return parseMemberListIQ(result)
             .filter(p => p)
             .filter(p => p)
             .sort((a, b) => (a.nick < b.nick ? -1 : a.nick > b.nick ? 1 : 0));
             .sort((a, b) => (a.nick < b.nick ? -1 : a.nick > b.nick ? 1 : 0));
     },
     },
@@ -1438,7 +1439,7 @@ const ChatRoomMixin = {
      * @param { XMLElement } pres - The presence stanza
      * @param { XMLElement } pres - The presence stanza
      */
      */
     updateOccupantsOnPresence (pres) {
     updateOccupantsOnPresence (pres) {
-        const data = st.parseMUCPresence(pres);
+        const data = parseMUCPresence(pres);
         if (data.type === 'error' || (!data.jid && !data.nick)) {
         if (data.type === 'error' || (!data.jid && !data.nick)) {
             return true;
             return true;
         }
         }
@@ -1538,7 +1539,7 @@ const ChatRoomMixin = {
      * @private
      * @private
      * @method _converse.ChatRoom#handleSubjectChange
      * @method _converse.ChatRoom#handleSubjectChange
      * @param { object } attrs - Attributes representing a received
      * @param { object } attrs - Attributes representing a received
-     *  message, as returned by {@link st.parseMUCMessage}
+     *  message, as returned by {@link parseMUCMessage}
      */
      */
     async handleSubjectChange (attrs) {
     async handleSubjectChange (attrs) {
         const __ = _converse.__;
         const __ = _converse.__;
@@ -1692,7 +1693,7 @@ const ChatRoomMixin = {
      * @private
      * @private
      * @method _converse.ChatRoom#findDanglingModeration
      * @method _converse.ChatRoom#findDanglingModeration
      * @param { object } attrs - Attributes representing a received
      * @param { object } attrs - Attributes representing a received
-     *  message, as returned by {@link st.parseMUCMessage}
+     *  message, as returned by {@link parseMUCMessage}
      * @returns { _converse.ChatRoomMessage }
      * @returns { _converse.ChatRoomMessage }
      */
      */
     findDanglingModeration (attrs) {
     findDanglingModeration (attrs) {
@@ -1723,7 +1724,7 @@ const ChatRoomMixin = {
      * @private
      * @private
      * @method _converse.ChatRoom#handleModeration
      * @method _converse.ChatRoom#handleModeration
      * @param { object } attrs - Attributes representing a received
      * @param { object } attrs - Attributes representing a received
-     *  message, as returned by {@link st.parseMUCMessage}
+     *  message, as returned by {@link parseMUCMessage}
      * @returns { Boolean } Returns `true` or `false` depending on
      * @returns { Boolean } Returns `true` or `false` depending on
      *  whether a message was moderated or not.
      *  whether a message was moderated or not.
      */
      */

+ 307 - 0
src/headless/plugins/muc/parsers.js

@@ -0,0 +1,307 @@
+import dayjs from 'dayjs';
+import {
+    StanzaParseError,
+    getChatMarker,
+    getChatState,
+    getCorrectionAttributes,
+    getEncryptionAttributes,
+    getErrorAttributes,
+    getOutOfBandAttributes,
+    getReceiptId,
+    getReferences,
+    getRetractionAttributes,
+    getSpoilerAttributes,
+    getStanzaIDs,
+    isArchived,
+    isCarbon,
+    isHeadline,
+    isValidReceiptRequest,
+    rejectUnencapsulatedForward,
+} from '@converse/headless/shared/parsers';
+import { api, converse } from '@converse/headless/core';
+
+const { Strophe, sizzle, u } = converse.env;
+const { NS } = Strophe;
+
+/**
+ * @private
+ * @param { XMLElement } stanza - The message stanza
+ * @param { XMLElement } original_stanza - The original stanza, that contains the
+ *  message stanza, if it was contained, otherwise it's the message stanza itself.
+ * @returns { Object }
+ */
+function getModerationAttributes (stanza) {
+    const fastening = sizzle(`apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
+    if (fastening) {
+        const applies_to_id = fastening.getAttribute('id');
+        const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE}"]`, fastening).pop();
+        if (moderated) {
+            const retracted = sizzle(`retract[xmlns="${Strophe.NS.RETRACT}"]`, moderated).pop();
+            if (retracted) {
+                return {
+                    'editable': false,
+                    'moderated': 'retracted',
+                    'moderated_by': moderated.getAttribute('by'),
+                    'moderated_id': applies_to_id,
+                    'moderation_reason': moderated.querySelector('reason')?.textContent
+                };
+            }
+        }
+    } else {
+        const tombstone = sizzle(`> moderated[xmlns="${Strophe.NS.MODERATE}"]`, stanza).pop();
+        if (tombstone) {
+            const retracted = sizzle(`retracted[xmlns="${Strophe.NS.RETRACT}"]`, tombstone).pop();
+            if (retracted) {
+                return {
+                    'editable': false,
+                    'is_tombstone': true,
+                    'moderated_by': tombstone.getAttribute('by'),
+                    'retracted': tombstone.getAttribute('stamp'),
+                    'moderation_reason': tombstone.querySelector('reason')?.textContent
+                };
+            }
+        }
+    }
+    return {};
+}
+
+/**
+ * Parses a passed in message stanza and returns an object of attributes.
+ * @param { XMLElement } stanza - The message stanza
+ * @param { XMLElement } original_stanza - The original stanza, that contains the
+ *  message stanza, if it was contained, otherwise it's the message stanza itself.
+ * @param { _converse.ChatRoom } chatbox
+ * @param { _converse } _converse
+ * @returns { Promise<MUCMessageAttributes|Error> }
+ */
+export async function parseMUCMessage (stanza, chatbox, _converse) {
+    const err = rejectUnencapsulatedForward(stanza);
+    if (err) {
+        return err;
+    }
+
+    const selector = `[xmlns="${NS.MAM}"] > forwarded[xmlns="${NS.FORWARD}"] > message`;
+    const original_stanza = stanza;
+    stanza = sizzle(selector, stanza).pop() || stanza;
+
+    if (sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length) {
+        return new StanzaParseError(
+            `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`,
+            stanza
+        );
+    }
+    const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
+    const from = stanza.getAttribute('from');
+    const nick = Strophe.unescapeNode(Strophe.getResourceFromJid(from));
+    const marker = getChatMarker(stanza);
+    const now = new Date().toISOString();
+    /**
+     * @typedef { Object } MUCMessageAttributes
+     * The object which {@link parseMUCMessage} returns
+     * @property { ('me'|'them') } sender - Whether the message was sent by the current user or someone else
+     * @property { Array<Object> } references - A list of objects representing XEP-0372 references
+     * @property { Boolean } editable - Is this message editable via XEP-0308?
+     * @property { Boolean } is_archived -  Is this message from a XEP-0313 MAM archive?
+     * @property { Boolean } is_carbon - Is this message a XEP-0280 Carbon?
+     * @property { Boolean } is_delayed - Was delivery of this message was delayed as per XEP-0203?
+     * @property { Boolean } is_encrypted -  Is this message XEP-0384  encrypted?
+     * @property { Boolean } is_error - Whether an error was received for this message
+     * @property { Boolean } is_headline - Is this a "headline" message?
+     * @property { Boolean } is_markable - Can this message be marked with a XEP-0333 chat marker?
+     * @property { Boolean } is_marker - Is this message a XEP-0333 Chat Marker?
+     * @property { Boolean } is_only_emojis - Does the message body contain only emojis?
+     * @property { Boolean } is_spoiler - Is this a XEP-0382 spoiler message?
+     * @property { Boolean } is_tombstone - Is this a XEP-0424 tombstone?
+     * @property { Boolean } is_unstyled - Whether XEP-0393 styling hints should be ignored
+     * @property { Boolean } is_valid_receipt_request - Does this message request a XEP-0184 receipt (and is not from us or a carbon or archived message)
+     * @property { Object } encrypted -  XEP-0384 encryption payload attributes
+     * @property { String } body - The contents of the <body> tag of the message stanza
+     * @property { String } chat_state - The XEP-0085 chat state notification contained in this message
+     * @property { String } edited - An ISO8601 string recording the time that the message was edited per XEP-0308
+     * @property { String } error_condition - The defined error condition
+     * @property { String } error_text - The error text received from the server
+     * @property { String } error_type - The type of error received from the server
+     * @property { String } from - The sender JID (${muc_jid}/${nick})
+     * @property { String } from_muc - The JID of the MUC from which this message was sent
+     * @property { String } from_real_jid - The real JID of the sender, if available
+     * @property { String } fullname - The full name of the sender
+     * @property { String } marker - The XEP-0333 Chat Marker value
+     * @property { String } marker_id - The `id` attribute of a XEP-0333 chat marker
+     * @property { String } moderated - The type of XEP-0425 moderation (if any) that was applied
+     * @property { String } moderated_by - The JID of the user that moderated this message
+     * @property { String } moderated_id - The  XEP-0359 Stanza ID of the message that this one moderates
+     * @property { String } moderation_reason - The reason provided why this message moderates another
+     * @property { String } msgid - The root `id` attribute of the stanza
+     * @property { String } nick - The MUC nickname of the sender
+     * @property { String } oob_desc - The description of the XEP-0066 out of band data
+     * @property { String } oob_url - The URL of the XEP-0066 out of band data
+     * @property { String } origin_id - The XEP-0359 Origin ID
+     * @property { String } receipt_id - The `id` attribute of a XEP-0184 <receipt> element
+     * @property { String } received - An ISO8601 string recording the time that the message was received
+     * @property { String } replace_id - The `id` attribute of a XEP-0308 <replace> element
+     * @property { String } retracted - An ISO8601 string recording the time that the message was retracted
+     * @property { String } retracted_id - The `id` attribute of a XEP-424 <retracted> element
+     * @property { String } spoiler_hint  The XEP-0382 spoiler hint
+     * @property { String } stanza_id - The XEP-0359 Stanza ID. Note: the key is actualy `stanza_id ${by_jid}` and there can be multiple.
+     * @property { String } subject - The <subject> element value
+     * @property { String } thread - The <thread> element value
+     * @property { String } time - The time (in ISO8601 format), either given by the XEP-0203 <delay> element, or of receipt.
+     * @property { String } to - The recipient JID
+     * @property { String } type - The type of message
+     */
+    let attrs = Object.assign(
+        {
+            from,
+            nick,
+            'body': stanza.querySelector('body')?.textContent?.trim(),
+            'chat_state': getChatState(stanza),
+            'from_muc': Strophe.getBareJidFromJid(from),
+            'from_real_jid': chatbox.occupants.findOccupant({ nick })?.get('jid'),
+            'is_archived': isArchived(original_stanza),
+            'is_carbon': isCarbon(original_stanza),
+            'is_delayed': !!delay,
+            'is_headline': isHeadline(stanza),
+            'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length,
+            'is_marker': !!marker,
+            'is_unstyled': !!sizzle(`unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length,
+            'marker_id': marker && marker.getAttribute('id'),
+            'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'),
+            'receipt_id': getReceiptId(stanza),
+            'received': new Date().toISOString(),
+            'references': getReferences(stanza),
+            'subject': stanza.querySelector('subject')?.textContent,
+            'thread': stanza.querySelector('thread')?.textContent,
+            'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : now,
+            'to': stanza.getAttribute('to'),
+            'type': stanza.getAttribute('type')
+        },
+        getErrorAttributes(stanza),
+        getOutOfBandAttributes(stanza),
+        getSpoilerAttributes(stanza),
+        getCorrectionAttributes(stanza, original_stanza),
+        getStanzaIDs(stanza, original_stanza),
+        getRetractionAttributes(stanza, original_stanza),
+        getModerationAttributes(stanza),
+        getEncryptionAttributes(stanza, _converse)
+    );
+
+    await api.emojis.initialize();
+    attrs = Object.assign(
+        {
+            'is_only_emojis': attrs.body ? u.isOnlyEmojis(attrs.body) : false,
+            'is_valid_receipt_request': isValidReceiptRequest(stanza, attrs),
+            'message': attrs.body || attrs.error, // TODO: Remove and use body and error attributes instead
+            'sender': attrs.nick === chatbox.get('nick') ? 'me' : 'them'
+        },
+        attrs
+    );
+
+    if (attrs.is_archived && original_stanza.getAttribute('from') !== attrs.from_muc) {
+        return new StanzaParseError(
+            `Invalid Stanza: Forged MAM message from ${original_stanza.getAttribute('from')}`,
+            stanza
+        );
+    } else if (attrs.is_archived && original_stanza.getAttribute('from') !== chatbox.get('jid')) {
+        return new StanzaParseError(
+            `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`,
+            stanza
+        );
+    } else if (attrs.is_carbon) {
+        return new StanzaParseError('Invalid Stanza: MUC messages SHOULD NOT be XEP-0280 carbon copied', stanza);
+    }
+    // We prefer to use one of the XEP-0359 unique and stable stanza IDs as the Model id, to avoid duplicates.
+    attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${attrs.from_muc || attrs.from}`] || u.getUniqueId();
+    /**
+     * *Hook* which allows plugins to add additional parsing
+     * @event _converse#parseMUCMessage
+     */
+    return api.hook('parseMUCMessage', stanza, attrs);
+}
+
+/**
+ * Given an IQ stanza with a member list, create an array of objects containing
+ * known member data (e.g. jid, nick, role, affiliation).
+ * @private
+ * @method muc_utils#parseMemberListIQ
+ * @returns { MemberListItem[] }
+ */
+export function parseMemberListIQ (iq) {
+    return sizzle(`query[xmlns="${Strophe.NS.MUC_ADMIN}"] item`, iq).map(item => {
+        /**
+         * @typedef {Object} MemberListItem
+         * Either the JID or the nickname (or both) will be available.
+         * @property {string} affiliation
+         * @property {string} [role]
+         * @property {string} [jid]
+         * @property {string} [nick]
+         */
+        const data = {
+            'affiliation': item.getAttribute('affiliation')
+        };
+        const jid = item.getAttribute('jid');
+        if (u.isValidJID(jid)) {
+            data['jid'] = jid;
+        } else {
+            // XXX: Prosody sends nick for the jid attribute value
+            // Perhaps for anonymous room?
+            data['nick'] = jid;
+        }
+        const nick = item.getAttribute('nick');
+        if (nick) {
+            data['nick'] = nick;
+        }
+        const role = item.getAttribute('role');
+        if (role) {
+            data['role'] = nick;
+        }
+        return data;
+    });
+}
+
+/**
+ * Parses a passed in MUC presence stanza and returns an object of attributes.
+ * @method parseMUCPresence
+ * @param { XMLElement } stanza - The presence stanza
+ * @returns { Object }
+ */
+export function parseMUCPresence (stanza) {
+    const from = stanza.getAttribute('from');
+    const type = stanza.getAttribute('type');
+    const data = {
+        'from': from,
+        'nick': Strophe.getResourceFromJid(from),
+        'type': type,
+        'states': [],
+        'hats': [],
+        'show': type !== 'unavailable' ? 'online' : 'offline'
+    };
+    Array.from(stanza.children).forEach(child => {
+        if (child.matches('status')) {
+            data.status = child.textContent || null;
+        } else if (child.matches('show')) {
+            data.show = child.textContent || 'online';
+        } else if (child.matches('x') && child.getAttribute('xmlns') === Strophe.NS.MUC_USER) {
+            Array.from(child.children).forEach(item => {
+                if (item.nodeName === 'item') {
+                    data.affiliation = item.getAttribute('affiliation');
+                    data.role = item.getAttribute('role');
+                    data.jid = item.getAttribute('jid');
+                    data.nick = item.getAttribute('nick') || data.nick;
+                } else if (item.nodeName == 'status' && item.getAttribute('code')) {
+                    data.states.push(item.getAttribute('code'));
+                }
+            });
+        } else if (child.matches('x') && child.getAttribute('xmlns') === Strophe.NS.VCARDUPDATE) {
+            data.image_hash = child.querySelector('photo')?.textContent;
+        } else if (child.matches('hats') && child.getAttribute('xmlns') === Strophe.NS.MUC_HATS) {
+            data['hats'] = Array.from(child.children).map(
+                c =>
+                    c.matches('hat') && {
+                        'title': c.getAttribute('title'),
+                        'uri': c.getAttribute('uri')
+                    }
+            );
+        }
+    });
+    return data;
+}

+ 1 - 47
src/headless/utils/muc.js → src/headless/plugins/muc/utils.js

@@ -4,10 +4,6 @@
  * @description This is the MUC utilities module.
  * @description This is the MUC utilities module.
  */
  */
 import { difference, indexOf } from "lodash-es";
 import { difference, indexOf } from "lodash-es";
-import { converse } from "@converse/headless/core";
-import u from "./core";
-
-const { Strophe, sizzle } = converse.env;
 
 
 /**
 /**
  * The MUC utils object. Contains utility functions related to multi-user chat.
  * The MUC utils object. Contains utility functions related to multi-user chat.
@@ -58,49 +54,7 @@ const muc_utils = {
             delta = delta.concat(difference(old_jids, new_jids).map(jid => ({'jid': jid, 'affiliation': 'none'})));
             delta = delta.concat(difference(old_jids, new_jids).map(jid => ({'jid': jid, 'affiliation': 'none'})));
         }
         }
         return delta;
         return delta;
-    },
-
-    /**
-     * Given an IQ stanza with a member list, create an array of objects containing
-     * known member data (e.g. jid, nick, role, affiliation).
-     * @private
-     * @method muc_utils#parseMemberListIQ
-     * @returns { MemberListItem[] }
-     */
-    parseMemberListIQ (iq) {
-        return sizzle(`query[xmlns="${Strophe.NS.MUC_ADMIN}"] item`, iq).map(
-            (item) => {
-                /**
-                 * @typedef {Object} MemberListItem
-                 * Either the JID or the nickname (or both) will be available.
-                 * @property {string} affiliation
-                 * @property {string} [role]
-                 * @property {string} [jid]
-                 * @property {string} [nick]
-                 */
-                const data = {
-                    'affiliation': item.getAttribute('affiliation'),
-                }
-                const jid = item.getAttribute('jid');
-                if (u.isValidJID(jid)) {
-                    data['jid'] = jid;
-                } else {
-                    // XXX: Prosody sends nick for the jid attribute value
-                    // Perhaps for anonymous room?
-                    data['nick'] = jid;
-                }
-                const nick = item.getAttribute('nick');
-                if (nick) {
-                    data['nick'] = nick;
-                }
-                const role = item.getAttribute('role');
-                if (role) {
-                    data['role'] = nick;
-                }
-                return data;
-            }
-        );
-    },
+    }
 }
 }
 
 
 export default muc_utils;
 export default muc_utils;

+ 41 - 0
src/headless/shared/actions.js

@@ -0,0 +1,41 @@
+import log from '../log';
+import { Strophe, $msg } from 'strophe.js/src/strophe';
+import { _converse, api, converse } from '@converse/headless/core';
+
+const u = converse.env.utils;
+
+export function rejectMessage (stanza, text) {
+    // Reject an incoming message by replying with an error message of type "cancel".
+    api.send(
+        $msg({
+            'to': stanza.getAttribute('from'),
+            'type': 'error',
+            'id': stanza.getAttribute('id')
+        })
+            .c('error', { 'type': 'cancel' })
+            .c('not-allowed', { xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas' })
+            .up()
+            .c('text', { xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas' })
+            .t(text)
+    );
+    log.warn(`Rejecting message stanza with the following reason: ${text}`);
+    log.warn(stanza);
+}
+
+
+/**
+ * Send out a XEP-0333 chat marker
+ * @param { String } to_jid
+ * @param { String } id - The id of the message being marked
+ * @param { String } type - The marker type
+ * @param { String } msg_type
+ */
+export function sendMarker (to_jid, id, type, msg_type) {
+    const stanza = $msg({
+        'from': _converse.connection.jid,
+        'id': u.getUniqueId(),
+        'to': to_jid,
+        'type': msg_type ? msg_type : 'chat'
+    }).c(type, {'xmlns': Strophe.NS.MARKERS, 'id': id});
+    api.send(stanza);
+}

+ 287 - 0
src/headless/shared/parsers.js

@@ -0,0 +1,287 @@
+import dayjs from 'dayjs';
+import sizzle from 'sizzle';
+import { Strophe } from 'strophe.js/src/strophe';
+import { _converse, api } from '@converse/headless/core';
+import { rejectMessage } from '@converse/headless/shared/actions';
+
+const { NS } = Strophe;
+
+export class StanzaParseError extends Error {
+    constructor (message, stanza) {
+        super(message, stanza);
+        this.name = 'StanzaParseError';
+        this.stanza = stanza;
+    }
+}
+
+/**
+ * Extract the XEP-0359 stanza IDs from the passed in stanza
+ * and return a map containing them.
+ * @private
+ * @param { XMLElement } stanza - The message stanza
+ * @returns { Object }
+ */
+export function getStanzaIDs (stanza, original_stanza) {
+    const attrs = {};
+    // Store generic stanza ids
+    const sids = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza);
+    const sid_attrs = sids.reduce((acc, s) => {
+        acc[`stanza_id ${s.getAttribute('by')}`] = s.getAttribute('id');
+        return acc;
+    }, {});
+    Object.assign(attrs, sid_attrs);
+
+    // Store the archive id
+    const result = sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop();
+    if (result) {
+        const by_jid = original_stanza.getAttribute('from') || _converse.bare_jid;
+        attrs[`stanza_id ${by_jid}`] = result.getAttribute('id');
+    }
+
+    // Store the origin id
+    const origin_id = sizzle(`origin-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop();
+    if (origin_id) {
+        attrs['origin_id'] = origin_id.getAttribute('id');
+    }
+    return attrs;
+}
+
+export function getEncryptionAttributes (stanza, _converse) {
+    const encrypted = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).pop();
+    const attrs = { 'is_encrypted': !!encrypted };
+    if (!encrypted || api.settings.get('clear_cache_on_logout')) {
+        return attrs;
+    }
+    const header = encrypted.querySelector('header');
+    attrs['encrypted'] = { 'device_id': header.getAttribute('sid') };
+
+    const device_id = _converse.omemo_store?.get('device_id');
+    const key = device_id && sizzle(`key[rid="${device_id}"]`, encrypted).pop();
+    if (key) {
+        Object.assign(attrs.encrypted, {
+            'iv': header.querySelector('iv').textContent,
+            'key': key.textContent,
+            'payload': encrypted.querySelector('payload')?.textContent || null,
+            'prekey': ['true', '1'].includes(key.getAttribute('prekey'))
+        });
+    }
+    return attrs;
+}
+
+/**
+ * @private
+ * @param { XMLElement } stanza - The message stanza
+ * @param { XMLElement } original_stanza - The original stanza, that contains the
+ *  message stanza, if it was contained, otherwise it's the message stanza itself.
+ * @returns { Object }
+ */
+export function getRetractionAttributes (stanza, original_stanza) {
+    const fastening = sizzle(`> apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
+    if (fastening) {
+        const applies_to_id = fastening.getAttribute('id');
+        const retracted = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT}"]`, fastening).pop();
+        if (retracted) {
+            const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
+            const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString();
+            return {
+                'editable': false,
+                'retracted': time,
+                'retracted_id': applies_to_id
+            };
+        }
+    } else {
+        const tombstone = sizzle(`> retracted[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop();
+        if (tombstone) {
+            return {
+                'editable': false,
+                'is_tombstone': true,
+                'retracted': tombstone.getAttribute('stamp')
+            };
+        }
+    }
+    return {};
+}
+
+export function getCorrectionAttributes (stanza, original_stanza) {
+    const el = sizzle(`replace[xmlns="${Strophe.NS.MESSAGE_CORRECT}"]`, stanza).pop();
+    if (el) {
+        const replace_id = el.getAttribute('id');
+        const msgid = replace_id;
+        if (replace_id) {
+            const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
+            const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : new Date().toISOString();
+            return {
+                msgid,
+                replace_id,
+                'edited': time
+            };
+        }
+    }
+    return {};
+}
+
+export function getSpoilerAttributes (stanza) {
+    const spoiler = sizzle(`spoiler[xmlns="${Strophe.NS.SPOILER}"]`, stanza).pop();
+    return {
+        'is_spoiler': !!spoiler,
+        'spoiler_hint': spoiler?.textContent
+    };
+}
+
+export function getOutOfBandAttributes (stanza) {
+    const xform = sizzle(`x[xmlns="${Strophe.NS.OUTOFBAND}"]`, stanza).pop();
+    if (xform) {
+        return {
+            'oob_url': xform.querySelector('url')?.textContent,
+            'oob_desc': xform.querySelector('desc')?.textContent
+        };
+    }
+    return {};
+}
+
+/**
+ * Returns the human readable error message contained in a `groupchat` message stanza of type `error`.
+ * @private
+ * @param { XMLElement } stanza - The message stanza
+ */
+export function getErrorAttributes (stanza) {
+    if (stanza.getAttribute('type') === 'error') {
+        const error = stanza.querySelector('error');
+        const text = sizzle(`text[xmlns="${Strophe.NS.STANZAS}"]`, error).pop();
+        return {
+            'is_error': true,
+            'error_text': text?.textContent,
+            'error_type': error.getAttribute('type'),
+            'error_condition': error.firstElementChild.nodeName
+        };
+    }
+    return {};
+}
+
+export function getReferences (stanza) {
+    const text = stanza.querySelector('body')?.textContent;
+    return sizzle(`reference[xmlns="${Strophe.NS.REFERENCE}"]`, stanza).map(ref => {
+        const begin = ref.getAttribute('begin');
+        const end = ref.getAttribute('end');
+        return {
+            'begin': begin,
+            'end': end,
+            'type': ref.getAttribute('type'),
+            'value': text.slice(begin, end),
+            'uri': ref.getAttribute('uri')
+        };
+    });
+}
+
+export function getReceiptId (stanza) {
+    const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop();
+    return receipt?.getAttribute('id');
+}
+
+/**
+ * Determines whether the passed in stanza is a XEP-0280 Carbon
+ * @private
+ * @param { XMLElement } stanza - The message stanza
+ * @returns { Boolean }
+ */
+export function isCarbon (stanza) {
+    const xmlns = Strophe.NS.CARBONS;
+    return (
+        sizzle(`message > received[xmlns="${xmlns}"]`, stanza).length > 0 ||
+        sizzle(`message > sent[xmlns="${xmlns}"]`, stanza).length > 0
+    );
+}
+
+/**
+ * Returns the XEP-0085 chat state contained in a message stanza
+ * @private
+ * @param { XMLElement } stanza - The message stanza
+ */
+export function getChatState (stanza) {
+    return sizzle(
+        `
+        composing[xmlns="${NS.CHATSTATES}"],
+        paused[xmlns="${NS.CHATSTATES}"],
+        inactive[xmlns="${NS.CHATSTATES}"],
+        active[xmlns="${NS.CHATSTATES}"],
+        gone[xmlns="${NS.CHATSTATES}"]`,
+        stanza
+    ).pop()?.nodeName;
+}
+
+export function isValidReceiptRequest (stanza, attrs) {
+    return (
+        attrs.sender !== 'me' &&
+        !attrs.is_carbon &&
+        !attrs.is_archived &&
+        sizzle(`request[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).length
+    );
+}
+
+export function rejectUnencapsulatedForward (stanza) {
+    const bare_forward = sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length;
+    if (bare_forward) {
+        rejectMessage(stanza, 'Forwarded messages not part of an encapsulating protocol are not supported');
+        const from_jid = stanza.getAttribute('from');
+        return new StanzaParseError(`Ignoring unencapsulated forwarded message from ${from_jid}`, stanza);
+    }
+}
+
+/**
+ * Determines whether the passed in stanza is a XEP-0333 Chat Marker
+ * @private
+ * @method getChatMarker
+ * @param { XMLElement } stanza - The message stanza
+ * @returns { Boolean }
+ */
+export function getChatMarker (stanza) {
+    // If we receive more than one marker (which shouldn't happen), we take
+    // the highest level of acknowledgement.
+    return sizzle(`
+        acknowledged[xmlns="${Strophe.NS.MARKERS}"],
+        displayed[xmlns="${Strophe.NS.MARKERS}"],
+        received[xmlns="${Strophe.NS.MARKERS}"]`,
+        stanza
+    ).pop();
+}
+
+export function isHeadline (stanza) {
+    return stanza.getAttribute('type') === 'headline';
+}
+
+export function isServerMessage (stanza) {
+    const from_jid = stanza.getAttribute('from');
+    if (stanza.getAttribute('type') !== 'error' && from_jid && !from_jid.includes('@')) {
+        // Some servers (e.g. Prosody) don't set the stanza
+        // type to "headline" when sending server messages.
+        // For now we check if an @ signal is included, and if not,
+        // we assume it's a headline stanza.
+        return true;
+    }
+    return false;
+}
+
+/**
+ * Determines whether the passed in stanza is a XEP-0313 MAM stanza
+ * @private
+ * @method isArchived
+ * @param { XMLElement } stanza - The message stanza
+ * @returns { Boolean }
+ */
+export function isArchived (original_stanza) {
+    return !!sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop();
+}
+
+
+/**
+ * Returns an object containing all attribute names and values for a particular element.
+ * @method getAttributes
+ * @param { XMLElement } stanza
+ * @returns { Object }
+ */
+export function getAttributes (stanza) {
+    return stanza.getAttributeNames().reduce((acc, name) => {
+        acc[name] = Strophe.xmlunescape(stanza.getAttribute(name));
+        return acc;
+    }, {});
+}

+ 0 - 738
src/headless/utils/stanza.js

@@ -1,738 +0,0 @@
-import { Strophe, $msg } from 'strophe.js/src/strophe';
-import dayjs from 'dayjs';
-import sizzle from 'sizzle';
-import u from '@converse/headless/utils/core';
-import log from "../log";
-import { _converse, api } from "@converse/headless/core";
-
-const { NS } = Strophe;
-
-
-function getSpoilerAttributes (stanza) {
-    const spoiler = sizzle(`spoiler[xmlns="${Strophe.NS.SPOILER}"]`, stanza).pop();
-    return {
-        'is_spoiler': !!spoiler,
-        'spoiler_hint': spoiler?.textContent
-    }
-}
-
-function getOutOfBandAttributes (stanza) {
-    const xform = sizzle(`x[xmlns="${Strophe.NS.OUTOFBAND}"]`, stanza).pop();
-    if (xform) {
-        return {
-            'oob_url': xform.querySelector('url')?.textContent,
-            'oob_desc': xform.querySelector('desc')?.textContent
-        }
-    }
-    return {};
-}
-
-function getCorrectionAttributes (stanza, original_stanza) {
-    const el = sizzle(`replace[xmlns="${Strophe.NS.MESSAGE_CORRECT}"]`, stanza).pop();
-    if (el) {
-        const replace_id = el.getAttribute('id');
-        const msgid = replace_id;
-        if (replace_id) {
-            const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
-            const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : (new Date()).toISOString();
-            return {
-                msgid,
-                replace_id,
-                'edited': time
-            }
-        }
-    }
-    return {};
-}
-
-
-function getEncryptionAttributes (stanza, _converse) {
-    const encrypted = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).pop();
-    const attrs = { 'is_encrypted': !!encrypted };
-    if (!encrypted || api.settings.get('clear_cache_on_logout')) {
-        return attrs;
-    }
-    const header = encrypted.querySelector('header');
-    attrs['encrypted'] = {'device_id': header.getAttribute('sid')};
-
-    const device_id = _converse.omemo_store?.get('device_id');
-    const key = device_id && sizzle(`key[rid="${device_id}"]`, encrypted).pop();
-    if (key) {
-        Object.assign(attrs.encrypted, {
-            'iv': header.querySelector('iv').textContent,
-            'key': key.textContent,
-            'payload': encrypted.querySelector('payload')?.textContent || null,
-            'prekey': ['true', '1'].includes(key.getAttribute('prekey'))
-        });
-    }
-    return attrs;
-}
-
-
-function isValidReceiptRequest (stanza, attrs) {
-    return (
-        attrs.sender !== 'me' &&
-        !attrs.is_carbon &&
-        !attrs.is_archived &&
-        sizzle(`request[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).length
-    );
-}
-
-
-function getReceiptId (stanza) {
-    const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop();
-    return receipt?.getAttribute('id');
-}
-
-/**
- * Returns the XEP-0085 chat state contained in a message stanza
- * @private
- * @param { XMLElement } stanza - The message stanza
- */
-function getChatState (stanza) {
-    return sizzle(`
-        composing[xmlns="${NS.CHATSTATES}"],
-        paused[xmlns="${NS.CHATSTATES}"],
-        inactive[xmlns="${NS.CHATSTATES}"],
-        active[xmlns="${NS.CHATSTATES}"],
-        gone[xmlns="${NS.CHATSTATES}"]`, stanza).pop()?.nodeName;
-}
-
-/**
- * Determines whether the passed in stanza is a XEP-0280 Carbon
- * @private
- * @param { XMLElement } stanza - The message stanza
- * @returns { Boolean }
- */
-function isCarbon (stanza) {
-    const xmlns = Strophe.NS.CARBONS;
-    return sizzle(`message > received[xmlns="${xmlns}"]`, stanza).length > 0 ||
-            sizzle(`message > sent[xmlns="${xmlns}"]`, stanza).length > 0;
-}
-
-/**
- * Extract the XEP-0359 stanza IDs from the passed in stanza
- * and return a map containing them.
- * @private
- * @param { XMLElement } stanza - The message stanza
- * @returns { Object }
- */
-function getStanzaIDs (stanza, original_stanza) {
-    const attrs = {};
-    // Store generic stanza ids
-    const sids = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza);
-    const sid_attrs = sids.reduce((acc, s) => {
-        acc[`stanza_id ${s.getAttribute('by')}`] = s.getAttribute('id');
-        return acc;
-    }, {});
-    Object.assign(attrs, sid_attrs);
-
-    // Store the archive id
-    const result = sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop();
-    if (result) {
-        const by_jid = original_stanza.getAttribute('from') || _converse.bare_jid;
-        attrs[`stanza_id ${by_jid}`] = result.getAttribute('id');
-    }
-
-    // Store the origin id
-    const origin_id = sizzle(`origin-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop();
-    if (origin_id) {
-        attrs['origin_id'] = origin_id.getAttribute('id');
-    }
-    return attrs;
-}
-
-/**
- * @private
- * @param { XMLElement } stanza - The message stanza
- * @param { XMLElement } original_stanza - The original stanza, that contains the
- *  message stanza, if it was contained, otherwise it's the message stanza itself.
- * @returns { Object }
- */
-function getModerationAttributes (stanza) {
-    const fastening = sizzle(`apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
-    if (fastening) {
-        const applies_to_id = fastening.getAttribute('id');
-        const moderated = sizzle(`moderated[xmlns="${Strophe.NS.MODERATE}"]`, fastening).pop();
-        if (moderated) {
-            const retracted = sizzle(`retract[xmlns="${Strophe.NS.RETRACT}"]`, moderated).pop();
-            if (retracted) {
-                return {
-                    'editable': false,
-                    'moderated': 'retracted',
-                    'moderated_by': moderated.getAttribute('by'),
-                    'moderated_id': applies_to_id,
-                    'moderation_reason': moderated.querySelector('reason')?.textContent
-                }
-            }
-        }
-    } else {
-        const tombstone = sizzle(`> moderated[xmlns="${Strophe.NS.MODERATE}"]`, stanza).pop();
-        if (tombstone) {
-            const retracted = sizzle(`retracted[xmlns="${Strophe.NS.RETRACT}"]`, tombstone).pop();
-            if (retracted) {
-                return {
-                    'editable': false,
-                    'is_tombstone': true,
-                    'moderated_by': tombstone.getAttribute('by'),
-                    'retracted': tombstone.getAttribute('stamp'),
-                    'moderation_reason': tombstone.querySelector('reason')?.textContent
-
-                }
-            }
-        }
-    }
-    return {};
-}
-
-
-/**
- * @private
- * @param { XMLElement } stanza - The message stanza
- * @param { XMLElement } original_stanza - The original stanza, that contains the
- *  message stanza, if it was contained, otherwise it's the message stanza itself.
- * @returns { Object }
- */
-function getRetractionAttributes (stanza, original_stanza) {
-    const fastening = sizzle(`> apply-to[xmlns="${Strophe.NS.FASTEN}"]`, stanza).pop();
-    if (fastening) {
-        const applies_to_id = fastening.getAttribute('id');
-        const retracted = sizzle(`> retract[xmlns="${Strophe.NS.RETRACT}"]`, fastening).pop();
-        if (retracted) {
-            const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
-            const time = delay ? dayjs(delay.getAttribute('stamp')).toISOString() : (new Date()).toISOString();
-            return {
-                'editable': false,
-                'retracted': time,
-                'retracted_id': applies_to_id
-            }
-        }
-    } else {
-        const tombstone = sizzle(`> retracted[xmlns="${Strophe.NS.RETRACT}"]`, stanza).pop();
-        if (tombstone) {
-            return {
-                'editable': false,
-                'is_tombstone': true,
-                'retracted': tombstone.getAttribute('stamp')
-            }
-        }
-    }
-    return {};
-}
-
-function getReferences (stanza) {
-    const text = stanza.querySelector('body')?.textContent;
-    return sizzle(`reference[xmlns="${Strophe.NS.REFERENCE}"]`, stanza).map(ref => {
-        const begin = ref.getAttribute('begin');
-        const end = ref.getAttribute('end');
-        return  {
-            'begin': begin,
-            'end': end,
-            'type': ref.getAttribute('type'),
-            'value': text.slice(begin, end),
-            'uri': ref.getAttribute('uri')
-        };
-    });
-}
-
-function rejectMessage (stanza, text) {
-    // Reject an incoming message by replying with an error message of type "cancel".
-    api.send(
-        $msg({
-            'to': stanza.getAttribute('from'),
-            'type': 'error',
-            'id': stanza.getAttribute('id')
-        }).c('error', {'type': 'cancel'})
-            .c('not-allowed', {xmlns:"urn:ietf:params:xml:ns:xmpp-stanzas"}).up()
-            .c('text', {xmlns:"urn:ietf:params:xml:ns:xmpp-stanzas"}).t(text)
-    );
-    log.warn(`Rejecting message stanza with the following reason: ${text}`);
-    log.warn(stanza);
-}
-
-
-/**
- * Returns the human readable error message contained in a `groupchat` message stanza of type `error`.
- * @private
- * @param { XMLElement } stanza - The message stanza
- */
-function getErrorAttributes (stanza) {
-    if (stanza.getAttribute('type') === 'error') {
-        const error = stanza.querySelector('error');
-        const text = sizzle(`text[xmlns="${Strophe.NS.STANZAS}"]`, error).pop();
-        return {
-            'is_error': true,
-            'error_text': text?.textContent,
-            'error_type': error.getAttribute('type'),
-            'error_condition': error.firstElementChild.nodeName
-        }
-    }
-    return {};
-}
-
-
-class StanzaParseError extends Error {
-    constructor (message, stanza) {
-        super(message, stanza);
-        this.name = 'StanzaParseError';
-        this.stanza = stanza;
-    }
-}
-
-
-function rejectUnencapsulatedForward (stanza) {
-    const bare_forward = sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length;
-    if (bare_forward) {
-        rejectMessage(
-            stanza,
-            'Forwarded messages not part of an encapsulating protocol are not supported'
-        );
-        const from_jid = stanza.getAttribute('from');
-        return new StanzaParseError(`Ignoring unencapsulated forwarded message from ${from_jid}`, stanza);
-    }
-}
-
-
-/**
- * The stanza utils object. Contains utility functions related to stanza processing.
- * @namespace st
- */
-const st = {
-
-    isHeadline (stanza) {
-        return stanza.getAttribute('type') === 'headline';
-    },
-
-    isServerMessage (stanza) {
-        const from_jid = stanza.getAttribute('from');
-        if (stanza.getAttribute('type') !== 'error' && from_jid && !from_jid.includes('@')) {
-            // Some servers (e.g. Prosody) don't set the stanza
-            // type to "headline" when sending server messages.
-            // For now we check if an @ signal is included, and if not,
-            // we assume it's a headline stanza.
-            return true;
-        }
-        return false;
-    },
-
-    /**
-     * Determines whether the passed in stanza is a XEP-0333 Chat Marker
-     * @private
-     * @method st#getChatMarker
-     * @param { XMLElement } stanza - The message stanza
-     * @returns { Boolean }
-     */
-    getChatMarker (stanza) {
-        // If we receive more than one marker (which shouldn't happen), we take
-        // the highest level of acknowledgement.
-        return sizzle(`
-            acknowledged[xmlns="${Strophe.NS.MARKERS}"],
-            displayed[xmlns="${Strophe.NS.MARKERS}"],
-            received[xmlns="${Strophe.NS.MARKERS}"]`, stanza).pop();
-    },
-
-    /**
-     * Determines whether the passed in stanza is a XEP-0313 MAM stanza
-     * @private
-     * @method st#isArchived
-     * @param { XMLElement } stanza - The message stanza
-     * @returns { Boolean }
-     */
-    isArchived (original_stanza) {
-        return !!sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop();
-    },
-
-    /**
-     * Returns an object containing all attribute names and values for a particular element.
-     * @method st#getAttributes
-     * @param { XMLElement } stanza
-     * @returns { Object }
-     */
-    getAttributes (stanza) {
-        return stanza.getAttributeNames().reduce((acc, name) => {
-            acc[name] = Strophe.xmlunescape(stanza.getAttribute(name))
-            return acc;
-        }, {});
-    },
-
-    /**
-     * Parses a passed in message stanza and returns an object of attributes.
-     * @method st#parseMessage
-     * @param { XMLElement } stanza - The message stanza
-     * @param { _converse } _converse
-     * @returns { (MessageAttributes|Error) }
-     */
-    async parseMessage (stanza, _converse) {
-        const err = rejectUnencapsulatedForward(stanza);
-        if (err) {
-            return err;
-        }
-
-        let to_jid = stanza.getAttribute('to');
-        const to_resource = Strophe.getResourceFromJid(to_jid);
-        if (api.settings.get('filter_by_resource') && (to_resource && to_resource !== _converse.resource)) {
-            return new StanzaParseError(`Ignoring incoming message intended for a different resource: ${to_jid}`, stanza);
-        }
-
-        const original_stanza = stanza;
-        let from_jid = stanza.getAttribute('from') || _converse.bare_jid;
-        if (isCarbon(stanza)) {
-            if (from_jid === _converse.bare_jid) {
-                const selector = `[xmlns="${Strophe.NS.CARBONS}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`;
-                stanza = sizzle(selector, stanza).pop();
-                to_jid = stanza.getAttribute('to');
-                from_jid = stanza.getAttribute('from');
-            } else {
-                // Prevent message forging via carbons: https://xmpp.org/extensions/xep-0280.html#security
-                rejectMessage(stanza, 'Rejecting carbon from invalid JID');
-                return new StanzaParseError(`Rejecting carbon from invalid JID ${to_jid}`, stanza);
-            }
-        }
-
-        const is_archived = st.isArchived(stanza);
-        if (is_archived) {
-            if (from_jid === _converse.bare_jid) {
-                const selector = `[xmlns="${Strophe.NS.MAM}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`;
-                stanza = sizzle(selector, stanza).pop();
-                to_jid = stanza.getAttribute('to');
-                from_jid = stanza.getAttribute('from');
-            } else {
-                return new StanzaParseError(`Invalid Stanza: alleged MAM message from ${stanza.getAttribute('from')}`, stanza);
-            }
-        }
-
-        const from_bare_jid = Strophe.getBareJidFromJid(from_jid);
-        const is_me = from_bare_jid === _converse.bare_jid;
-        if (is_me && to_jid === null) {
-            return new StanzaParseError(
-                `Don't know how to handle message stanza without 'to' attribute. ${stanza.outerHTML}`,
-                stanza
-            );
-        }
-
-        const is_headline = st.isHeadline(stanza);
-        const is_server_message = st.isServerMessage(stanza);
-        let contact, contact_jid;
-        if (!is_headline && !is_server_message) {
-            contact_jid = is_me ? Strophe.getBareJidFromJid(to_jid) : from_bare_jid;
-            contact = await api.contacts.get(contact_jid);
-            if (contact === undefined && !api.settings.get("allow_non_roster_messaging")) {
-                log.error(stanza);
-                return new StanzaParseError(
-                    `Blocking messaging with a JID not in our roster because allow_non_roster_messaging is false.`,
-                    stanza
-                );
-            }
-        }
-        /**
-         * @typedef { Object } MessageAttributes
-         * The object which {@link st.parseMessage} returns
-         * @property { ('me'|'them') } sender - Whether the message was sent by the current user or someone else
-         * @property { Array<Object> } references - A list of objects representing XEP-0372 references
-         * @property { Boolean } editable - Is this message editable via XEP-0308?
-         * @property { Boolean } is_archived -  Is this message from a XEP-0313 MAM archive?
-         * @property { Boolean } is_carbon - Is this message a XEP-0280 Carbon?
-         * @property { Boolean } is_delayed - Was delivery of this message was delayed as per XEP-0203?
-         * @property { Boolean } is_encrypted -  Is this message XEP-0384  encrypted?
-         * @property { Boolean } is_error - Whether an error was received for this message
-         * @property { Boolean } is_headline - Is this a "headline" message?
-         * @property { Boolean } is_markable - Can this message be marked with a XEP-0333 chat marker?
-         * @property { Boolean } is_marker - Is this message a XEP-0333 Chat Marker?
-         * @property { Boolean } is_only_emojis - Does the message body contain only emojis?
-         * @property { Boolean } is_spoiler - Is this a XEP-0382 spoiler message?
-         * @property { Boolean } is_tombstone - Is this a XEP-0424 tombstone?
-         * @property { Boolean } is_unstyled - Whether XEP-0393 styling hints should be ignored
-         * @property { Boolean } is_valid_receipt_request - Does this message request a XEP-0184 receipt (and is not from us or a carbon or archived message)
-         * @property { Object } encrypted -  XEP-0384 encryption payload attributes
-         * @property { String } body - The contents of the <body> tag of the message stanza
-         * @property { String } chat_state - The XEP-0085 chat state notification contained in this message
-         * @property { String } contact_jid - The JID of the other person or entity
-         * @property { String } edited - An ISO8601 string recording the time that the message was edited per XEP-0308
-         * @property { String } error_condition - The defined error condition
-         * @property { String } error_text - The error text received from the server
-         * @property { String } error_type - The type of error received from the server
-         * @property { String } from - The sender JID
-         * @property { String } fullname - The full name of the sender
-         * @property { String } marker - The XEP-0333 Chat Marker value
-         * @property { String } marker_id - The `id` attribute of a XEP-0333 chat marker
-         * @property { String } msgid - The root `id` attribute of the stanza
-         * @property { String } nick - The roster nickname of the sender
-         * @property { String } oob_desc - The description of the XEP-0066 out of band data
-         * @property { String } oob_url - The URL of the XEP-0066 out of band data
-         * @property { String } origin_id - The XEP-0359 Origin ID
-         * @property { String } receipt_id - The `id` attribute of a XEP-0184 <receipt> element
-         * @property { String } received - An ISO8601 string recording the time that the message was received
-         * @property { String } replace_id - The `id` attribute of a XEP-0308 <replace> element
-         * @property { String } retracted - An ISO8601 string recording the time that the message was retracted
-         * @property { String } retracted_id - The `id` attribute of a XEP-424 <retracted> element
-         * @property { String } spoiler_hint  The XEP-0382 spoiler hint
-         * @property { String } stanza_id - The XEP-0359 Stanza ID. Note: the key is actualy `stanza_id ${by_jid}` and there can be multiple.
-         * @property { String } subject - The <subject> element value
-         * @property { String } thread - The <thread> element value
-         * @property { String } time - The time (in ISO8601 format), either given by the XEP-0203 <delay> element, or of receipt.
-         * @property { String } to - The recipient JID
-         * @property { String } type - The type of message
-         */
-        const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
-        const marker = st.getChatMarker(stanza);
-        const now =  (new Date()).toISOString();
-        let attrs = Object.assign({
-                contact_jid,
-                is_archived,
-                is_headline,
-                is_server_message,
-                'body': stanza.querySelector('body')?.textContent?.trim(),
-                'chat_state': getChatState(stanza),
-                'from': Strophe.getBareJidFromJid(stanza.getAttribute('from')),
-                'is_carbon': isCarbon(original_stanza),
-                'is_delayed': !!delay,
-                'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length,
-                'is_marker': !!marker,
-                'is_unstyled': !!sizzle(`unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length,
-                'marker_id': marker && marker.getAttribute('id'),
-                'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'),
-                'nick': contact?.attributes?.nickname,
-                'receipt_id': getReceiptId(stanza),
-                'received': (new Date()).toISOString(),
-                'references': getReferences(stanza),
-                'sender': is_me ? 'me' : 'them',
-                'subject': stanza.querySelector('subject')?.textContent,
-                'thread': stanza.querySelector('thread')?.textContent,
-                'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : now,
-                'to': stanza.getAttribute('to'),
-                'type': stanza.getAttribute('type')
-            },
-            getErrorAttributes(stanza),
-            getOutOfBandAttributes(stanza),
-            getSpoilerAttributes(stanza),
-            getCorrectionAttributes(stanza, original_stanza),
-            getStanzaIDs(stanza, original_stanza),
-            getRetractionAttributes(stanza, original_stanza),
-            getEncryptionAttributes(stanza, _converse)
-        );
-
-        if (attrs.is_archived) {
-            const from = original_stanza.getAttribute('from');
-            if (from && from !== _converse.bare_jid) {
-                return new StanzaParseError(`Invalid Stanza: Forged MAM message from ${from}`, stanza);
-            }
-        }
-        await api.emojis.initialize();
-        attrs = Object.assign({
-            'message': attrs.body || attrs.error, // TODO: Remove and use body and error attributes instead
-            'is_only_emojis': attrs.body ? u.isOnlyEmojis(attrs.body) : false,
-            'is_valid_receipt_request': isValidReceiptRequest(stanza, attrs)
-        }, attrs);
-
-        // We prefer to use one of the XEP-0359 unique and stable stanza IDs
-        // as the Model id, to avoid duplicates.
-        attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${(attrs.from)}`] || u.getUniqueId();
-
-        /**
-         * *Hook* which allows plugins to add additional parsing
-         * @event _converse#parseMessage
-         */
-        return api.hook('parseMessage', stanza, attrs);
-    },
-
-    /**
-     * Parses a passed in message stanza and returns an object of attributes.
-     * @method st#parseMUCMessage
-     * @param { XMLElement } stanza - The message stanza
-     * @param { XMLElement } original_stanza - The original stanza, that contains the
-     *  message stanza, if it was contained, otherwise it's the message stanza itself.
-     * @param { _converse.ChatRoom } chatbox
-     * @param { _converse } _converse
-     * @returns { Promise<MUCMessageAttributes|Error> }
-     */
-    async parseMUCMessage (stanza, chatbox, _converse) {
-        const err = rejectUnencapsulatedForward(stanza);
-        if (err) {
-            return err;
-        }
-
-        const selector = `[xmlns="${NS.MAM}"] > forwarded[xmlns="${NS.FORWARD}"] > message`;
-        const original_stanza = stanza;
-        stanza = sizzle(selector, stanza).pop() || stanza;
-
-        if (sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length) {
-            return new StanzaParseError(
-                `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`,
-                stanza
-            );
-        }
-        const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
-        const from = stanza.getAttribute('from');
-        const nick = Strophe.unescapeNode(Strophe.getResourceFromJid(from));
-        const marker = st.getChatMarker(stanza);
-        const now =  (new Date()).toISOString();
-        /**
-         * @typedef { Object } MUCMessageAttributes
-         * The object which {@link st.parseMUCMessage} returns
-         * @property { ('me'|'them') } sender - Whether the message was sent by the current user or someone else
-         * @property { Array<Object> } references - A list of objects representing XEP-0372 references
-         * @property { Boolean } editable - Is this message editable via XEP-0308?
-         * @property { Boolean } is_archived -  Is this message from a XEP-0313 MAM archive?
-         * @property { Boolean } is_carbon - Is this message a XEP-0280 Carbon?
-         * @property { Boolean } is_delayed - Was delivery of this message was delayed as per XEP-0203?
-         * @property { Boolean } is_encrypted -  Is this message XEP-0384  encrypted?
-         * @property { Boolean } is_error - Whether an error was received for this message
-         * @property { Boolean } is_headline - Is this a "headline" message?
-         * @property { Boolean } is_markable - Can this message be marked with a XEP-0333 chat marker?
-         * @property { Boolean } is_marker - Is this message a XEP-0333 Chat Marker?
-         * @property { Boolean } is_only_emojis - Does the message body contain only emojis?
-         * @property { Boolean } is_spoiler - Is this a XEP-0382 spoiler message?
-         * @property { Boolean } is_tombstone - Is this a XEP-0424 tombstone?
-         * @property { Boolean } is_unstyled - Whether XEP-0393 styling hints should be ignored
-         * @property { Boolean } is_valid_receipt_request - Does this message request a XEP-0184 receipt (and is not from us or a carbon or archived message)
-         * @property { Object } encrypted -  XEP-0384 encryption payload attributes
-         * @property { String } body - The contents of the <body> tag of the message stanza
-         * @property { String } chat_state - The XEP-0085 chat state notification contained in this message
-         * @property { String } edited - An ISO8601 string recording the time that the message was edited per XEP-0308
-         * @property { String } error_condition - The defined error condition
-         * @property { String } error_text - The error text received from the server
-         * @property { String } error_type - The type of error received from the server
-         * @property { String } from - The sender JID (${muc_jid}/${nick})
-         * @property { String } from_muc - The JID of the MUC from which this message was sent
-         * @property { String } from_real_jid - The real JID of the sender, if available
-         * @property { String } fullname - The full name of the sender
-         * @property { String } marker - The XEP-0333 Chat Marker value
-         * @property { String } marker_id - The `id` attribute of a XEP-0333 chat marker
-         * @property { String } moderated - The type of XEP-0425 moderation (if any) that was applied
-         * @property { String } moderated_by - The JID of the user that moderated this message
-         * @property { String } moderated_id - The  XEP-0359 Stanza ID of the message that this one moderates
-         * @property { String } moderation_reason - The reason provided why this message moderates another
-         * @property { String } msgid - The root `id` attribute of the stanza
-         * @property { String } nick - The MUC nickname of the sender
-         * @property { String } oob_desc - The description of the XEP-0066 out of band data
-         * @property { String } oob_url - The URL of the XEP-0066 out of band data
-         * @property { String } origin_id - The XEP-0359 Origin ID
-         * @property { String } receipt_id - The `id` attribute of a XEP-0184 <receipt> element
-         * @property { String } received - An ISO8601 string recording the time that the message was received
-         * @property { String } replace_id - The `id` attribute of a XEP-0308 <replace> element
-         * @property { String } retracted - An ISO8601 string recording the time that the message was retracted
-         * @property { String } retracted_id - The `id` attribute of a XEP-424 <retracted> element
-         * @property { String } spoiler_hint  The XEP-0382 spoiler hint
-         * @property { String } stanza_id - The XEP-0359 Stanza ID. Note: the key is actualy `stanza_id ${by_jid}` and there can be multiple.
-         * @property { String } subject - The <subject> element value
-         * @property { String } thread - The <thread> element value
-         * @property { String } time - The time (in ISO8601 format), either given by the XEP-0203 <delay> element, or of receipt.
-         * @property { String } to - The recipient JID
-         * @property { String } type - The type of message
-         */
-        let attrs = Object.assign({
-                from,
-                nick,
-                'body': stanza.querySelector('body')?.textContent?.trim(),
-                'chat_state': getChatState(stanza),
-                'from_muc': Strophe.getBareJidFromJid(from),
-                'from_real_jid': chatbox.occupants.findOccupant({nick})?.get('jid'),
-                'is_archived': st.isArchived(original_stanza),
-                'is_carbon': isCarbon(original_stanza),
-                'is_delayed': !!delay,
-                'is_headline': st.isHeadline(stanza),
-                'is_markable': !!sizzle(`markable[xmlns="${Strophe.NS.MARKERS}"]`, stanza).length,
-                'is_marker': !!marker,
-                'is_unstyled': !!sizzle(`unstyled[xmlns="${Strophe.NS.STYLING}"]`, stanza).length,
-                'marker_id': marker && marker.getAttribute('id'),
-                'msgid': stanza.getAttribute('id') || original_stanza.getAttribute('id'),
-                'receipt_id': getReceiptId(stanza),
-                'received': (new Date()).toISOString(),
-                'references': getReferences(stanza),
-                'subject': stanza.querySelector('subject')?.textContent,
-                'thread': stanza.querySelector('thread')?.textContent,
-                'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : now,
-                'to': stanza.getAttribute('to'),
-                'type': stanza.getAttribute('type'),
-            },
-            getErrorAttributes(stanza),
-            getOutOfBandAttributes(stanza),
-            getSpoilerAttributes(stanza),
-            getCorrectionAttributes(stanza, original_stanza),
-            getStanzaIDs(stanza, original_stanza),
-            getRetractionAttributes(stanza, original_stanza),
-            getModerationAttributes(stanza),
-            getEncryptionAttributes(stanza, _converse)
-        );
-
-
-        await api.emojis.initialize();
-        attrs = Object.assign({
-            'is_only_emojis': attrs.body ? u.isOnlyEmojis(attrs.body) : false,
-            'is_valid_receipt_request': isValidReceiptRequest(stanza, attrs),
-            'message': attrs.body || attrs.error, // TODO: Remove and use body and error attributes instead
-            'sender': attrs.nick === chatbox.get('nick') ? 'me': 'them',
-        }, attrs);
-
-        if (attrs.is_archived && original_stanza.getAttribute('from') !== attrs.from_muc) {
-            return new StanzaParseError(
-                `Invalid Stanza: Forged MAM message from ${original_stanza.getAttribute('from')}`,
-                stanza
-            );
-        } else if (attrs.is_archived && original_stanza.getAttribute('from') !== chatbox.get('jid')) {
-            return new StanzaParseError(
-                `Invalid Stanza: Forged MAM groupchat message from ${stanza.getAttribute('from')}`,
-                stanza
-            );
-        } else if (attrs.is_carbon) {
-            return new StanzaParseError(
-                "Invalid Stanza: MUC messages SHOULD NOT be XEP-0280 carbon copied",
-                stanza
-            );
-        }
-        // We prefer to use one of the XEP-0359 unique and stable stanza IDs as the Model id, to avoid duplicates.
-        attrs['id'] = attrs['origin_id'] || attrs[`stanza_id ${(attrs.from_muc || attrs.from)}`] || u.getUniqueId();
-        /**
-         * *Hook* which allows plugins to add additional parsing
-         * @event _converse#parseMUCMessage
-         */
-        return api.hook('parseMUCMessage', stanza, attrs);
-    },
-
-    /**
-     * Parses a passed in MUC presence stanza and returns an object of attributes.
-     * @method st#parseMUCPresence
-     * @param { XMLElement } stanza - The presence stanza
-     * @returns { Object }
-     */
-    parseMUCPresence (stanza) {
-        const from = stanza.getAttribute("from");
-        const type = stanza.getAttribute("type");
-        const data = {
-            'from': from,
-            'nick': Strophe.getResourceFromJid(from),
-            'type': type,
-            'states': [],
-            'hats': [],
-            'show': type !== 'unavailable' ? 'online' : 'offline'
-        };
-        Array.from(stanza.children).forEach(child => {
-            if (child.matches('status')) {
-                data.status = child.textContent || null;
-            } else if (child.matches('show')) {
-                data.show = child.textContent || 'online';
-            } else if (child.matches('x') && child.getAttribute('xmlns') === Strophe.NS.MUC_USER) {
-                Array.from(child.children).forEach(item => {
-                    if (item.nodeName === "item") {
-                        data.affiliation = item.getAttribute("affiliation");
-                        data.role = item.getAttribute("role");
-                        data.jid = item.getAttribute("jid");
-                        data.nick = item.getAttribute("nick") || data.nick;
-                    } else if (item.nodeName == 'status' && item.getAttribute("code")) {
-                        data.states.push(item.getAttribute("code"));
-                    }
-                });
-            } else if (child.matches('x') && child.getAttribute('xmlns') === Strophe.NS.VCARDUPDATE) {
-                data.image_hash = child.querySelector('photo')?.textContent;
-            } else if (child.matches('hats') && child.getAttribute('xmlns') === Strophe.NS.MUC_HATS) {
-                data['hats'] = Array.from(child.children).map(c => c.matches('hat') && {
-                    'title': c.getAttribute('title'),
-                    'uri': c.getAttribute('uri')
-                });
-            }
-        });
-        return data;
-    }
-}
-
-export default st;

+ 2 - 2
src/modals/muc-list.js

@@ -1,11 +1,11 @@
 import BootstrapModal from "./base.js";
 import BootstrapModal from "./base.js";
 import log from "@converse/headless/log";
 import log from "@converse/headless/log";
-import st from "@converse/headless/utils/stanza";
 import tpl_list_chatrooms_modal from "./templates/muc-list.js";
 import tpl_list_chatrooms_modal from "./templates/muc-list.js";
 import tpl_room_description from "templates/room_description.html";
 import tpl_room_description from "templates/room_description.html";
 import tpl_spinner from "templates/spinner.js";
 import tpl_spinner from "templates/spinner.js";
 import { __ } from '../i18n';
 import { __ } from '../i18n';
 import { _converse, api, converse } from "@converse/headless/core";
 import { _converse, api, converse } from "@converse/headless/core";
+import { getAttributes } from '@converse/headless/shared/parsers';
 import { head } from "lodash-es";
 import { head } from "lodash-es";
 
 
 const { Strophe, $iq, sizzle } = converse.env;
 const { Strophe, $iq, sizzle } = converse.env;
@@ -144,7 +144,7 @@ export default BootstrapModal.extend({
         const rooms = iq ? sizzle('query item', iq) : [];
         const rooms = iq ? sizzle('query item', iq) : [];
         if (rooms.length) {
         if (rooms.length) {
             this.model.set({'feedback_text': __('Groupchats found')}, {'silent': true});
             this.model.set({'feedback_text': __('Groupchats found')}, {'silent': true});
-            this.items = rooms.map(st.getAttributes);
+            this.items = rooms.map(getAttributes);
         } else {
         } else {
             this.items = [];
             this.items = [];
             this.model.set({'feedback_text': __('No groupchats found')}, {'silent': true});
             this.model.set({'feedback_text': __('No groupchats found')}, {'silent': true});

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

@@ -7,7 +7,6 @@
 import '../../components/muc-sidebar';
 import '../../components/muc-sidebar';
 import '../chatview/index.js';
 import '../chatview/index.js';
 import '../modal.js';
 import '../modal.js';
-import '@converse/headless/utils/muc';
 import ChatRoomViewMixin from './muc.js';
 import ChatRoomViewMixin from './muc.js';
 import MUCConfigForm from './config-form.js';
 import MUCConfigForm from './config-form.js';
 import MUCPasswordForm from './password-form.js';
 import MUCPasswordForm from './password-form.js';