Explorar o código

Refactor the body directive

Changes how rich message markup is generated and kept track of.
We now have a MessageText class which subclasses String and which keeps
track of the rich markup associated with the message.
JC Brand %!s(int64=5) %!d(string=hai) anos
pai
achega
8943609d10

+ 1 - 1
Makefile

@@ -231,4 +231,4 @@ doc: node_modules docsdev apidoc
 
 
 PHONY: apidoc
 PHONY: apidoc
 apidoc:
 apidoc:
-	$(JSDOC) --private --readme docs/source/jsdoc_intro.md -c docs/source/conf.json -d docs/html/api src/*.js src/utils/*.js src/headless/*.js src/headless/utils/*.js
+	$(JSDOC) --private --readme docs/source/jsdoc_intro.md -c docs/source/conf.json -d docs/html/api src/templates/directives/*.js src/components/*.js src/*.js src/utils/*.js src/headless/*.js src/headless/utils/*.js

+ 1 - 1
spec/chatbox.js

@@ -1663,7 +1663,7 @@ describe("Chatboxes", function () {
             await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-msg').length, 1000);
             await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-msg').length, 1000);
             expect(view.model.sendMessage).toHaveBeenCalled();
             expect(view.model.sendMessage).toHaveBeenCalled();
             const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-            expect(msg.innerHTML.replace(/\<!----\>/g, '')).toEqual(
+            await u.waitUntil(() => msg.innerHTML.replace(/\<!----\>/g, '') ===
                 '<a target="_blank" rel="noopener" href="https://www.openstreetmap.org/?mlat=37.786971&amp;'+
                 '<a target="_blank" rel="noopener" href="https://www.openstreetmap.org/?mlat=37.786971&amp;'+
                 'mlon=-122.399677#map=18/37.786971/-122.399677">https://www.openstreetmap.org/?mlat=37.786971&amp;mlon=-122.399677#map=18/37.786971/-122.399677</a>');
                 'mlon=-122.399677#map=18/37.786971/-122.399677">https://www.openstreetmap.org/?mlat=37.786971&amp;mlon=-122.399677#map=18/37.786971/-122.399677</a>');
             done();
             done();

+ 1 - 1
spec/emojis.js

@@ -276,7 +276,7 @@ describe("Emojis", function () {
             });
             });
             await new Promise(resolve => view.model.messages.once('rendered', resolve));
             await new Promise(resolve => view.model.messages.once('rendered', resolve));
             const body = view.el.querySelector('converse-chat-message-body');
             const body = view.el.querySelector('converse-chat-message-body');
-            expect(body.innerHTML.replace(/<!---->/g, '').trim()).toBe(
+            await u.waitUntil(() => body.innerHTML.replace(/<!---->/g, '').trim() ===
                 'Running tests for <img class="emoji" draggable="false" title=":converse:" alt=":converse:" src="/dist/images/custom_emojis/converse.png">');
                 'Running tests for <img class="emoji" draggable="false" title=":converse:" alt=":converse:" src="/dist/images/custom_emojis/converse.png">');
             done();
             done();
         }));
         }));

+ 38 - 1
spec/mentions.js

@@ -4,7 +4,7 @@ const { Promise, Strophe, $msg, $pres } = converse.env;
 const u = converse.env.utils;
 const u = converse.env.utils;
 
 
 
 
-describe("A groupchat message", function () {
+describe("An incoming groupchat message", function () {
 
 
     it("is specially marked when you are mentioned in it",
     it("is specially marked when you are mentioned in it",
         mock.initConverse(
         mock.initConverse(
@@ -102,6 +102,7 @@ describe("A groupchat message", function () {
                 .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'7', 'end':'11', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up()
                 .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'7', 'end':'11', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up()
                 .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'12', 'end':'15', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up()
                 .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'12', 'end':'15', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up()
                 .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'16', 'end':'24', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree;
                 .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'16', 'end':'24', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree;
+
         await view.model.handleMessageStanza(msg);
         await view.model.handleMessageStanza(msg);
         const message = await u.waitUntil(() => view.el.querySelector('.chat-msg__text'));
         const message = await u.waitUntil(() => view.el.querySelector('.chat-msg__text'));
         expect(message.classList.length).toEqual(1);
         expect(message.classList.length).toEqual(1);
@@ -111,6 +112,10 @@ describe("A groupchat message", function () {
             '<span class="mention">mr.robot</span>, how are you?');
             '<span class="mention">mr.robot</span>, how are you?');
         done();
         done();
     }));
     }));
+});
+
+
+describe("A sent groupchat message", function () {
 
 
     describe("in which someone is mentioned", function () {
     describe("in which someone is mentioned", function () {
 
 
@@ -206,6 +211,12 @@ describe("A groupchat message", function () {
             expect(references)
             expect(references)
                 .toEqual([]);
                 .toEqual([]);
 
 
+            [text, references] = view.model.parseTextForReferences(
+                "Welcome @gibson 💩 We have a guide on how to do that here: https://conversejs.org/docs/html/index.html");
+            expect(text).toBe("Welcome gibson 💩 We have a guide on how to do that here: https://conversejs.org/docs/html/index.html");
+            expect(references.length).toBe(1);
+            expect(references).toEqual([{"begin":8,"end":14,"value":"gibson","type":"mention","uri":"xmpp:gibson@montague.lit"}]);
+
             [text, references] = view.model.parseTextForReferences(
             [text, references] = view.model.parseTextForReferences(
                 'https://linkmauve.fr@Link Mauve/ https://linkmauve.fr/@github/is_back gibson@gibson.com gibson@Link Mauve.fr')
                 'https://linkmauve.fr@Link Mauve/ https://linkmauve.fr/@github/is_back gibson@gibson.com gibson@Link Mauve.fr')
             expect(text).toBe(
             expect(text).toBe(
@@ -436,4 +447,30 @@ describe("A groupchat message", function () {
             done();
             done();
         }));
         }));
     });
     });
+
+    it("highlights all users mentioned via XEP-0372 references in a quoted message",
+        mock.initConverse(
+            ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+        const members = [{'jid': 'gibson@gibson.net', 'nick': 'gibson', 'affiliation': 'member'}];
+        const muc_jid = 'lounge@montague.lit';
+        await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom', [], members);
+        const view = _converse.api.chatviews.get(muc_jid);
+        const textarea = view.el.querySelector('textarea.chat-textarea');
+        textarea.value = "Welcome @gibson 💩 We have a guide on how to do that here: https://conversejs.org/docs/html/index.html";
+        const enter_event = {
+            'target': textarea,
+            'preventDefault': function preventDefault () {},
+            'stopPropagation': function stopPropagation () {},
+            'keyCode': 13 // Enter
+        }
+        view.onKeyDown(enter_event);
+        const message = await u.waitUntil(() => view.el.querySelector('.chat-msg__text'));
+        expect(message.innerHTML.replace(/<!---->/g, '')).toEqual(
+            `Welcome <span class="mention">gibson</span> <span title=":poop:">💩</span> `+
+            `We have a guide on how to do that here: `+
+            `<a target="_blank" rel="noopener" href="https://conversejs.org/docs/html/index.html">https://conversejs.org/docs/html/index.html</a>`);
+        done();
+    }));
 });
 });

+ 4 - 4
spec/messages.js

@@ -104,7 +104,7 @@ describe("A Chat Message", function () {
         expect(textarea.value).toBe('');
         expect(textarea.value).toBe('');
 
 
         const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
         const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
-        expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(2);
+        await u.waitUntil(() => view.el.querySelectorAll('.chat-msg .chat-msg__action').length === 2);
         let action = view.el.querySelector('.chat-msg .chat-msg__action');
         let action = view.el.querySelector('.chat-msg .chat-msg__action');
         expect(action.textContent.trim()).toBe('Edit');
         expect(action.textContent.trim()).toBe('Edit');
 
 
@@ -891,8 +891,8 @@ describe("A Chat Message", function () {
         await new Promise(resolve => view.model.messages.once('rendered', resolve));
         await new Promise(resolve => view.model.messages.once('rendered', resolve));
         const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
         const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
         expect(msg.textContent).toEqual(message);
         expect(msg.textContent).toEqual(message);
-        expect(msg.innerHTML.replace(/<!---->/g, ''))
-            .toEqual('This message contains a hyperlink: <a target="_blank" rel="noopener" href="http://www.opkode.com">www.opkode.com</a>');
+        await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') ===
+            'This message contains a hyperlink: <a target="_blank" rel="noopener" href="http://www.opkode.com">www.opkode.com</a>');
         done();
         done();
     }));
     }));
 
 
@@ -921,7 +921,7 @@ describe("A Chat Message", function () {
             </message>`);
             </message>`);
         _converse.connection._dataRecv(mock.createRequest(stanza));
         _converse.connection._dataRecv(mock.createRequest(stanza));
         await new Promise(resolve => view.model.messages.once('rendered', resolve));
         await new Promise(resolve => view.model.messages.once('rendered', resolve));
-        expect(view.content.querySelector('converse-chat-message:last-child .chat-msg__text').innerHTML.replace(/<!---->/g, '')).toBe('Hey\n\nHave you heard the news?');
+        await u.waitUntil(() => view.content.querySelector('converse-chat-message:last-child .chat-msg__text').innerHTML.replace(/<!---->/g, '') === 'Hey\n\nHave you heard the news?');
         stanza = u.toStanza(`
         stanza = u.toStanza(`
             <message from="${contact_jid}"
             <message from="${contact_jid}"
                      type="chat"
                      type="chat"

+ 20 - 16
spec/xss.js

@@ -144,28 +144,32 @@ describe("XSS", function () {
             let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
             expect(msg.textContent).toEqual(message);
             expect(msg.innerHTML.replace(/<!---->/g, ''))
             expect(msg.innerHTML.replace(/<!---->/g, ''))
-                .toEqual('<a target="_blank" rel="noopener" href="http://www.opkode.com/%27onmouseover=%27alert%281%29%27whatever">http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever</a>');
+                .toEqual('http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever');
+
+            await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') ===
+                '<a target="_blank" rel="noopener" href="http://www.opkode.com/%27onmouseover=%27alert%281%29%27whatever">http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever</a>');
 
 
             message = 'http://www.opkode.com/"onmouseover="alert(1)"whatever';
             message = 'http://www.opkode.com/"onmouseover="alert(1)"whatever';
             await mock.sendMessage(view, message);
             await mock.sendMessage(view, message);
 
 
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual('<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert%281%29%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>');
+            await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') ===
+                '<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert%281%29%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>');
 
 
             message = "https://en.wikipedia.org/wiki/Ender's_Game";
             message = "https://en.wikipedia.org/wiki/Ender's_Game";
             await mock.sendMessage(view, message);
             await mock.sendMessage(view, message);
 
 
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual('<a target="_blank" rel="noopener" href="https://en.wikipedia.org/wiki/Ender%27s_Game">'+message+'</a>');
+            await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') === '<a target="_blank" rel="noopener" href="https://en.wikipedia.org/wiki/Ender%27s_Game">'+message+'</a>');
 
 
             message = "<https://bugs.documentfoundation.org/show_bug.cgi?id=123737>";
             message = "<https://bugs.documentfoundation.org/show_bug.cgi?id=123737>";
             await mock.sendMessage(view, message);
             await mock.sendMessage(view, message);
 
 
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual(
+            await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') ===
                 `&lt;<a target="_blank" rel="noopener" href="https://bugs.documentfoundation.org/show_bug.cgi?id=123737">https://bugs.documentfoundation.org/show_bug.cgi?id=123737</a>&gt;`);
                 `&lt;<a target="_blank" rel="noopener" href="https://bugs.documentfoundation.org/show_bug.cgi?id=123737">https://bugs.documentfoundation.org/show_bug.cgi?id=123737</a>&gt;`);
 
 
             message = '<http://www.opkode.com/"onmouseover="alert(1)"whatever>';
             message = '<http://www.opkode.com/"onmouseover="alert(1)"whatever>';
@@ -173,7 +177,7 @@ describe("XSS", function () {
 
 
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual(
+            await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') ===
                 '&lt;<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert%281%29%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>&gt;');
                 '&lt;<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert%281%29%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>&gt;');
 
 
             message = `https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2`
             message = `https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2`
@@ -181,7 +185,7 @@ describe("XSS", function () {
 
 
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual(
+            await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') ===
                 `<a target="_blank" rel="noopener" href="https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=%213m6%211e1%213m4%211sQ7SdHo_bPLPlLlU8GSGWaQ%212e0%217i13312%218i6656%214m5%213m4%211s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08%218m2%213d52.3773668%214d4.5489388%215m1%211e2">https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2</a>`);
                 `<a target="_blank" rel="noopener" href="https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=%213m6%211e1%213m4%211sQ7SdHo_bPLPlLlU8GSGWaQ%212e0%217i13312%218i6656%214m5%213m4%211s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08%218m2%213d52.3773668%214d4.5489388%215m1%211e2">https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2</a>`);
             done();
             done();
         }));
         }));
@@ -229,16 +233,16 @@ describe("XSS", function () {
                 expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual(url);
                 expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual(url);
             }
             }
 
 
-            function checkParsedURL ({ entered, href }) {
+            async function checkParsedURL ({ entered, href }) {
                 const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
                 const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
                 expect(msg.textContent).toEqual(entered);
                 expect(msg.textContent).toEqual(entered);
-                expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual(`<a target="_blank" rel="noopener" href="${href}">${entered}</a>`);
+                await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') === `<a target="_blank" rel="noopener" href="${href}">${entered}</a>`);
             }
             }
 
 
-            function checkParsedXMPPURL ({ entered, href }) {
+            async function checkParsedXMPPURL ({ entered, href }) {
                 const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
                 const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
                 expect(msg.textContent.trim()).toEqual(entered);
                 expect(msg.textContent.trim()).toEqual(entered);
-                expect(msg.innerHTML.replace(/<!---->/g, '').trim()).toEqual(`<a target="_blank" rel="noopener" href="${href}">${entered}</a>`);
+                await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '').trim() === `<a target="_blank" rel="noopener" href="${href}">${entered}</a>`);
             }
             }
 
 
             await mock.sendMessage(view, bad_urls[0]);
             await mock.sendMessage(view, bad_urls[0]);
@@ -248,22 +252,22 @@ describe("XSS", function () {
             checkNonParsedURL(bad_urls[1]);
             checkNonParsedURL(bad_urls[1]);
 
 
             await mock.sendMessage(view, good_urls[0].entered);
             await mock.sendMessage(view, good_urls[0].entered);
-            checkParsedURL(good_urls[0]);
+            await checkParsedURL(good_urls[0]);
 
 
             await mock.sendMessage(view, good_urls[1].entered);
             await mock.sendMessage(view, good_urls[1].entered);
-            checkParsedURL(good_urls[1]);
+            await checkParsedURL(good_urls[1]);
 
 
             await mock.sendMessage(view, good_urls[2].entered);
             await mock.sendMessage(view, good_urls[2].entered);
-            checkParsedURL(good_urls[2]);
+            await checkParsedURL(good_urls[2]);
 
 
             await mock.sendMessage(view, good_urls[3].entered);
             await mock.sendMessage(view, good_urls[3].entered);
-            checkParsedXMPPURL(good_urls[3]);
+            await checkParsedXMPPURL(good_urls[3]);
 
 
             await mock.sendMessage(view, good_urls[4].entered);
             await mock.sendMessage(view, good_urls[4].entered);
-            checkParsedURL(good_urls[4]);
+            await checkParsedURL(good_urls[4]);
 
 
             await mock.sendMessage(view, good_urls[5].entered);
             await mock.sendMessage(view, good_urls[5].entered);
-            checkParsedURL(good_urls[5]);
+            await checkParsedURL(good_urls[5]);
 
 
             done();
             done();
         }));
         }));

+ 1 - 1
src/components/emoji-picker-content.js

@@ -92,7 +92,7 @@ export default class EmojiPickerContent extends CustomElement {
               return true;
               return true;
           }
           }
       } else {
       } else {
-          if (this.current_skintone && _converse.emojis.toned.includes(shortname)) {
+          if (this.current_skintone && converse.emojis.toned.includes(shortname)) {
               return true;
               return true;
           }
           }
       }
       }

+ 4 - 16
src/headless/converse-emoji.js

@@ -72,7 +72,7 @@ function getTonedEmojis () {
 }
 }
 
 
 
 
-function convertASCII2Emoji (str) {
+export function convertASCII2Emoji (str) {
     // Replace ASCII smileys
     // Replace ASCII smileys
     return str.replace(ASCII_REPLACE_REGEX, (entire, m1, m2, m3) => {
     return str.replace(ASCII_REPLACE_REGEX, (entire, m1, m2, m3) => {
         if( (typeof m3 === 'undefined') || (m3 === '') || (!(u.unescapeHTML(m3) in ASCII_LIST)) ) {
         if( (typeof m3 === 'undefined') || (m3 === '') || (!(u.unescapeHTML(m3) in ASCII_LIST)) ) {
@@ -86,7 +86,7 @@ function convertASCII2Emoji (str) {
 }
 }
 
 
 
 
-function getEmojiMarkup (data, options={unicode_only: false, add_title_wrapper: false}) {
+export function getEmojiMarkup (data, options={unicode_only: false, add_title_wrapper: false}) {
     const emoji = data.emoji;
     const emoji = data.emoji;
     const shortname = data.shortname;
     const shortname = data.shortname;
     if (emoji) {
     if (emoji) {
@@ -111,7 +111,7 @@ function getEmojiMarkup (data, options={unicode_only: false, add_title_wrapper:
 }
 }
 
 
 
 
-function getShortnameReferences (text) {
+export function getShortnameReferences (text) {
     const references = [...text.matchAll(shortnames_regex)];
     const references = [...text.matchAll(shortnames_regex)];
     return references.map(ref => {
     return references.map(ref => {
         const cp = converse.emojis.by_sn[ref[0]].cp;
         const cp = converse.emojis.by_sn[ref[0]].cp;
@@ -126,7 +126,7 @@ function getShortnameReferences (text) {
 }
 }
 
 
 
 
-function getCodePointReferences (text) {
+export function getCodePointReferences (text) {
     const references = [];
     const references = [];
     const how = {
     const how = {
         callback: (icon_id) => {
         callback: (icon_id) => {
@@ -228,18 +228,6 @@ converse.plugins.add('converse-emoji', {
         const emojis_by_attribute = {};
         const emojis_by_attribute = {};
 
 
         Object.assign(u, {
         Object.assign(u, {
-            /**
-             * Replaces emoji shortnames in the passed-in string with unicode or image-based emojis
-             * (based on the value of `use_system_emojis`).
-             * @method u.addEmoji
-             * @param { String } text = The text
-             * @returns { String } The text with shortnames replaced with emoji unicodes or images.
-             */
-            addEmoji (text) {
-                const options = {add_title_wrapper: true, unicode_only: false};
-                return u.shortnamesToEmojis(text, options);
-            },
-
             /**
             /**
              * Returns an emoji represented by the passed in shortname.
              * Returns an emoji represented by the passed in shortname.
              * Scans the passed in text for shortnames and replaces them with
              * Scans the passed in text for shortnames and replaces them with

+ 172 - 69
src/templates/directives/body.js

@@ -1,111 +1,214 @@
+import URI from "urijs";
+import log from '@converse/headless/log';
 import { _converse, api, converse } from  "@converse/headless/converse-core";
 import { _converse, api, converse } from  "@converse/headless/converse-core";
+import { convertASCII2Emoji, getEmojiMarkup, getCodePointReferences, getShortnameReferences } from "@converse/headless/converse-emoji.js";
 import { directive, html } from "lit-html";
 import { directive, html } from "lit-html";
 import { isString } from "lodash-es";
 import { isString } from "lodash-es";
+import { until } from 'lit-html/directives/until.js';
 
 
 const u = converse.env.utils;
 const u = converse.env.utils;
 
 
 
 
-class MessageBodyRenderer extends String {
+/**
+ * @class MessageText
+ * A String subclass that is used to represent the rich text
+ * of a chat message.
+ *
+ * The "rich" parts of the text is represented by lit-html TemplateResult
+ * objects which are added via the {@link MessageText.addTemplateResult}
+ * method and saved as metadata.
+ *
+ * By default Converse adds TemplateResults to support emojis, hyperlinks,
+ * images, map URIs and mentions.
+ *
+ * 3rd party plugins can listen for the `beforeMessageBodyTransformed`
+ * and/or `afterMessageBodyTransformed` events and then call
+ * `addTemplateResult` on the MessageText instance in order to add their own
+ * rich features.
+ */
+class MessageText extends String {
+
+    /**
+     * Create a new {@link MessageText} instance.
+     * @param { String } text - The plain text that was received from the `<message>` stanza.
+     */
+    constructor (text) {
+        super(text);
+        this.references = [];
+    }
+
+    /**
+     * The "rich" markup parts of a chat message are represented by lit-html
+     * TemplateResult objects.
+     *
+     * This method can be used to add new template results to this message's
+     * text.
+     *
+     * @method MessageText.addTemplateResult
+     * @param { Number } begin - The starting index of the plain message text
+     * which is being replaced with markup.
+     * @param { Number } end - The ending index of the plain message text
+     * which is being replaced with markup.
+     * @param { Object } template - The lit-html TemplateResult instance
+     */
+    addTemplateResult (begin, end, template) {
+        this.references.push({begin, end, template});
+    }
+
+    isMeCommand () {
+        const text = this.toString();
+        if (!text) {
+            return false;
+        }
+        return text.startsWith('/me ');
+    }
+
+    static replaceText (text) {
+        return convertASCII2Emoji(text.replace(/\n\n+/g, '\n\n'));
+    }
+
+    marshall () {
+        let list = [this.toString()];
+        this.references
+            .sort((a, b) => b.begin - a.begin)
+            .forEach(ref => {
+                const text = list.shift();
+                list = [
+                    text.slice(0, ref.begin),
+                    ref.template,
+                    text.slice(ref.end),
+                    ...list
+                ];
+            });
+
+        // Subtract `/me ` from 3rd person messages
+        if (this.isMeCommand()) list[0] = list[0].substring(4);
+
+        return list.reduce((acc, i) => isString(i) ? [...acc, MessageText.replaceText(i)] : [...acc, i], []);
+    }
+}
+
+
+function addMapURLs (text) {
+    const regex = /geo:([\-0-9.]+),([\-0-9.]+)(?:,([\-0-9.]+))?(?:\?(.*))?/g;
+    const matches = text.matchAll(regex);
+    for (const m of matches) {
+        text.addTemplateResult(
+            m.index,
+            m.index+m.input.length,
+            u.convertUrlToHyperlink(m.input.replace(regex, _converse.geouri_replacement))
+        );
+    }
+}
+
+
+function addHyperlinks (text) {
+    const objs = [];
+    try {
+        const parse_options = { 'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi };
+        URI.withinString(text, (url, start, end) => {
+            objs.push({url, start, end})
+            return url;
+        } , parse_options);
+    } catch (error) {
+        log.debug(error);
+        return;
+    }
+    const show_images = api.settings.get('show_images_inline');
+    objs.forEach(url_obj => {
+        const url_text = text.slice(url_obj.start, url_obj.end);
+        text.addTemplateResult(
+            url_obj.start,
+            url_obj.end,
+            show_images && u.isImageURL(url_text) ? u.convertToImageTag(url_text) : u.convertUrlToHyperlink(url_text),
+        );
+    });
+}
+
+
+async function addEmojis (text) {
+    await api.emojis.initialize();
+    const references = [...getShortnameReferences(text.toString()), ...getCodePointReferences(text.toString())];
+    references.forEach(e => {
+        text.addTemplateResult(
+            e.begin,
+            e.end,
+            getEmojiMarkup(e, {'add_title_wrapper': true})
+        );
+    });
+}
+
+
+const tpl_mention_with_nick = (o) => html`<span class="mention mention--self badge badge-info">${o.mention}</span>`;
+const tpl_mention = (o) => html`<span class="mention">${o.mention}</span>`;
+
+
+function addReferences (text, model) {
+    const nick = model.collection.chatbox.get('nick');
+    model.get('references')?.forEach(ref => {
+        const mention = text.slice(ref.begin, ref.end);
+        if (mention === nick) {
+            text.addTemplateResult(ref.begin, ref.end, tpl_mention_with_nick({mention}));
+        } else {
+            text.addTemplateResult(ref.begin, ref.end, tpl_mention({mention}));
+        }
+    });
+}
+
+
+class MessageBodyRenderer {
 
 
     constructor (component) {
     constructor (component) {
-        super();
-        this.text = component.model.getMessageText();
         this.model = component.model;
         this.model = component.model;
         this.component = component;
         this.component = component;
+        this.text = this.component.model.getMessageText();
     }
     }
 
 
     async transform () {
     async transform () {
+        const text = new MessageText(this.text);
         /**
         /**
          * Synchronous event which provides a hook for transforming a chat message's body text
          * Synchronous event which provides a hook for transforming a chat message's body text
          * before the default transformations have been applied.
          * before the default transformations have been applied.
          * @event _converse#beforeMessageBodyTransformed
          * @event _converse#beforeMessageBodyTransformed
          * @param { _converse.Message } model - The model representing the message
          * @param { _converse.Message } model - The model representing the message
-         * @param { string } text - The message text
+         * @param { MessageText } text - A {@link MessageText } instance. You
+         * can call {@link MessageText#addTemplateResult } on it in order to
+         * add TemplateResult objects meant to render rich parts of the
+         * message.
          * @example _converse.api.listen.on('beforeMessageBodyTransformed', (view, text) => { ... });
          * @example _converse.api.listen.on('beforeMessageBodyTransformed', (view, text) => { ... });
          */
          */
-        await api.trigger('beforeMessageBodyTransformed', this.model, this.text, {'Synchronous': true});
-
-        let text = this.component.is_me_message ? this.text.substring(4) : this.text;
-        // Collapse multiple line breaks into at most two
-        text = text.replace(/\n\n+/g, '\n\n');
-        text = u.geoUriToHttp(text, _converse.geouri_replacement);
+        await api.trigger('beforeMessageBodyTransformed', this.model, text, {'Synchronous': true});
 
 
-        let list = await Promise.all(u.addHyperlinks(text));
+        addHyperlinks(text);
+        addMapURLs(text);
+        await addEmojis(text);
+        addReferences(text, this.model);
 
 
-        await api.emojis.initialize();
-        list = list.reduce((acc, i) => isString(i) ? [...acc, ...u.addEmoji(i)] : [...acc, i], []);
-
-        const addMentions = text => addMentionsMarkup(text, this.model.get('references'), this.model.collection.chatbox)
-        list = list.reduce((acc, i) => isString(i) ? [...acc, ...addMentions(i)] : [...acc, i], []);
         /**
         /**
          * Synchronous event which provides a hook for transforming a chat message's body text
          * Synchronous event which provides a hook for transforming a chat message's body text
          * after the default transformations have been applied.
          * after the default transformations have been applied.
          * @event _converse#afterMessageBodyTransformed
          * @event _converse#afterMessageBodyTransformed
          * @param { _converse.Message } model - The model representing the message
          * @param { _converse.Message } model - The model representing the message
-         * @param { string } text - The message text
+         * @param { MessageText } text - A {@link MessageText } instance. You
+         * can call {@link MessageText#addTemplateResult} on it in order to
+         * add TemplateResult objects meant to render rich parts of the
+         * message.
          * @example _converse.api.listen.on('afterMessageBodyTransformed', (view, text) => { ... });
          * @example _converse.api.listen.on('afterMessageBodyTransformed', (view, text) => { ... });
          */
          */
         await api.trigger('afterMessageBodyTransformed', this.model, text, {'Synchronous': true});
         await api.trigger('afterMessageBodyTransformed', this.model, text, {'Synchronous': true});
-        return list;
-    }
-
-    async render () {
-        return html`${await this.transform()}`
-    }
-
-    get length () {
-        return this.text.length;
+        return text.marshall();
     }
     }
 
 
-    toString () {
-        return "" + this.text;
-    }
-
-    textOf () {
-        return this.toString();
-    }
-}
-
-const tpl_mention_with_nick = (o) => html`<span class="mention mention--self badge badge-info">${o.mention}</span>`;
-const tpl_mention = (o) => html`<span class="mention">${o.mention}</span>`;
-
-
-function addMentionsMarkup (text, references, chatbox) {
-    if (chatbox.get('message_type') === 'groupchat' && references.length) {
-        let list = [text];
-        const nick = chatbox.get('nick');
-        references
-            .sort((a, b) => b.begin - a.begin)
-            .forEach(ref => {
-                const text = list.shift();
-                const mention = text.slice(ref.begin, ref.end);
-                if (mention === nick) {
-                    list = [
-                        text.slice(0, ref.begin),
-                        tpl_mention_with_nick({mention}),
-                        text.slice(ref.end),
-                        ...list
-                    ];
-                } else {
-                    list = [
-                        text.slice(0, ref.begin),
-                        tpl_mention({mention}),
-                        text.slice(ref.end),
-                        ...list
-                    ];
-                }
-            });
-        return list;
-    } else {
-        return [text];
+    render () {
+        return html`${until(this.transform(), html`${this.text}`)}`;
     }
     }
 }
 }
 
 
 
 
-export const renderBodyText = directive(component => async part => {
+export const renderBodyText = directive(component => part => {
     const model = component.model;
     const model = component.model;
     const renderer = new MessageBodyRenderer(component);
     const renderer = new MessageBodyRenderer(component);
-    part.setValue(await renderer.render());
+    part.setValue(renderer.render());
     part.commit();
     part.commit();
     model.collection?.trigger('rendered', model);
     model.collection?.trigger('rendered', model);
 });
 });

+ 0 - 5
src/utils/html.js

@@ -409,11 +409,6 @@ u.addHyperlinks = function (text) {
     return list;
     return list;
 }
 }
 
 
-u.geoUriToHttp = function(text, geouri_replacement) {
-    const regex = /geo:([\-0-9.]+),([\-0-9.]+)(?:,([\-0-9.]+))?(?:\?(.*))?/g;
-    return text.replace(regex, geouri_replacement);
-};
-
 u.httpToGeoUri = function(text, _converse) {
 u.httpToGeoUri = function(text, _converse) {
     const replacement = 'geo:$1,$2';
     const replacement = 'geo:$1,$2';
     return text.replace(_converse.api.settings.get("geouri_regex"), replacement);
     return text.replace(_converse.api.settings.get("geouri_regex"), replacement);