Selaa lähdekoodia

Render chat messages as web components

- Render chat content as a <converse-chat-content> component
- Create new component for rendering the message body
- Get rid of `showMessage` method
JC Brand 5 vuotta sitten
vanhempi
commit
7651d58470
54 muutettua tiedostoa jossa 1589 lisäystä ja 1440 poistoa
  1. 1 0
      .eslintrc.json
  2. 12 2
      karma.conf.js
  3. 36 4
      sass/_chatbox.scss
  4. 20 12
      sass/_chatrooms.scss
  5. 1 1
      sass/_core.scss
  6. 1 2
      sass/_messages.scss
  7. 26 19
      spec/chatbox.js
  8. 15 12
      spec/emojis.js
  9. 1 1
      spec/hats.js
  10. 10 12
      spec/http-file-upload.js
  11. 14 5
      spec/mam.js
  12. 127 120
      spec/messages.js
  13. 3 7
      spec/mock.js
  14. 75 68
      spec/muc.js
  15. 30 20
      spec/muc_messages.js
  16. 2 2
      spec/notification.js
  17. 5 6
      spec/omemo.js
  18. 4 0
      spec/push.js
  19. 19 27
      spec/retractions.js
  20. 29 17
      spec/spoilers.js
  21. 24 24
      spec/xss.js
  22. 2 2
      src/components/adhoc-commands.js
  23. 41 0
      src/components/chat_content.js
  24. 3 3
      src/components/dropdown.js
  25. 43 0
      src/components/help_messages.js
  26. 29 0
      src/components/message-body.js
  27. 124 0
      src/components/message-history.js
  28. 288 0
      src/components/message.js
  29. 114 279
      src/converse-chatview.js
  30. 7 1
      src/converse-headlines-view.js
  31. 0 386
      src/converse-message-view.js
  32. 48 103
      src/converse-muc-views.js
  33. 0 7
      src/converse-omemo.js
  34. 0 1
      src/converse.js
  35. 62 25
      src/headless/converse-chat.js
  36. 63 43
      src/headless/converse-muc.js
  37. 0 10
      src/headless/utils/core.js
  38. 21 32
      src/headless/utils/stanza.js
  39. 11 0
      src/modals/message-versions.js
  40. 3 2
      src/templates/avatar.js
  41. 3 3
      src/templates/chatbox.js
  42. 2 8
      src/templates/chatroom.js
  43. 2 4
      src/templates/chatroom_head.js
  44. 31 0
      src/templates/directives/avatar.js
  45. 111 0
      src/templates/directives/body.js
  46. 23 0
      src/templates/directives/retraction.js
  47. 0 7
      src/templates/file_progress.html
  48. 16 0
      src/templates/file_progress.js
  49. 0 1
      src/templates/help_message.html
  50. 0 13
      src/templates/info.html
  51. 0 52
      src/templates/message.html
  52. 9 0
      src/templates/new_day.js
  53. 77 97
      src/utils/html.js
  54. 1 0
      webpack.prod.js

+ 1 - 0
.eslintrc.json

@@ -35,6 +35,7 @@
         "lodash/prefer-startswith": "off",
         "lodash/preferred-alias": "off",
         "lodash/matches-prop-shorthand": "off",
+        "lodash/prop-shorthand": "off",
         "accessor-pairs": "error",
         "array-bracket-spacing": "off",
         "array-callback-return": "error",

+ 12 - 2
karma.conf.js

@@ -13,6 +13,12 @@ module.exports = function(config) {
       "dist/converse.js",
       "dist/converse.css",
       { pattern: "dist/webfonts/**/*.*", included: false },
+      { pattern: "dist/\@fortawesome/fontawesome-free/sprites/solid.svg",
+        watched: false,
+        included: false,
+        served: true,
+        nocache: false
+      },
       { pattern: "node_modules/sinon/pkg/sinon.js", type: 'module' },
       { pattern: "spec/mock.js", type: 'module' },
 
@@ -50,9 +56,13 @@ module.exports = function(config) {
       { pattern: "spec/hats.js", type: 'module' },
       { pattern: "spec/http-file-upload.js", type: 'module' },
       { pattern: "spec/emojis.js", type: 'module' },
-      { pattern: "spec/xss.js", type: 'module' },
-
+      { pattern: "spec/xss.js", type: 'module' }
     ],
+
+    proxies: {
+      "/dist/\@fortawesome/fontawesome-free/sprites/solid.svg": "/base/dist/\@fortawesome/fontawesome-free/sprites/solid.svg"
+    },
+
     exclude: ['**/*.sw?'],
 
     // preprocess matching files before serving them to the browser

+ 36 - 4
sass/_chatbox.scss

@@ -219,13 +219,46 @@
             font-size: var(--message-font-size);
             height: 100%;
             line-height: 1.3em;
-            overflow-y: auto;
-            padding: 1em 0 0 0;
-
+            overflow: hidden;
+            padding: 0;
             display: flex;
             flex-direction: column;
             justify-content: space-between;
 
+            converse-chat-content {
+                display: flex;
+                flex-direction: column;
+                height: 100%;
+                justify-content: space-between;
+            }
+
+            converse-chat-message {
+                .spinner {
+                    width: 100%;
+                    overflow-y: hidden;
+                }
+            }
+
+            .chat-content__help {
+                converse-chat-help {
+                    border-top: 1px solid var(--chat-head-color);
+                    display: block;
+                    padding: 0.5em 0;
+                }
+                .close-chat-help {
+                    float: right;
+                    padding-right: 1em;
+                    cursor: pointer;
+                    color: var(--chat-content-background-color);
+                }
+            }
+
+            .chat-content__messages {
+                overflow-x: hidden;
+                overflow-y: auto;
+                height: 100%;
+            }
+
             .chat-content__notifications {
                 height: 1.7em;
                 white-space: pre;
@@ -235,7 +268,6 @@
                 font-style: italic;
                 line-height: var(--line-height-small);
                 padding: 0 1em 0.3em;
-
                 &:before {
                     content: " ";
                 }

+ 20 - 12
sass/_chatrooms.scss

@@ -97,6 +97,16 @@
         }
     }
 
+    .empty-history-feedback {
+        position: relative;
+        span {
+            width: 100%;
+            text-align: center;
+            position: absolute;
+            margin-top: 50%;
+        }
+    }
+
     .chatroom {
         width: var(--chatroom-width);
         @media screen and (max-height: $mobile-landscape-height){
@@ -166,6 +176,16 @@
                     .chat-content {
                         height: 100%;
                     }
+                    .chat-content__help {
+                        converse-chat-help {
+                            border-top: 1px solid var(--chatroom-head-bg-color);
+                        }
+                        .close-chat-help {
+                            svg {
+                                fill: 1px solid var(--chatroom-head-bg-color) !important;
+                            }
+                        }
+                    }
                 }
                 .occupants {
                     display: flex;
@@ -330,18 +350,6 @@
             }
         }
 
-        .empty-history-feedback {
-            position: relative;
-            height: 100%;
-            color: var(--text-color-lighten-15-percent);
-            span {
-                width: 100%;
-                text-align: center;
-                position: absolute;
-                margin-top: 50%;
-            }
-        }
-
         .muc-bottom-panel {
             border-top: var(--message-input-border-top);
             height: 3em;

+ 1 - 1
sass/_core.scss

@@ -340,7 +340,7 @@ body.converse-fullscreen {
     q {
       quotes: "“" "”" "‘" "’";
       &.reason {
-        display: block;
+        display: inline;
       }
     }
     q:before {

+ 1 - 2
sass/_messages.scss

@@ -196,7 +196,7 @@
                 a {
                     word-wrap: break-word;
                     word-break: break-all;
-                    display: inline-block;
+                    display: inline;
                     &.chat-image__link {
                         display: block;
                     }
@@ -222,7 +222,6 @@
 
             .chat-msg__error {
                 color: var(--error-color);
-                font-weight: bold;
             }
 
             .chat-msg__media {

+ 26 - 19
spec/chatbox.js

@@ -5,9 +5,13 @@ const $msg = converse.env.$msg;
 const Strophe = converse.env.Strophe;
 const u = converse.env.utils;
 const sizzle = converse.env.sizzle;
+const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
 
 describe("Chatboxes", function () {
 
+    beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
+    afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
+
     describe("A Chatbox", function () {
 
         it("has a /help command to show the available commands", mock.initConverse(['rosterGroupsFetched', 'chatBoxesFetched'], {}, async function (done, _converse) {
@@ -20,7 +24,8 @@ describe("Chatboxes", function () {
             const view = _converse.chatboxviews.get(contact_jid);
             mock.sendMessage(view, '/help');
 
-            const info_messages = Array.prototype.slice.call(view.el.querySelectorAll('.chat-info:not(.chat-date)'), 0);
+            await u.waitUntil(() => sizzle('.chat-info:not(.chat-date)', view.el).length);
+            const info_messages = await u.waitUntil(() => sizzle('.chat-info:not(.chat-date)', view.el));
             expect(info_messages.length).toBe(4);
             expect(info_messages.pop().textContent).toBe('/help: Show this menu');
             expect(info_messages.pop().textContent).toBe('/me: Write in the third person');
@@ -35,7 +40,8 @@ describe("Chatboxes", function () {
                 }).c('body').t('hello world').tree();
             await _converse.handleMessageStanza(msg);
             await u.waitUntil(() => view.content.querySelectorAll('.chat-msg').length);
-            expect(view.msgs_container.lastElementChild.textContent.trim().indexOf('hello world')).not.toBe(-1);
+            const msg_txt_sel = 'converse-chat-message:last-child .chat-msg__body';
+            await u.waitUntil(() => view.el.querySelector(msg_txt_sel).textContent.trim() === 'hello world');
             done();
         }));
 
@@ -58,30 +64,36 @@ describe("Chatboxes", function () {
 
             await _converse.handleMessageStanza(msg);
             const view = _converse.chatboxviews.get(sender_jid);
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await u.waitUntil(() => view.el.querySelector('.chat-msg__text'));
             expect(view.el.querySelectorAll('.chat-msg--action').length).toBe(1);
-            expect(_.includes(view.el.querySelector('.chat-msg__author').textContent, '**Mercutio')).toBeTruthy();
+            expect(view.el.querySelector('.chat-msg__author').textContent.includes('**Mercutio')).toBeTruthy();
             expect(view.el.querySelector('.chat-msg__text').textContent).toBe('is tired');
+
             message = '/me is as well';
             await mock.sendMessage(view, message);
             expect(view.el.querySelectorAll('.chat-msg--action').length).toBe(2);
             await u.waitUntil(() => sizzle('.chat-msg__author:last', view.el).pop().textContent.trim() === '**Romeo Montague');
             const last_el = sizzle('.chat-msg__text:last', view.el).pop();
-            expect(last_el.textContent).toBe('is as well');
+            await u.waitUntil(() => last_el.textContent === 'is as well');
             expect(u.hasClass('chat-msg--followup', last_el)).toBe(false);
+
             // Check that /me messages after a normal message don't
             // get the 'chat-msg--followup' class.
             message = 'This a normal message';
             await mock.sendMessage(view, message);
-            let message_el = view.el.querySelector('.message:last-child');
-            expect(u.hasClass('chat-msg--followup', message_el)).toBeFalsy();
+            const msg_txt_sel = 'converse-chat-message:last-child .chat-msg__body';
+            await u.waitUntil(() => view.el.querySelector(msg_txt_sel).textContent.trim() === message);
+            let el = view.el.querySelector('converse-chat-message:last-child .chat-msg__body');
+            expect(u.hasClass('chat-msg--followup', el)).toBeFalsy();
+
             message = '/me wrote a 3rd person message';
             await mock.sendMessage(view, message);
-            message_el = view.el.querySelector('.message:last-child');
+            await u.waitUntil(() => view.el.querySelector(msg_txt_sel).textContent.trim() === message.replace('/me ', ''));
+            el = view.el.querySelector('converse-chat-message:last-child .chat-msg__body');
             expect(view.el.querySelectorAll('.chat-msg--action').length).toBe(3);
+
             expect(sizzle('.chat-msg__text:last', view.el).pop().textContent).toBe('wrote a 3rd person message');
             expect(u.isVisible(sizzle('.chat-msg__author:last', view.el).pop())).toBeTruthy();
-            expect(u.hasClass('chat-msg--followup', message_el)).toBeFalsy();
             done();
         }));
 
@@ -451,7 +463,7 @@ describe("Chatboxes", function () {
                     keyCode: 13 // Enter
                 };
                 view.onKeyDown(ev);
-                await new Promise(resolve => view.once('messageInserted', resolve));
+                await new Promise(resolve => view.model.messages.once('rendered', resolve));
                 view.onKeyUp(ev);
                 expect(counter.textContent).toBe('200');
 
@@ -1166,8 +1178,6 @@ describe("Chatboxes", function () {
             expect(document.title).toBe('Converse Tests');
 
             const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            const view = await mock.openChatBoxFor(_converse, sender_jid)
-
             const previous_state = _converse.windowState;
             const message = 'This message will increment the message counter';
             const msg = $msg({
@@ -1184,7 +1194,6 @@ describe("Chatboxes", function () {
             spyOn(_converse, 'clearMsgCounter').and.callThrough();
 
             await _converse.handleMessageStanza(msg);
-            await new Promise(resolve => view.once('messageInserted', resolve));
             expect(_converse.incrementMsgCounter).toHaveBeenCalled();
             expect(_converse.clearMsgCounter).not.toHaveBeenCalled();
             expect(document.title).toBe('Messages (1) Converse Tests');
@@ -1604,9 +1613,8 @@ describe("Chatboxes", function () {
 
             await mock.waitForRoster(_converse, 'current', 1);
 
-            const message = "geo:37.786971,-122.399677",
-                  contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-
+            const message = "geo:37.786971,-122.399677";
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
             await mock.openChatBoxFor(_converse, contact_jid);
             const view = _converse.chatboxviews.get(contact_jid);
             spyOn(view.model, 'sendMessage').and.callThrough();
@@ -1614,10 +1622,9 @@ describe("Chatboxes", function () {
             await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-msg').length, 1000);
             expect(view.model.sendMessage).toHaveBeenCalled();
             const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-            expect(msg.innerHTML).toEqual(
+            expect(msg.innerHTML.replace(/\<!----\>/g, '')).toEqual(
                 '<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.7869'+
-                '71&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();
         }));
     });

+ 15 - 12
spec/emojis.js

@@ -170,9 +170,8 @@ describe("Emojis", function () {
                 .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
             await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
             const view = _converse.api.chatviews.get(sender_jid);
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            let message = view.content.querySelector('.chat-msg__text');
-            expect(u.hasClass('chat-msg__text--larger', message)).toBe(true);
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+            await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.content.querySelector('.chat-msg__text')));
 
             _converse.handleMessageStanza($msg({
                     'from': sender_jid,
@@ -181,9 +180,10 @@ describe("Emojis", function () {
                     'id': _converse.connection.getUniqueId()
                 }).c('body').t('😇 Hello world! 😇 😇').up()
                 .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            message = view.content.querySelector('.message:last-child .chat-msg__text');
-            expect(u.hasClass('chat-msg__text--larger', message)).toBe(false);
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+            let sel = '.message:last-child .chat-msg__text';
+            await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.content.querySelector(sel)));
 
             // Test that a modified message that no longer contains only
             // emojis now renders normally again.
@@ -194,9 +194,11 @@ describe("Emojis", function () {
                 preventDefault: function preventDefault () {},
                 keyCode: 13 // Enter
             });
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
             expect(view.el.querySelectorAll('.chat-msg').length).toBe(3);
-            expect(view.content.querySelector('.message:last-child .chat-msg__text').textContent).toBe('💩 😇');
+            const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
+            await u.waitUntil(() => view.content.querySelector(last_msg_sel).textContent === '💩 😇');
+
             expect(textarea.value).toBe('');
             view.onKeyDown({
                 target: textarea,
@@ -204,7 +206,8 @@ describe("Emojis", function () {
             });
             expect(textarea.value).toBe('💩 😇');
             expect(view.model.messages.at(2).get('correcting')).toBe(true);
-            await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg:last-child')), 500);
+            sel = 'converse-chat-message:last-child .chat-msg'
+            await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector(sel)), 500);
             textarea.value = textarea.value += 'This is no longer an emoji-only message';
             view.onKeyDown({
                 target: textarea,
@@ -213,7 +216,7 @@ describe("Emojis", function () {
             });
             await new Promise(resolve => view.model.messages.once('rendered', resolve));
             expect(view.model.messages.models.length).toBe(3);
-            message = view.content.querySelector('.message:last-child .chat-msg__text');
+            let message = view.content.querySelector(last_msg_sel);
             expect(u.hasClass('chat-msg__text--larger', message)).toBe(false);
 
             textarea.value = ':smile: Hello world!';
@@ -222,7 +225,7 @@ describe("Emojis", function () {
                 preventDefault: function preventDefault () {},
                 keyCode: 13 // Enter
             });
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
             textarea.value = ':smile: :smiley: :imp:';
             view.onKeyDown({
@@ -230,7 +233,7 @@ describe("Emojis", function () {
                 preventDefault: function preventDefault () {},
                 keyCode: 13 // Enter
             });
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
             message = view.content.querySelector('.message:last-child .chat-msg__text');
             expect(u.hasClass('chat-msg__text--larger', message)).toBe(true);

+ 1 - 1
spec/hats.js

@@ -60,7 +60,7 @@ describe("A XEP-0317 MUC Hat", function () {
         await u.waitUntil(() => view.model.getOccupant("Terry").get('hats').length === 3);
         hats = view.model.getOccupant("Terry").get('hats');
         expect(hats.map(h => h.title).join(' ')).toBe("Teacher's Assistant Dark Mage Mad hatter");
-        await u.waitUntil(() => view.el.querySelectorAll('.chat-msg .badge').length === 3);
+        await u.waitUntil(() => view.el.querySelectorAll('.chat-msg .badge').length === 3, 1000);
         badges = Array.from(view.el.querySelectorAll('.chat-msg .badge'));
         expect(badges.map(b => b.textContent.trim()).join(' ' )).toBe("Teacher's Assistant Dark Mage Mad hatter");
 

+ 10 - 12
spec/http-file-upload.js

@@ -247,7 +247,7 @@ describe("XEP-0363: HTTP File Upload", function () {
                         'name': "my-juliet.jpg"
                     };
                     view.model.sendFiles([file]);
-                    await new Promise(resolve => view.once('messageInserted', resolve));
+                    await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
                     await u.waitUntil(() => _.filter(IQ_stanzas, iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length);
                     const iq = IQ_stanzas.pop();
@@ -352,7 +352,7 @@ describe("XEP-0363: HTTP File Upload", function () {
                         'name': "my-juliet.jpg"
                     };
                     view.model.sendFiles([file]);
-                    await new Promise(resolve => view.once('messageInserted', resolve));
+                    await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
                     await u.waitUntil(() => _.filter(IQ_stanzas, iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length);
                     const iq = IQ_stanzas.pop();
@@ -575,7 +575,7 @@ describe("XEP-0363: HTTP File Upload", function () {
                     'name': "my-juliet.jpg"
                 };
                 view.model.sendFiles([file]);
-                await new Promise(resolve => view.once('messageInserted', resolve));
+                await new Promise(resolve => view.model.messages.once('rendered', resolve));
                 await u.waitUntil(() => _.filter(IQ_stanzas, iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length)
                 const iq = IQ_stanzas.pop();
                 expect(Strophe.serialize(iq)).toBe(
@@ -606,18 +606,16 @@ describe("XEP-0363: HTTP File Upload", function () {
                         <get url="${message}" />
                     </slot>
                     </iq>`);
-                spyOn(XMLHttpRequest.prototype, 'send').and.callFake(function () {
+
+                spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async () => {
                     const message = view.model.messages.at(0);
                     expect(view.el.querySelector('.chat-content progress').getAttribute('value')).toBe('0');
                     message.set('progress', 0.5);
-                    u.waitUntil(() => view.el.querySelector('.chat-content progress').getAttribute('value') === '0.5')
-                    .then(() => {
-                        message.set('progress', 1);
-                        u.waitUntil(() => view.el.querySelector('.chat-content progress').getAttribute('value') === '1')
-                    }).then(() => {
-                        expect(view.el.querySelector('.chat-content .chat-msg__text').textContent).toBe('Uploading file: my-juliet.jpg, 22.91 KB');
-                        done();
-                    });
+                    await u.waitUntil(() => view.el.querySelector('.chat-content progress').getAttribute('value') === '0.5');
+                    message.set('progress', 1);
+                    await u.waitUntil(() => view.el.querySelector('.chat-content progress').getAttribute('value') === '1');
+                    expect(view.el.querySelector('.chat-content .chat-msg__text').textContent).toBe('Uploading file: my-juliet.jpg, 22.91 KB');
+                    done();
                 });
                 _converse.connection._dataRecv(mock.createRequest(stanza));
             }));

+ 14 - 5
spec/mam.js

@@ -7,11 +7,15 @@ const $msg = converse.env.$msg;
 const dayjs = converse.env.dayjs;
 const u = converse.env.utils;
 const sizzle = converse.env.sizzle;
+const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
 // See: https://xmpp.org/rfcs/rfc3921.html
 
 // Implements the protocol defined in https://xmpp.org/extensions/xep-0313.html#config
 describe("Message Archive Management", function () {
 
+    beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
+    afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
+
     describe("The XEP-0313 Archive", function () {
 
         it("is queried when the user enters a new MUC",
@@ -194,8 +198,12 @@ describe("Message Archive Management", function () {
                 </iq>`);
             _converse.connection._dataRecv(mock.createRequest(result));
             await u.waitUntil(() => view.model.messages.length === 5);
-            const msg_els = view.content.querySelectorAll('.chat-msg__text');
-            expect(Array.from(msg_els).map(e => e.textContent).join(' ')).toBe("2nd Message 3rd Message 4th Message 5th Message 6th Message");
+            await u.waitUntil(() => view.content.querySelectorAll('.chat-msg__text').length);
+            const msg_els = Array.from(view.content.querySelectorAll('.chat-msg__text'));
+            await u.waitUntil(
+                () => msg_els.map(e => e.textContent).join(' ') === "2nd Message 3rd Message 4th Message 5th Message 6th Message",
+                1000
+            );
             done();
         }));
     });
@@ -253,7 +261,7 @@ describe("Message Archive Management", function () {
                             .c('count').t('16');
                 _converse.connection._dataRecv(mock.createRequest(iq_result));
 
-                await new Promise(resolve => view.once('messageInserted', resolve));
+                await new Promise(resolve => view.model.messages.once('rendered', resolve));
                 expect(view.model.messages.length).toBe(1);
                 expect(view.model.messages.at(0).get('message')).toBe("Thrice the brinded cat hath mew'd.");
                 done();
@@ -1038,9 +1046,8 @@ describe("Chatboxes", function () {
             expect(view.model.messages.at(0).get('type')).toBe('error');
             expect(view.model.messages.at(0).get('message')).toBe('Timeout while trying to fetch archived messages.');
 
-            let err_message = view.el.querySelector('.message.chat-error');
+            let err_message = await u.waitUntil(() => view.el.querySelector('.message.chat-error'));
             err_message.querySelector('.retry').click();
-            expect(err_message.querySelector('.spinner')).not.toBe(null);
 
             while (_converse.connection.IQ_stanzas.length) {
                 _converse.connection.IQ_stanzas.pop();
@@ -1058,6 +1065,8 @@ describe("Chatboxes", function () {
                     `</query>`+
                 `</iq>`);
 
+            await u.waitUntil(() => view.el.querySelector('converse-chat-message .spinner'), 1000);
+
             const msg1 = $msg({'id':'aeb212', 'to': contact_jid})
                         .c('result',  {'xmlns': 'urn:xmpp:mam:2', 'queryid': queryid, 'id':'28482-98726-73623'})
                             .c('forwarded', {'xmlns':'urn:xmpp:forward:0'})

+ 127 - 120
spec/messages.js

@@ -69,7 +69,7 @@ describe("A Chat Message", function () {
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
         });
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        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__text').textContent)
@@ -87,7 +87,6 @@ describe("A Chat Message", function () {
         expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
         expect(view.model.messages.at(0).get('correcting')).toBe(true);
         expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
         await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')));
 
         spyOn(_converse.connection, 'send');
@@ -98,7 +97,6 @@ describe("A Chat Message", function () {
             keyCode: 13 // Enter
         });
         expect(_converse.connection.send).toHaveBeenCalled();
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
         const msg = _converse.connection.send.calls.all()[0].args[0];
         expect(msg.toLocaleString())
@@ -121,14 +119,13 @@ describe("A Chat Message", function () {
         expect(keys.length).toBe(1);
         expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?');
 
+        await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')) === false);
         expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-        expect(u.hasClass('correcting', view.el.querySelector('.chat-msg'))).toBe(false);
 
         // Test that clicking the pencil icon a second time cancels editing.
         action = view.el.querySelector('.chat-msg .chat-msg__action');
         action.style.opacity = 1;
         action.click();
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
         expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
         expect(view.model.messages.at(0).get('correcting')).toBe(true);
@@ -153,7 +150,7 @@ describe("A Chat Message", function () {
             }).c('body').t('Hello').up()
             .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
         );
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
         expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(2);
 
         // Test confirmation dialog
@@ -203,7 +200,7 @@ describe("A Chat Message", function () {
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
         });
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        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__text').textContent)
             .toBe('But soft, what light through yonder airlock breaks?');
@@ -279,7 +276,7 @@ describe("A Chat Message", function () {
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
         });
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
         expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
 
         textarea.value =  'Arise, fair sun, and kill the envious moon';
@@ -288,7 +285,7 @@ describe("A Chat Message", function () {
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
         });
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
         expect(view.el.querySelectorAll('.chat-msg').length).toBe(3);
 
         view.onKeyDown({
@@ -372,7 +369,7 @@ describe("A Chat Message", function () {
             .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2017-12-31T22:08:25Z'})
             .tree();
         _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
         msg = $msg({
                 'xmlns': 'jabber:client',
@@ -384,7 +381,7 @@ describe("A Chat Message", function () {
             .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'})
             .tree();
         _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
         msg = $msg({
                 'xmlns': 'jabber:client',
@@ -396,7 +393,7 @@ describe("A Chat Message", function () {
             .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'})
             .tree();
         _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
         msg = $msg({
                 'xmlns': 'jabber:client',
@@ -408,7 +405,7 @@ describe("A Chat Message", function () {
             .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T12:18:23Z'})
             .tree();
         _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
         msg = $msg({
                 'xmlns': 'jabber:client',
@@ -420,7 +417,7 @@ describe("A Chat Message", function () {
             .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T22:28:23Z'})
             .tree();
         _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
         // Insert <composing> message, to also check that
         // text messages are inserted correctly with
@@ -434,7 +431,8 @@ describe("A Chat Message", function () {
             .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
             .tree();
         _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        const csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
+        expect(csntext.trim()).toEqual('Mercutio is typing');
 
         msg = $msg({
                 'id': _converse.connection.getUniqueId(),
@@ -446,7 +444,7 @@ describe("A Chat Message", function () {
             .c('body').t("latest message")
             .tree();
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
         view.clearSpinner(); //cleanup
         expect(view.content.querySelectorAll('.date-separator').length).toEqual(4);
@@ -473,7 +471,7 @@ describe("A Chat Message", function () {
 
         el = sizzle('.chat-msg:eq(1)', view.content).pop();
         expect(el.querySelector('.chat-msg__text').textContent).toEqual('Inbetween message');
-        expect(el.nextElementSibling.querySelector('.chat-msg__text').textContent).toEqual('another inbetween message');
+        expect(el.parentElement.nextElementSibling.querySelector('.chat-msg__text').textContent).toEqual('another inbetween message');
         el = sizzle('.chat-msg:eq(2)', view.content).pop();
         expect(el.querySelector('.chat-msg__text').textContent)
             .toEqual('another inbetween message');
@@ -492,7 +490,7 @@ describe("A Chat Message", function () {
 
         el = sizzle('.chat-msg:eq(4)', view.content).pop();
         expect(el.querySelector('.chat-msg__text').textContent).toEqual('message');
-        expect(el.nextElementSibling.querySelector('.chat-msg__text').textContent).toEqual('newer message from the next day');
+        expect(el.parentElement.nextElementSibling.querySelector('.chat-msg__text').textContent).toEqual('newer message from the next day');
         expect(u.hasClass('chat-msg--followup', el)).toBe(false);
 
         day = sizzle('.date-separator:last', view.content).pop();
@@ -624,8 +622,8 @@ describe("A Chat Message", function () {
         expect(msg_obj.get('sender')).toEqual('me');
         expect(msg_obj.get('is_delayed')).toEqual(false);
         // Now check that the message appears inside the chatbox in the DOM
-        const msg_txt = view.el.querySelector('.chat-content .chat-msg .chat-msg__text').textContent;
-        expect(msg_txt).toEqual(msgtext);
+        const msg_el = await u.waitUntil(() => view.el.querySelector('.chat-content .chat-msg .chat-msg__text'));
+        expect(msg_el.textContent).toEqual(msgtext);
         done();
     }));
 
@@ -751,7 +749,7 @@ describe("A Chat Message", function () {
 
         await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
         await mock.openChatBoxFor(_converse, contact_jid);
-        await mock.clearChatBoxMessages(_converse, contact_jid);
+
         const one_day_ago = dayjs().subtract(1, 'day');
         const chatbox = _converse.chatboxes.get(contact_jid);
         const view = _converse.chatboxviews.get(contact_jid);
@@ -766,7 +764,7 @@ describe("A Chat Message", function () {
         .c('delay', { xmlns:'urn:xmpp:delay', from: 'montague.lit', stamp: one_day_ago.toISOString() })
         .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
         expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
         expect(chatbox.messages.length).toEqual(1);
@@ -798,7 +796,7 @@ describe("A Chat Message", function () {
         }).c('body').t(message).up()
         .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
         expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
         // Check that there is a <time> element, with the required props.
@@ -823,9 +821,9 @@ describe("A Chat Message", function () {
         const msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.msgs_container).pop().textContent;
         expect(msg_txt).toEqual(message);
 
-        expect(view.msgs_container.querySelector('.chat-msg:last-child .chat-msg__text').textContent).toEqual(message);
-        expect(view.msgs_container.querySelector('.chat-msg:last-child .chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
-        expect(view.msgs_container.querySelector('.chat-msg:last-child .chat-msg__author').textContent.trim()).toBe('Juliet Capulet');
+        expect(view.msgs_container.querySelector('converse-chat-message:last-child .chat-msg__text').textContent).toEqual(message);
+        expect(view.msgs_container.querySelector('converse-chat-message:last-child .chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
+        expect(view.msgs_container.querySelector('converse-chat-message:last-child .chat-msg__author').textContent.trim()).toBe('Juliet Capulet');
         done();
     }));
 
@@ -845,7 +843,7 @@ describe("A Chat Message", function () {
         expect(view.model.sendMessage).toHaveBeenCalled();
         const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
         expect(msg.textContent).toEqual(message);
-        expect(msg.innerHTML).toEqual('&lt;p&gt;This message contains &lt;em&gt;some&lt;/em&gt; &lt;b&gt;markup&lt;/b&gt;&lt;/p&gt;');
+        expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual('&lt;p&gt;This message contains &lt;em&gt;some&lt;/em&gt; &lt;b&gt;markup&lt;/b&gt;&lt;/p&gt;');
         done();
     }));
 
@@ -863,10 +861,10 @@ describe("A Chat Message", function () {
         spyOn(view.model, 'sendMessage').and.callThrough();
         mock.sendMessage(view, message);
         expect(view.model.sendMessage).toHaveBeenCalled();
-        await new Promise(resolve => view.once('messageInserted', 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();
         expect(msg.textContent).toEqual(message);
-        expect(msg.innerHTML)
+        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>');
         done();
     }));
@@ -886,8 +884,8 @@ describe("A Chat Message", function () {
                 <body>Hey\nHave you heard the news?</body>
             </message>`);
         _converse.connection._dataRecv(mock.createRequest(stanza));
-        await new Promise(resolve => view.once('messageInserted', resolve));
-        expect(view.content.querySelector('.chat-msg__text').innerHTML).toBe('Hey<br>Have you heard the news?');
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        expect(view.content.querySelector('.chat-msg__text').innerHTML.replace(/<!---->/g, '')).toBe('Hey\nHave you heard the news?');
         stanza = u.toStanza(`
             <message from="${contact_jid}"
                      type="chat"
@@ -895,8 +893,8 @@ describe("A Chat Message", function () {
                 <body>Hey\n\n\nHave you heard the news?</body>
             </message>`);
         _converse.connection._dataRecv(mock.createRequest(stanza));
-        await new Promise(resolve => view.once('messageInserted', resolve));
-        expect(view.content.querySelector('.message:last-child .chat-msg__text').innerHTML).toBe('Hey<br><br>Have you heard the news?');
+        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?');
         stanza = u.toStanza(`
             <message from="${contact_jid}"
                      type="chat"
@@ -904,8 +902,8 @@ describe("A Chat Message", function () {
                 <body>Hey\nHave you heard\nthe news?</body>
             </message>`);
         _converse.connection._dataRecv(mock.createRequest(stanza));
-        await new Promise(resolve => view.once('messageInserted', resolve));
-        expect(view.content.querySelector('.message:last-child .chat-msg__text').innerHTML).toBe('Hey<br>Have you heard<br>the news?');
+        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\nHave you heard\nthe news?');
         done();
     }));
 
@@ -925,16 +923,20 @@ describe("A Chat Message", function () {
         await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-image').length, 1000)
         expect(view.model.sendMessage).toHaveBeenCalled();
         let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
-        expect(msg.innerHTML.trim()).toEqual(
-            `<a target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg" class="chat-image__link"><img src="${message}" class="chat-image img-thumbnail"></a>`);
+        expect(msg.innerHTML.replace(/<!---->/g, '').trim()).toEqual(
+            `<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
+                `<img class="chat-image img-thumbnail" src="${base_url}/logo/conversejs-filled.svg">`+
+            `</a>`);
+
         message += "?param1=val1&param2=val2";
         mock.sendMessage(view, message);
         await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-image').length === 2, 1000);
         expect(view.model.sendMessage).toHaveBeenCalled();
         msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
-        expect(msg.innerHTML.trim()).toEqual(
-            '<a target="_blank" rel="noopener" href="'+base_url+'/logo/conversejs-filled.svg?param1=val1&amp;param2=val2" class="chat-image__link"><img'+
-            ' src="'+message.replace(/&/g, '&amp;')+'" class="chat-image img-thumbnail"></a>')
+        expect(msg.innerHTML.replace(/<!---->/g, '').trim()).toEqual(
+            `<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg?param1=val1&amp;param2=val2">`+
+                `<img class="chat-image img-thumbnail" src="${message.replace(/&/g, '&amp;')}">`+
+            `</a>`);
 
         // Test now with two images in one message
         message += ' hello world '+base_url+"/logo/conversejs-filled.svg";
@@ -981,7 +983,7 @@ describe("A Chat Message", function () {
 
     it("will be correctly identified and rendered as a followup message",
         mock.initConverse(
-            ['rosterGroupsFetched'], {},
+            ['rosterGroupsFetched'], {'debounced_content_rendering': false},
             async function (done, _converse) {
 
         await mock.waitForRoster(_converse, 'current');
@@ -1006,7 +1008,7 @@ describe("A Chat Message", function () {
             .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
         await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
         const view = _converse.api.chatviews.get(sender_jid);
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
         jasmine.clock().tick(3*ONE_MINUTE_LATER);
         _converse.handleMessageStanza($msg({
@@ -1016,7 +1018,7 @@ describe("A Chat Message", function () {
                 'id': u.getUniqueId()
             }).c('body').t("Another message 3 minutes later").up()
             .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
         jasmine.clock().tick(11*ONE_MINUTE_LATER);
         _converse.handleMessageStanza($msg({
@@ -1026,7 +1028,7 @@ describe("A Chat Message", function () {
                 'id': u.getUniqueId()
             }).c('body').t("Another message 14 minutes since we started").up()
             .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
         jasmine.clock().tick(1*ONE_MINUTE_LATER);
 
@@ -1037,26 +1039,29 @@ describe("A Chat Message", function () {
                 'id': _converse.connection.getUniqueId()
             }).c('body').t("Another message 1 minute and 1 second since the previous one").up()
             .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
         jasmine.clock().tick(1*ONE_MINUTE_LATER);
         await mock.sendMessage(view, "Another message within 10 minutes, but from a different person");
 
         expect(view.content.querySelectorAll('.message').length).toBe(6);
         expect(view.content.querySelectorAll('.chat-msg').length).toBe(5);
-        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(2)'))).toBe(false);
-        expect(view.content.querySelector('.message:nth-child(2) .chat-msg__text').textContent).toBe("A message");
-        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(3)'))).toBe(true);
-        expect(view.content.querySelector('.message:nth-child(3) .chat-msg__text').textContent).toBe(
+
+        const nth_child = (n) => `converse-chat-message:nth-child(${n}) .chat-msg`;
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(2)))).toBe(false);
+        expect(view.content.querySelector(`${nth_child(2)} .chat-msg__text`).textContent).toBe("A message");
+
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(3)))).toBe(true);
+        expect(view.content.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe(
             "Another message 3 minutes later");
-        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(4)'))).toBe(false);
-        expect(view.content.querySelector('.message:nth-child(4) .chat-msg__text').textContent).toBe(
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(4)))).toBe(false);
+        expect(view.content.querySelector(`${nth_child(4)} .chat-msg__text`).textContent).toBe(
             "Another message 14 minutes since we started");
-        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(5)'))).toBe(true);
-        expect(view.content.querySelector('.message:nth-child(5) .chat-msg__text').textContent).toBe(
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(5)))).toBe(true);
+        expect(view.content.querySelector(`${nth_child(5)} .chat-msg__text`).textContent).toBe(
             "Another message 1 minute and 1 second since the previous one");
-        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(6)'))).toBe(false);
-        expect(view.content.querySelector('.message:nth-child(6) .chat-msg__text').textContent).toBe(
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(6)))).toBe(false);
+        expect(view.content.querySelector(`${nth_child(6)} .chat-msg__text`).textContent).toBe(
             "Another message within 10 minutes, but from a different person");
 
         // Let's add a delayed, inbetween message
@@ -1070,26 +1075,32 @@ describe("A Chat Message", function () {
             }).c('body').t("A delayed message, sent 5 minutes since we started").up()
               .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp': dayjs(base_time).add(5, 'minutes').toISOString()})
               .tree());
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
         expect(view.content.querySelectorAll('.message').length).toBe(7);
         expect(view.content.querySelectorAll('.chat-msg').length).toBe(6);
-        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(2)'))).toBe(false);
-        expect(view.content.querySelector('.message:nth-child(2) .chat-msg__text').textContent).toBe("A message");
-        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(3)'))).toBe(true);
-        expect(view.content.querySelector('.message:nth-child(3) .chat-msg__text').textContent).toBe(
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(2)))).toBe(false);
+        expect(view.content.querySelector(`${nth_child(2)} .chat-msg__text`).textContent).toBe("A message");
+
+
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(3)))).toBe(true);
+        expect(view.content.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe(
             "Another message 3 minutes later");
-        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(4)'))).toBe(true);
-        expect(view.content.querySelector('.message:nth-child(4) .chat-msg__text').textContent).toBe(
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(4)))).toBe(true);
+        expect(view.content.querySelector(`${nth_child(4)} .chat-msg__text`).textContent).toBe(
             "A delayed message, sent 5 minutes since we started");
 
-        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(5)'))).toBe(false);
-        expect(view.content.querySelector('.message:nth-child(5) .chat-msg__text').textContent).toBe(
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(5)))).toBe(true);
+        expect(view.content.querySelector(`${nth_child(5)} .chat-msg__text`).textContent).toBe(
             "Another message 14 minutes since we started");
-        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(6)'))).toBe(true);
-        expect(view.content.querySelector('.message:nth-child(6) .chat-msg__text').textContent).toBe(
+
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(6)))).toBe(true);
+        expect(view.content.querySelector(`${nth_child(6)} .chat-msg__text`).textContent).toBe(
             "Another message 1 minute and 1 second since the previous one");
-        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(7)'))).toBe(false);
+
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(7)))).toBe(false);
+        expect(view.content.querySelector(`${nth_child(7)} .chat-msg__text`).textContent).toBe(
+            "Another message within 10 minutes, but from a different person");
 
         _converse.handleMessageStanza(
             $msg({
@@ -1101,29 +1112,28 @@ describe("A Chat Message", function () {
             .c('body').t("A carbon message 4 minutes later").up()
             .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':dayjs(base_time).add(4, 'minutes').toISOString()})
             .tree());
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
-        expect(view.content.querySelectorAll('.message').length).toBe(8);
         expect(view.content.querySelectorAll('.chat-msg').length).toBe(7);
-        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(2)'))).toBe(false);
-        expect(view.content.querySelector('.message:nth-child(2) .chat-msg__text').textContent).toBe("A message");
-        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(3)'))).toBe(true);
-        expect(view.content.querySelector('.message:nth-child(3) .chat-msg__text').textContent).toBe(
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(2)))).toBe(false);
+        expect(view.content.querySelector(`${nth_child(2)} .chat-msg__text`).textContent).toBe("A message");
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(3)))).toBe(true);
+        expect(view.content.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe(
             "Another message 3 minutes later");
-        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(4)'))).toBe(false);
-        expect(view.content.querySelector('.message:nth-child(4) .chat-msg__text').textContent).toBe(
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(4)))).toBe(false);
+        expect(view.content.querySelector(`${nth_child(4)} .chat-msg__text`).textContent).toBe(
             "A carbon message 4 minutes later");
-        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(5)'))).toBe(false);
-        expect(view.content.querySelector('.message:nth-child(5) .chat-msg__text').textContent).toBe(
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(5)))).toBe(false);
+        expect(view.content.querySelector(`${nth_child(5)} .chat-msg__text`).textContent).toBe(
             "A delayed message, sent 5 minutes since we started");
-        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(6)'))).toBe(false);
-        expect(view.content.querySelector('.message:nth-child(6) .chat-msg__text').textContent).toBe(
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(6)))).toBe(true);
+        expect(view.content.querySelector(`${nth_child(6)} .chat-msg__text`).textContent).toBe(
             "Another message 14 minutes since we started");
-        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(7)'))).toBe(true);
-        expect(view.content.querySelector('.message:nth-child(7) .chat-msg__text').textContent).toBe(
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(7)))).toBe(true);
+        expect(view.content.querySelector(`${nth_child(7)} .chat-msg__text`).textContent).toBe(
             "Another message 1 minute and 1 second since the previous one");
-        expect(u.hasClass('chat-msg--followup', view.content.querySelector('.message:nth-child(8)'))).toBe(false);
-        expect(view.content.querySelector('.message:nth-child(8) .chat-msg__text').textContent).toBe(
+        expect(u.hasClass('chat-msg--followup', view.content.querySelector(nth_child(8)))).toBe(false);
+        expect(view.content.querySelector(`${nth_child(8)} .chat-msg__text`).textContent).toBe(
             "Another message within 10 minutes, but from a different person");
 
         jasmine.clock().uninstall();
@@ -1205,7 +1215,7 @@ describe("A Chat Message", function () {
             });
             const chatbox = _converse.chatboxes.get(contact_jid);
             expect(chatbox).toBeDefined();
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
             let msg_obj = chatbox.messages.models[0];
             let msg_id = msg_obj.get('msgid');
             let msg = $msg({
@@ -1214,8 +1224,7 @@ describe("A Chat Message", function () {
                     'id': u.getUniqueId(),
                 }).c('received', {'id': msg_id, xmlns: Strophe.NS.RECEIPTS}).up().tree();
             _converse.connection._dataRecv(mock.createRequest(msg));
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
-            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(1);
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__receipt').length === 1);
 
             // Also handle receipts with type 'chat'. See #1353
             spyOn(_converse, 'handleMessageStanza').and.callThrough();
@@ -1225,7 +1234,7 @@ describe("A Chat Message", function () {
                 preventDefault: function preventDefault () {},
                 keyCode: 13 // Enter
             });
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
             msg_obj = chatbox.messages.models[1];
             msg_id = msg_obj.get('msgid');
@@ -1236,8 +1245,7 @@ describe("A Chat Message", function () {
                     'id': u.getUniqueId(),
                 }).c('received', {'id': msg_id, xmlns: Strophe.NS.RECEIPTS}).up().tree();
             _converse.connection._dataRecv(mock.createRequest(msg));
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
-            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(2);
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__receipt').length === 2);
             expect(_converse.handleMessageStanza.calls.count()).toBe(1);
             done();
         }));
@@ -1377,7 +1385,7 @@ describe("A Chat Message", function () {
                     'type': 'chat',
                     'id': msg_id,
                 }).c('body').t('But soft, what light through yonder airlock breaks?').tree());
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            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__text').textContent)
                 .toBe('But soft, what light through yonder airlock breaks?');
@@ -1411,7 +1419,7 @@ describe("A Chat Message", function () {
             expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
             expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
             view.el.querySelector('.chat-msg__content .fa-edit').click();
-            const modal = view.model.messages.at(0).message_versions_modal;
+            const modal = await u.waitUntil(() => view.el.querySelector('converse-chat-message').message_versions_modal);
             await u.waitUntil(() => u.isVisible(modal.el), 1000);
             const older_msgs = modal.el.querySelectorAll('.older-msg');
             expect(older_msgs.length).toBe(2);
@@ -1456,7 +1464,7 @@ describe("A Chat Message", function () {
 
                 await _converse.handleMessageStanza(msg);
                 const view = await u.waitUntil(() => _converse.api.chatviews.get(sender_jid));
-                await new Promise(resolve => view.once('messageInserted', resolve));
+                await new Promise(resolve => view.model.messages.once('rendered', resolve));
                 expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
 
                 // Check that the chatbox and its view now exist
@@ -1508,7 +1516,7 @@ describe("A Chat Message", function () {
                 _converse.allow_non_roster_messaging = true;
                 await _converse.handleMessageStanza(msg);
                 view = _converse.chatboxviews.get(sender_jid);
-                await new Promise(resolve => view.once('messageInserted', resolve));
+                await new Promise(resolve => view.model.messages.once('rendered', resolve));
                 expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
                 // Check that the chatbox and its view now exist
                 chatbox = await _converse.api.chats.get(sender_jid);
@@ -1563,7 +1571,7 @@ describe("A Chat Message", function () {
                 let msg_text = 'This message will not be sent, due to an error';
                 const view = _converse.api.chatviews.get(sender_jid);
                 const message = await view.model.sendMessage(msg_text);
-                await new Promise(resolve => view.once('messageInserted', resolve));
+                await new Promise(resolve => view.model.messages.once('rendered', resolve));
                 let msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.content).pop().textContent;
                 expect(msg_txt).toEqual(msg_text);
 
@@ -1598,8 +1606,9 @@ describe("A Chat Message", function () {
                     .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
                         .t('Server-to-server connection failed: Connecting failed: connection timeout');
                 _converse.connection._dataRecv(mock.createRequest(stanza));
-                await new Promise(resolve => view.once('messageInserted', resolve));
-                expect(view.content.querySelector('.chat-error').textContent.trim()).toEqual(error_txt);
+                await u.waitUntil(() => view.content.querySelector('.chat-msg__error').textContent.trim() === error_txt);
+
+                const other_error_txt = 'Server-to-server connection failed: Connecting failed: connection timeout';
                 stanza = $msg({
                         'to': _converse.connection.jid,
                         'type': 'error',
@@ -1609,10 +1618,10 @@ describe("A Chat Message", function () {
                     .c('error', {'type': 'cancel'})
                     .c('remote-server-not-found', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
                     .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
-                        .t('Server-to-server connection failed: Connecting failed: connection timeout');
+                        .t(other_error_txt);
                 _converse.connection._dataRecv(mock.createRequest(stanza));
-                await new Promise(resolve => view.once('messageInserted', resolve));
-                expect(view.content.querySelectorAll('.chat-error').length).toEqual(2);
+                await u.waitUntil(() =>
+                    view.content.querySelector('converse-chat-message:last-child .chat-msg__error').textContent.trim() === other_error_txt);
 
                 // We don't render duplicates
                 stanza = $msg({
@@ -1626,13 +1635,11 @@ describe("A Chat Message", function () {
                     .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
                         .t('Server-to-server connection failed: Connecting failed: connection timeout');
                 _converse.connection._dataRecv(mock.createRequest(stanza));
-                expect(view.content.querySelectorAll('.chat-error').length).toEqual(2);
+                expect(view.content.querySelectorAll('.chat-msg__error').length).toEqual(2);
 
                 msg_text = 'This message will be sent, and also receive an error';
                 const third_message = await view.model.sendMessage(msg_text);
-                await new Promise(resolve => view.once('messageInserted', resolve));
-                msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.content).pop().textContent;
-                expect(msg_txt).toEqual(msg_text);
+                await u.waitUntil(() => sizzle('converse-chat-message:last-child .chat-msg__text', view.content).pop()?.textContent === msg_text);
 
                 // A different error message will however render
                 stanza = $msg({
@@ -1647,8 +1654,8 @@ describe("A Chat Message", function () {
                         .t('Something else went wrong as well');
                 _converse.connection._dataRecv(mock.createRequest(stanza));
                 await u.waitUntil(() => view.model.messages.length > 3);
-                await new Promise(resolve => view.once('messageInserted', resolve));
-                expect(view.content.querySelectorAll('.chat-error').length).toEqual(3);
+                await new Promise(resolve => view.model.messages.once('rendered', resolve));
+                expect(view.content.querySelectorAll('.chat-error').length).toEqual(1);
                 done();
             }));
 
@@ -1709,7 +1716,7 @@ describe("A Chat Message", function () {
                         id: _converse.connection.getUniqueId(),
                     }).c('body').t('Message: '+i).up()
                     .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-                promises.push(new Promise(resolve => view.once('messageInserted', resolve)));
+                promises.push(new Promise(resolve => view.model.messages.once('rendered', resolve)));
             }
             await Promise.all(promises);
             // XXX Fails on Travis
@@ -1728,7 +1735,7 @@ describe("A Chat Message", function () {
                     id: u.getUniqueId()
                 }).c('body').t(message).up()
                 .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
             await u.waitUntil(() => view.model.messages.length > 20, 1000);
             // Now check that the message appears inside the chatbox in the DOM
             const  msg_txt = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop().textContent;
@@ -1813,16 +1820,16 @@ describe("A Chat Message", function () {
                     <x xmlns="jabber:x:oob"><url>https://montague.lit/audio.mp3</url></x>
                 </message>`)
             _converse.connection._dataRecv(mock.createRequest(stanza));
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
             await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-msg audio').length, 1000);
             let msg = view.el.querySelector('.chat-msg .chat-msg__text');
             expect(msg.classList.length).toEqual(1);
             expect(u.hasClass('chat-msg__text', msg)).toBe(true);
             expect(msg.textContent).toEqual('Have you heard this funny audio?');
             let media = view.el.querySelector('.chat-msg .chat-msg__media');
-            expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
-                `<!---->    <audio controls="" src="https://montague.lit/audio.mp3"></audio>    `+
-                `<a target="_blank" rel="noopener" href="https://montague.lit/audio.mp3"><!---->Download audio file "audio.mp3"<!----></a><!---->`);
+            expect(media.innerHTML.replace(/<!---->/g, '').replace(/(\r\n|\n|\r)/gm, "").trim()).toEqual(
+                `<audio controls="" src="https://montague.lit/audio.mp3"></audio>    `+
+                `<a target="_blank" rel="noopener" href="https://montague.lit/audio.mp3">Download audio file "audio.mp3"</a>`);
 
             // If the <url> and <body> contents is the same, don't duplicate.
             stanza = u.toStanza(`
@@ -1833,14 +1840,14 @@ describe("A Chat Message", function () {
                     <x xmlns="jabber:x:oob"><url>https://montague.lit/audio.mp3</url></x>
                 </message>`);
             _converse.connection._dataRecv(mock.createRequest(stanza));
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
             msg = view.el.querySelector('.chat-msg:last-child .chat-msg__text');
-            expect(msg.innerHTML).toEqual('<!-- message gets added here via renderMessage -->'); // Emtpy
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual('Have you heard this funny audio?'); // Emtpy
             media = view.el.querySelector('.chat-msg:last-child .chat-msg__media');
-            expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
-                `<!---->    <audio controls="" src="https://montague.lit/audio.mp3"></audio>    `+
+            expect(media.innerHTML.replace(/<!---->/g, '').replace(/(\r\n|\n|\r)/gm, "").trim()).toEqual(
+                `<audio controls="" src="https://montague.lit/audio.mp3"></audio>    `+
                 `<a target="_blank" rel="noopener" href="https://montague.lit/audio.mp3">`+
-                `<!---->Download audio file "audio.mp3"<!----></a><!---->`);
+                `Download audio file "audio.mp3"</a>`);
             done();
         }));
 
@@ -1881,9 +1888,9 @@ describe("A Chat Message", function () {
                     <x xmlns="jabber:x:oob"><url>https://montague.lit/video.mp4</url></x>
                 </message>`);
             _converse.connection._dataRecv(mock.createRequest(stanza));
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
             msg = view.el.querySelector('.chat-msg:last-child .chat-msg__text');
-            expect(msg.innerHTML).toEqual('<!-- message gets added here via renderMessage -->'); // Emtpy
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual('Have you seen this funny video?');
             media = view.el.querySelector('.chat-msg:last-child .chat-msg__media');
             expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
                 `<!----><video controls="" preload="metadata" style="max-height: 50vh" src="https://montague.lit/video.mp4"></video><!---->`);
@@ -1908,7 +1915,7 @@ describe("A Chat Message", function () {
                     <x xmlns="jabber:x:oob"><url>https://montague.lit/funny.pdf</url></x>
                 </message>`);
             _converse.connection._dataRecv(mock.createRequest(stanza));
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
             await u.waitUntil(() => view.el.querySelectorAll('.chat-content .chat-msg a').length, 1000);
             const msg = view.el.querySelector('.chat-msg .chat-msg__text');
             expect(u.hasClass('chat-msg__text', msg)).toBe(true);
@@ -2048,7 +2055,7 @@ describe("A XEP-0333 Chat Marker", function () {
                 <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.once('messageInserted', resolve));
+        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);
 

+ 3 - 7
spec/mock.js

@@ -4,6 +4,8 @@ let _converse, initConverse;
 
 const converseLoaded = new Promise(resolve => window.addEventListener('converse-loaded', resolve));
 
+jasmine.DEFAULT_TIMEOUT_INTERVAL = 5000;
+
 mock.initConverse = function (promise_names=[], settings=null, func) {
     if (typeof promise_names === "function") {
         func = promise_names;
@@ -337,12 +339,6 @@ window.addEventListener('converse-loaded', () => {
         await view.model.messages.fetched;
     };
 
-    mock.clearChatBoxMessages = function (converse, jid) {
-        const view = converse.chatboxviews.get(jid);
-        view.msgs_container.innerHTML = '';
-        return view.model.messages.clearStore();
-    };
-
     mock.createContact = async function (_converse, name, ask, requesting, subscription) {
         const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
         if (_converse.roster.get(jid)) {
@@ -449,7 +445,7 @@ window.addEventListener('converse-loaded', () => {
     }
 
     mock.sendMessage = function (view, message) {
-        const promise = new Promise(resolve => view.once('messageInserted', resolve));
+        const promise = new Promise(resolve => view.model.messages.once('rendered', resolve));
         view.el.querySelector('.chat-textarea').value = message;
         view.onKeyDown({
             target: view.el.querySelector('textarea.chat-textarea'),

+ 75 - 68
spec/muc.js

@@ -1,14 +1,14 @@
 /*global mock */
 
-const _ = converse.env._,
-      $pres = converse.env.$pres,
-      $iq = converse.env.$iq,
-      $msg = converse.env.$msg,
-      Model = converse.env.Model,
-      Strophe = converse.env.Strophe,
-      Promise = converse.env.Promise,
-      sizzle = converse.env.sizzle,
-      u = converse.env.utils;
+const _ = converse.env._;
+const $pres = converse.env.$pres;
+const $iq = converse.env.$iq;
+const $msg = converse.env.$msg;
+const Model = converse.env.Model;
+const Strophe = converse.env.Strophe;
+const Promise = converse.env.Promise;
+const sizzle = converse.env.sizzle;
+const u = converse.env.utils;
 
 describe("Groupchats", function () {
 
@@ -527,7 +527,7 @@ describe("Groupchats", function () {
                         <body>This is a message</body>
                     </message>`);
                 _converse.connection._dataRecv(mock.createRequest(stanza));
-                await new Promise(resolve => view.once('messageInserted', resolve));
+                await new Promise(resolve => view.model.messages.once('rendered', resolve));
                 expect(sizzle('.chat-msg__subject', view.el).length).toBe(1);
                 expect(sizzle('.chat-msg__subject', view.el).pop().textContent.trim()).toBe('This is a message subject');
                 expect(sizzle('.chat-msg__text').length).toBe(1);
@@ -562,7 +562,7 @@ describe("Groupchats", function () {
                         <body>This is a message</body>
                     </message>`);
                 _converse.connection._dataRecv(mock.createRequest(stanza));
-                await new Promise(resolve => view.once('messageInserted', resolve));
+                await new Promise(resolve => view.model.messages.once('rendered', resolve));
                 expect(sizzle('.chat-msg__subject', view.el).length).toBe(1);
                 expect(sizzle('.chat-msg__subject', view.el).pop().textContent.trim()).toBe('This is a message subject');
                 expect(sizzle('.chat-msg__text').length).toBe(1);
@@ -645,8 +645,7 @@ describe("Groupchats", function () {
                     </message>`)));
                 await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 2);
 
-                let el = sizzle('.chat-info__message', view.el).pop();
-                expect(el.textContent.trim()).toBe('Topic set by ralphm');
+                await u.waitUntil(() => sizzle('.chat-info__message', view.el).pop()?.textContent.trim() === 'Topic set by ralphm');
                 await u.waitUntil(() => desc.textContent.trim()  === 'This is a new topic');
 
                 // Doesn't show multiple subsequent topic change notifications
@@ -666,7 +665,7 @@ describe("Groupchats", function () {
                 await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 4);
                 await u.waitUntil(() => desc.textContent.trim()  === "Some1's topic");
                 expect(sizzle('.chat-info__message', view.el).length).toBe(2);
-                el = sizzle('.chat-info__message', view.el).pop();
+                const el = sizzle('.chat-info__message', view.el).pop();
                 expect(el.textContent.trim()).toBe('Topic set by some1');
 
                 // Removes current topic
@@ -676,8 +675,8 @@ describe("Groupchats", function () {
                     </message>`);
                 _converse.connection._dataRecv(mock.createRequest(stanza));
                 await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 5);
-                await u.waitUntil(() => view.el.querySelector('.chat-head__desc') === null);
-                expect(view.el.querySelector('.chat-info:last-child').textContent.trim()).toBe("Topic cleared by some1");
+                await u.waitUntil(() => view.el.querySelector('.chat-head__desc').textContent.replace(/<!---->/g, '') === '');
+                await u.waitUntil(() => view.el.querySelector('converse-chat-message:last-child .chat-info').textContent.trim() === "Topic cleared by some1");
                 done();
             }));
         });
@@ -701,12 +700,11 @@ describe("Groupchats", function () {
                 }).c('body').t(message).tree();
 
             await view.model.handleMessageStanza(msg);
-
             spyOn(view.model, 'clearMessages').and.callThrough();
             await view.model.close();
             await u.waitUntil(() => view.model.clearMessages.calls.count());
             expect(view.model.messages.length).toBe(0);
-            expect(view.msgs_container.innerHTML).toBe('');
+            expect(view.el.querySelector('converse-chat-history')).toBe(null);
             done()
         }));
 
@@ -861,7 +859,7 @@ describe("Groupchats", function () {
                 'type': 'groupchat'
             }).c('body').t('hello world').tree();
             _converse.connection._dataRecv(mock.createRequest(msg));
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
             // Add another entrant, otherwise the above message will be
             // collapsed if "newguy" leaves immediately again
@@ -1082,7 +1080,6 @@ describe("Groupchats", function () {
                 </presence>`);
             _converse.connection._dataRecv(mock.createRequest(presence));
             await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === "romeo, fabio and Dele Olajide have entered the groupchat");
-
             presence = u.toStanza(
                 `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/jcbrand">
                     <x xmlns="http://jabber.org/protocol/muc#user">
@@ -1158,10 +1155,6 @@ describe("Groupchats", function () {
             await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() ===
                 "romeo, jcbrand and others have entered the groupchat\nfuvuv has left the groupchat");
 
-            // XXX: hack so that we can test leave/enter of occupants
-            // who were already in the room when we joined.
-            view.msgs_container.innerHTML = '';
-
             presence = u.toStanza(
                 `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/fabio">
                     <status>Disconnected: closed</status>
@@ -2042,7 +2035,7 @@ describe("Groupchats", function () {
                 preventDefault: function preventDefault () {},
                 keyCode: 13
             });
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
             expect(_converse.api.trigger).toHaveBeenCalledWith('messageSend', jasmine.any(_converse.Message));
             expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
@@ -2102,7 +2095,7 @@ describe("Groupchats", function () {
                         type: 'groupchat',
                         id: u.getUniqueId(),
                     }).c('body').t(message).tree());
-                await new Promise(resolve => view.once('messageInserted', resolve));
+                await new Promise(resolve => view.model.messages.once('rendered', resolve));
                 // Now check that the message appears inside the chatbox in the DOM
                 const msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.content).pop().textContent;
                 expect(msg_txt).toEqual(message);
@@ -2898,8 +2891,10 @@ describe("Groupchats", function () {
             textarea.value = '/help';
             view.onKeyDown(enter);
 
-            let info_messages = sizzle('.chat-info:not(.chat-event)', view.el);
-            expect(info_messages.length).toBe(20);
+            await u.waitUntil(() => sizzle('converse-chat-help .chat-info', view.el).length);
+            const chat_help_el = view.el.querySelector('converse-chat-help');
+            let info_messages = sizzle('.chat-info', chat_help_el);
+            expect(info_messages.length).toBe(19);
             expect(info_messages.pop().textContent.trim()).toBe('/voice: Allow muted user to post messages');
             expect(info_messages.pop().textContent.trim()).toBe('/topic: Set groupchat subject (alias for /subject)');
             expect(info_messages.pop().textContent.trim()).toBe('/subject: Set groupchat subject');
@@ -2919,47 +2914,49 @@ describe("Groupchats", function () {
             expect(info_messages.pop().textContent.trim()).toBe('/clear: Clear the chat area');
             expect(info_messages.pop().textContent.trim()).toBe('/ban: Ban user by changing their affiliation to outcast');
             expect(info_messages.pop().textContent.trim()).toBe('/admin: Change user\'s affiliation to admin');
-            expect(info_messages.pop().textContent.trim()).toBe('You can run the following commands');
 
             const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});
             occupant.set('affiliation', 'admin');
-            textarea = view.el.querySelector('.chat-textarea');
-            textarea.value = '/clear';
-            view.onKeyDown(enter);
-            await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length === 0);
+
+            view.el.querySelector('.close-chat-help').click();
+            await u.waitUntil(() => chat_help_el.hidden);
 
             textarea.value = '/help';
             view.onKeyDown(enter);
-            info_messages = sizzle('.chat-info:not(.chat-event)', view.el);
-            expect(info_messages.length).toBe(19);
+            await u.waitUntil(() => !chat_help_el.hidden);
+            info_messages = sizzle('.chat-info', chat_help_el);
+            expect(info_messages.length).toBe(18);
             let commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
             expect(commands).toEqual([
-                "You can run the following commands",
                 "/admin", "/ban", "/clear", "/deop", "/destroy",
                 "/help", "/kick", "/me", "/member", "/modtools", "/mute", "/nick",
                 "/op", "/register", "/revoke", "/subject", "/topic", "/voice"
             ]);
             occupant.set('affiliation', 'member');
-            textarea.value = '/clear';
-            view.onKeyDown(enter);
-            await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length === 0);
+            view.el.querySelector('.close-chat-help').click();
+            await u.waitUntil(() => chat_help_el.hidden);
 
             textarea.value = '/help';
             view.onKeyDown(enter);
-            info_messages = sizzle('.chat-info', view.el).slice(1);
+            await u.waitUntil(() => !chat_help_el.hidden);
+            info_messages = sizzle('.chat-info', chat_help_el);
             expect(info_messages.length).toBe(9);
             commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
             expect(commands).toEqual(["/clear", "/help", "/kick", "/me", "/modtools", "/mute", "/nick", "/register", "/voice"]);
 
+            view.el.querySelector('.close-chat-help').click();
+            await u.waitUntil(() => chat_help_el.hidden);
+            expect(view.model.get('show_help_messages')).toBe(false);
+
             occupant.set('role', 'participant');
+            // Role changes causes rerender, so we need to get the new textarea
             textarea = view.el.querySelector('.chat-textarea');
-            textarea.value = '/clear';
-            view.onKeyDown(enter);
-            await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length === 0);
 
             textarea.value = '/help';
             view.onKeyDown(enter);
-            info_messages = sizzle('.chat-info', view.el).slice(1);
+            await u.waitUntil(() => view.model.get('show_help_messages'));
+            await u.waitUntil(() => !chat_help_el.hidden);
+            info_messages = sizzle('.chat-info', chat_help_el);
             expect(info_messages.length).toBe(5);
             commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
             expect(commands).toEqual(["/clear", "/help", "/me", "/nick", "/register"]);
@@ -2967,13 +2964,13 @@ describe("Groupchats", function () {
             // Test that /topic is available if all users may change the subject
             // Note: we're making a shortcut here, this value should never be set manually
             view.model.config.set('changesubject', true);
-            textarea.value = '/clear';
-            view.onKeyDown(enter);
-            await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length === 0);
+            view.el.querySelector('.close-chat-help').click();
+            await u.waitUntil(() => chat_help_el.hidden);
 
             textarea.value = '/help';
             view.onKeyDown(enter);
-            info_messages = sizzle('.chat-info', view.el).slice(1);
+            await u.waitUntil(() => !chat_help_el.hidden, 1000);
+            info_messages = sizzle('.chat-info', chat_help_el);
             expect(info_messages.length).toBe(7);
             commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
             expect(commands).toEqual(["/clear", "/help", "/me", "/nick", "/register", "/subject", "/topic"]);
@@ -2995,8 +2992,9 @@ describe("Groupchats", function () {
             textarea.value = '/help';
             view.onKeyDown(enter);
 
+            await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length);
             const info_messages = sizzle('.chat-info:not(.chat-event)', view.el);
-            expect(info_messages.length).toBe(18);
+            expect(info_messages.length).toBe(17);
             expect(info_messages.pop().textContent.trim()).toBe('/topic: Set groupchat subject (alias for /subject)');
             expect(info_messages.pop().textContent.trim()).toBe('/subject: Set groupchat subject');
             expect(info_messages.pop().textContent.trim()).toBe('/revoke: Revoke the user\'s current affiliation');
@@ -3014,7 +3012,6 @@ describe("Groupchats", function () {
             expect(info_messages.pop().textContent.trim()).toBe('/clear: Clear the chat area');
             expect(info_messages.pop().textContent.trim()).toBe('/ban: Ban user by changing their affiliation to outcast');
             expect(info_messages.pop().textContent.trim()).toBe('/admin: Change user\'s affiliation to admin');
-            expect(info_messages.pop().textContent.trim()).toBe('You can run the following commands');
             done();
         }));
 
@@ -3432,7 +3429,7 @@ describe("Groupchats", function () {
 
             textarea.value = '/ban joe22';
             view.onFormSubmitted(new Event('submit'));
-            await u.waitUntil(() => view.el.querySelector('.message:last-child')?.textContent?.trim() ===
+            await u.waitUntil(() => view.el.querySelector('converse-chat-message:last-child')?.textContent?.trim() ===
                 "Error: couldn't find a groupchat participant based on your arguments");
             done();
         }));
@@ -3520,6 +3517,7 @@ describe("Groupchats", function () {
                     }).c('actor', {'nick': 'romeo'}).up()
                       .c('reason').t("You're annoying").up().up()
                     .c('status', {'code': '307'});
+
             _converse.connection._dataRecv(mock.createRequest(presence));
 
             await u.waitUntil(() => view.el.querySelectorAll('.chat-info').length === 2);
@@ -4996,11 +4994,13 @@ describe("Groupchats", function () {
 
                 // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
 
-                const timeout_functions = [];
-                spyOn(window, 'setTimeout').and.callFake(f => {
+                const remove_notifications_timeouts = [];
+                const setTimeout = window.setTimeout;
+                spyOn(window, 'setTimeout').and.callFake((f, w) => {
                     if (f.toString() === "() => this.removeNotification(actor, state)") {
-                        timeout_functions.push(f)
+                        remove_notifications_timeouts.push(f)
                     }
+                    setTimeout(f, w);
                 });
 
                 // <composing> state
@@ -5014,7 +5014,7 @@ describe("Groupchats", function () {
 
                 csntext = await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent);
                 expect(csntext.trim()).toEqual('newguy is typing');
-                expect(timeout_functions.length).toBe(1);
+                expect(remove_notifications_timeouts.length).toBe(1);
 
                 expect(view.el.querySelector('.chat-content__notifications').textContent.trim()).toEqual('newguy is typing');
 
@@ -5048,7 +5048,6 @@ describe("Groupchats", function () {
                 await view.model.handleMessageStanza(msg);
                 await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === 'newguy, nomorenicks and others are typing');
 
-                // Check that new messages appear under the chat state notifications
                 msg = $msg({
                     from: `${muc_jid}/some1`,
                     id: u.getUniqueId(),
@@ -5056,7 +5055,7 @@ describe("Groupchats", function () {
                     type: 'groupchat'
                 }).c('body').t('hello world').tree();
                 await view.model.handleMessageStanza(msg);
-                await new Promise(resolve => view.once('messageInserted', resolve));
+                await new Promise(resolve => view.model.messages.once('rendered', resolve), 1000);
 
                 const messages = view.el.querySelectorAll('.message');
                 expect(messages.length).toBe(2);
@@ -5064,7 +5063,7 @@ describe("Groupchats", function () {
                 expect(view.el.querySelector('.chat-msg .chat-msg__text').textContent.trim()).toBe('hello world');
 
                 // Test that the composing notifications get removed via timeout.
-                timeout_functions[0]();
+                remove_notifications_timeouts[0]();
                 await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.trim() === 'nomorenicks, majortom and groundcontrol are typing');
                 done();
             }));
@@ -5186,34 +5185,42 @@ describe("Groupchats", function () {
             const textarea = view.el.querySelector('.chat-textarea');
             textarea.value = 'Hello world';
             view.onFormSubmitted(new Event('submit'));
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
             let stanza = u.toStanza(`
-                <message xmlns="jabber:client" type="error" to="troll@montague.lit/resource" from="trollbox@montague.lit">
+                <message id="${view.model.messages.at(0).get('msgid')}"
+                         xmlns="jabber:client"
+                         type="error"
+                         to="troll@montague.lit/resource"
+                         from="trollbox@montague.lit">
                     <error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error>
                 </message>`);
             _converse.connection._dataRecv(mock.createRequest(stanza));
-            await new Promise(resolve => view.once('messageInserted', resolve));
-            expect(view.el.querySelector('.chat-error').textContent.trim()).toBe(
+            await u.waitUntil(() => view.el.querySelector('.chat-msg__error')?.textContent.trim(), 1000);
+            expect(view.el.querySelector('.chat-msg__error').textContent.trim()).toBe(
                 "Your message was not delivered because you weren't allowed to send it.");
 
             textarea.value = 'Hello again';
             view.onFormSubmitted(new Event('submit'));
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__text').length === 2);
 
             stanza = u.toStanza(`
-                <message xmlns="jabber:client" type="error" to="troll@montague.lit/resource" from="trollbox@montague.lit">
+                <message id="${view.model.messages.at(1).get('msgid')}"
+                         xmlns="jabber:client"
+                         type="error"
+                         to="troll@montague.lit/resource"
+                         from="trollbox@montague.lit">
                     <error type="auth">
                         <forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
                         <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">Thou shalt not!</text>
                     </error>
                 </message>`);
             _converse.connection._dataRecv(mock.createRequest(stanza));
-            await new Promise(resolve => view.once('messageInserted', resolve));
 
-            expect(view.el.querySelector('.message:last-child').textContent.trim()).toBe(
-                'Your message was not delivered because you weren\'t allowed to send it. '+
-                'The message from the server is: "Thou shalt not!"')
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__error').length === 2);
+            const sel = 'converse-message-history converse-chat-message:last-child .chat-msg__error';
+            await u.waitUntil(() => view.el.querySelector(sel)?.textContent.trim());
+            expect(view.el.querySelector(sel).textContent.trim()).toBe('Thou shalt not!')
             done();
         }));
 

+ 30 - 20
spec/muc_messages.js

@@ -2,9 +2,12 @@
 
 const { Promise, Strophe, $msg, $pres, sizzle, stanza_utils } = converse.env;
 const u = converse.env.utils;
+const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
 
 describe("A Groupchat Message", function () {
 
+    beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
+    afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
 
     describe("which is succeeded by an error message", function () {
 
@@ -25,7 +28,7 @@ describe("A Groupchat Message", function () {
                 'keyCode': 13 // Enter
             }
             view.onKeyDown(enter_event);
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
             const msg = view.model.messages.at(0);
             const err_msg_text = "Message rejected because you're sending messages too quickly";
@@ -44,7 +47,7 @@ describe("A Groupchat Message", function () {
             const message = view.model.messages.at(0);
             expect(message.get('received')).toBeUndefined();
             expect(message.get('body')).toBe('hello world');
-            expect(message.get('error')).toBe(err_msg_text);
+            expect(message.get('error_text')).toBe(err_msg_text);
             done();
         }));
     });
@@ -180,7 +183,7 @@ describe("A Groupchat Message", function () {
               .c('active', {'xmlns': "http://jabber.org/protocol/chatstates"})
               .tree();
         await view.model.handleMessageStanza(msg);
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
         expect(view.el.querySelector('.chat-msg')).not.toBe(null);
         done();
     }));
@@ -203,7 +206,7 @@ describe("A Groupchat Message", function () {
                 type: 'groupchat'
             }).c('body').t(message).tree();
         await view.model.handleMessageStanza(msg);
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
         expect(u.hasClass('mentioned', view.el.querySelector('.chat-msg'))).toBeTruthy();
         done();
     }));
@@ -435,7 +438,7 @@ describe("A Groupchat Message", function () {
             type: 'groupchat'
         }).c('body').t('Another message!').tree();
         await view.model.handleMessageStanza(msg);
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
         expect(view.model.messages.last().occupant.get('affiliation')).toBe('member');
         expect(view.model.messages.last().occupant.get('role')).toBe('participant');
         expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
@@ -472,7 +475,7 @@ describe("A Groupchat Message", function () {
             type: 'groupchat'
         }).c('body').t('Message from someone not in the MUC right now').tree();
         await view.model.handleMessageStanza(msg);
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
         expect(view.model.messages.last().occupant).toBeUndefined();
         // Check that there's a new "add" event handler, for when the occupant appears.
         expect(view.model.occupants._events.add.length).toBe(add_events+1);
@@ -583,7 +586,7 @@ describe("A Groupchat Message", function () {
         await u.waitUntil(() => view.el.querySelector('.chat-msg__text').textContent ===
             'But soft, what light through yonder chimney breaks?', 500);
         expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-        expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
+        await u.waitUntil(() => view.el.querySelector('.chat-msg__content .fa-edit'));
 
         await view.model.handleMessageStanza($msg({
                 'from': 'lounge@montague.lit/newguy',
@@ -597,8 +600,9 @@ describe("A Groupchat Message", function () {
             'But soft, what light through yonder window breaks?', 500);
         expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
         expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
-        view.el.querySelector('.chat-msg__content .fa-edit').click();
-        const modal = view.model.messages.at(0).message_versions_modal;
+        const edit = await u.waitUntil(() => view.el.querySelector('.chat-msg__content .fa-edit'));
+        edit.click();
+        const modal = await u.waitUntil(() => view.el.querySelector('converse-chat-message').message_versions_modal);
         await u.waitUntil(() => u.isVisible(modal.el), 1000);
         const older_msgs = modal.el.querySelectorAll('.older-msg');
         expect(older_msgs.length).toBe(2);
@@ -641,11 +645,10 @@ describe("A Groupchat Message", function () {
             target: textarea,
             keyCode: 38 // Up arrow
         });
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
         expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
         expect(view.model.messages.at(0).get('correcting')).toBe(true);
         expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-        expect(u.hasClass('correcting', view.el.querySelector('.chat-msg'))).toBe(true);
+        await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')));
 
         spyOn(_converse.connection, 'send');
         textarea.value = 'But soft, what light through yonder window breaks?';
@@ -688,7 +691,7 @@ describe("A Groupchat Message", function () {
             'to': 'romeo@montague.lit',
             'type': 'groupchat'
         }).c('body').t('Hello world').tree());
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
         expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
 
         // Test that pressing the down arrow cancels message correction
@@ -729,7 +732,7 @@ describe("A Groupchat Message", function () {
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
         });
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
         expect(view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length).toBe(0);
 
         const msg_obj = view.model.messages.at(0);
@@ -807,7 +810,7 @@ describe("A Groupchat Message", function () {
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
         });
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
         expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
 
         const msg_obj = view.model.messages.at(0);
@@ -841,7 +844,7 @@ describe("A Groupchat Message", function () {
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
         });
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        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?");
@@ -929,7 +932,7 @@ describe("A Groupchat Message", function () {
             await view.model.handleMessageStanza(msg);
             const message = await u.waitUntil(() => view.el.querySelector('.chat-msg__text'));
             expect(message.classList.length).toEqual(1);
-            expect(message.innerHTML).toBe(
+            expect(message.innerHTML.replace(/<!---->/g, '')).toBe(
                 'hello <span class="mention">z3r0</span> '+
                 '<span class="mention mention--self badge badge-info">tom</span> '+
                 '<span class="mention">mr.robot</span>, how are you?');
@@ -970,7 +973,7 @@ describe("A Groupchat Message", function () {
             await view.model.handleMessageStanza(msg);
             const message = await u.waitUntil(() => view.el.querySelector('.chat-msg__text'));
             expect(message.classList.length).toEqual(1);
-            expect(message.innerHTML).toBe(
+            expect(message.innerHTML.replace(/<!---->/g, '')).toBe(
                 '&gt;hello <span class="mention">z3r0</span> '+
                 '<span class="mention mention--self badge badge-info">tom</span> '+
                 '<span class="mention">mr.robot</span>, how are you?');
@@ -1144,7 +1147,7 @@ describe("A Groupchat Message", function () {
             }
             spyOn(_converse.connection, 'send');
             view.onKeyDown(enter_event);
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
             const msg = _converse.connection.send.calls.all()[0].args[0];
             expect(msg.toLocaleString())
                 .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
@@ -1191,7 +1194,14 @@ describe("A Groupchat Message", function () {
             }
             spyOn(_converse.connection, 'send');
             view.onKeyDown(enter_event);
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+            const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
+            await u.waitUntil(() =>
+                view.content.querySelector(last_msg_sel).innerHTML.replace(/<!---->/g, '') ===
+                    'hello <span class="mention">z3r0</span> <span class="mention">gibson</span> <span class="mention">mr.robot</span>, how are you?'
+            );
+
             const msg = _converse.connection.send.calls.all()[0].args[0];
             expect(msg.toLocaleString())
                 .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
@@ -1269,7 +1279,7 @@ describe("A Groupchat Message", function () {
                 'keyCode': 13 // Enter
             }
             view.onKeyDown(enter_event);
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
             const msg = _converse.connection.send.calls.all()[0].args[0];
             expect(msg.toLocaleString())

+ 2 - 2
spec/notification.js

@@ -65,7 +65,7 @@ describe("Notifications", function () {
                             type: 'groupchat'
                         }).c('body').t(message).tree();
                     _converse.connection._dataRecv(mock.createRequest(msg));
-                    await new Promise(resolve => view.once('messageInserted', resolve));
+                    await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
                     await u.waitUntil(() => _converse.areDesktopNotificationsEnabled.calls.count() === 1);
                     expect(_converse.showMessageNotification).toHaveBeenCalled();
@@ -94,7 +94,7 @@ describe("Notifications", function () {
                     _converse.connection._dataRecv(mock.createRequest(stanza));
                     await u.waitUntil(() => _converse.chatboxviews.keys().length);
                     const view = _converse.chatboxviews.get('notify.example.com');
-                    await new Promise(resolve => view.once('messageInserted', resolve));
+                    await new Promise(resolve => view.model.messages.once('rendered', resolve));
                     expect(_converse.chatboxviews.keys().includes('notify.example.com')).toBeTruthy();
                     expect(_converse.showMessageNotification).toHaveBeenCalled();
                     done();

+ 5 - 6
spec/omemo.js

@@ -199,7 +199,7 @@ describe("The OMEMO module", function() {
                         .up().up()
                     .c('payload').t(obj.payload);
         _converse.connection._dataRecv(mock.createRequest(stanza));
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
         expect(view.model.messages.length).toBe(2);
         expect(view.el.querySelectorAll('.chat-msg__body')[1].textContent.trim())
             .toBe('This is an encrypted message from the contact');
@@ -218,7 +218,7 @@ describe("The OMEMO module", function() {
                     .up().up()
                 .c('payload').t(obj.payload);
         _converse.connection._dataRecv(mock.createRequest(stanza));
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
         await u.waitUntil(() => view.model.messages.length > 1);
         expect(view.model.messages.length).toBe(3);
         expect(view.el.querySelectorAll('.chat-msg__body')[2].textContent.trim())
@@ -435,7 +435,7 @@ describe("The OMEMO module", function() {
             </message>
         `);
         _converse.connection._dataRecv(mock.createRequest(carbon));
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
         expect(view.model.messages.length).toBe(1);
         expect(view.el.querySelector('.chat-msg__body').textContent.trim())
             .toBe('This is an encrypted carbon message from another device of mine');
@@ -1258,7 +1258,7 @@ describe("The OMEMO module", function() {
 
     it("adds a toolbar button for starting an encrypted groupchat session",
         mock.initConverse(
-            ['rosterGroupsFetched', 'chatBoxesFetched'], {'view_mode': 'fullscreen'},
+            ['rosterGroupsFetched', 'chatBoxesFetched'], {},
             async function (done, _converse) {
 
         await mock.waitUntilDiscoConfirmed(
@@ -1416,8 +1416,7 @@ describe("The OMEMO module", function() {
         _converse.connection._dataRecv(mock.createRequest(stanza));
 
         await u.waitUntil(() => !view.model.get('omemo_supported'));
-
-        expect(view.el.querySelector('.chat-error').textContent.trim()).toBe(
+        await u.waitUntil(() => view.el.querySelector('.chat-error .chat-info__message')?.textContent.trim() ===
             "oldguy doesn't appear to have a client that supports OMEMO. "+
             "Encrypted chat will no longer be possible in this grouchat."
         );

+ 4 - 0
spec/push.js

@@ -5,9 +5,13 @@ const Strophe = converse.env.Strophe;
 const _ = converse.env._;
 const sizzle = converse.env.sizzle;
 const u = converse.env.utils;
+const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
 
 describe("XEP-0357 Push Notifications", function () {
 
+    beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
+    afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
+
     it("can be enabled",
         mock.initConverse(
             ['rosterGroupsFetched'], {

+ 19 - 27
spec/retractions.js

@@ -180,6 +180,7 @@ describe("Message Retractions", function () {
             _converse.connection._dataRecv(mock.createRequest(received_stanza));
             await u.waitUntil(() => view.model.handleModeration.calls.count() === 2);
 
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length);
             expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
             expect(view.model.messages.length).toBe(1);
 
@@ -221,10 +222,8 @@ describe("Message Retractions", function () {
                 </message>
             `);
 
-            const promise = new Promise(resolve => _converse.api.listen.on('messageAdded', resolve));
             _converse.connection._dataRecv(mock.createRequest(retraction_stanza));
             await u.waitUntil(() => view.model.messages.length === 1);
-            await promise;
             const message = view.model.messages.at(0);
             expect(message.get('dangling_retraction')).toBe(true);
             expect(message.get('is_ephemeral')).toBe(false);
@@ -628,8 +627,8 @@ describe("Message Retractions", function () {
                     `</apply-to>`+
                 `</message>`);
 
+            await u.waitUntil(() => view.model.messages.last().get('retracted'));
             const message = view.model.messages.last();
-            expect(message.get('retracted')).toBeTruthy();
             expect(message.get('is_ephemeral')).toBe(false);
             expect(message.get('editable')).toBeFalsy();
 
@@ -648,7 +647,7 @@ describe("Message Retractions", function () {
             _converse.connection._dataRecv(mock.createRequest(reflection));
             await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1);
 
-            expect(view.model.messages.length).toBe(1);
+            await u.waitUntil(() => view.model.messages.length === 1);
             expect(view.model.messages.last().get('retracted')).toBeTruthy();
             expect(view.model.messages.last().get('is_ephemeral')).toBe(false);
             expect(view.model.messages.last().get('editable')).toBe(false);
@@ -675,7 +674,7 @@ describe("Message Retractions", function () {
             await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
 
             expect(view.model.messages.length).toBe(1);
-            expect(view.model.messages.last().get('retracted')).toBeTruthy();
+            await u.waitUntil(() => view.model.messages.last().get('retracted'));
             const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
             expect(el.textContent.trim()).toBe('romeo has removed this message');
 
@@ -695,20 +694,15 @@ describe("Message Retractions", function () {
                 </message>`);
 
             _converse.connection._dataRecv(mock.createRequest(error));
-            await u.waitUntil(() => view.el.querySelectorAll('.chat-error').length === 1);
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__error').length === 1);
             await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 0);
-            expect(view.model.messages.length).toBe(2);
+            expect(view.model.messages.length).toBe(1);
             expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
             expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
             expect(view.model.messages.at(0).get('editable')).toBeTruthy();
 
-            const err_msg = "Sorry, something went wrong while trying to retract your message."
-            expect(view.model.messages.at(1).get('message')).toBe(err_msg);
-            expect(view.model.messages.at(1).get('type')).toBe('error');
-
-            expect(view.el.querySelectorAll('.chat-error').length).toBe(1);
-            const errmsg = view.el.querySelector('.chat-error');
-            expect(errmsg.textContent.trim()).toBe("Sorry, something went wrong while trying to retract your message.");
+            const errmsg = view.el.querySelector('.chat-msg__error');
+            expect(errmsg.textContent.trim()).toBe("You're not allowed to retract your message.");
             done();
         }));
 
@@ -728,25 +722,23 @@ describe("Message Retractions", function () {
             occupant.save('role', 'member');
             await u.waitUntil(() => view.el.querySelector('.chat-content__notifications').textContent.includes("romeo is no longer a moderator"))
             await sendAndThenRetractMessage(_converse, view);
-            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
-
             expect(view.model.messages.length).toBe(1);
             expect(view.model.messages.last().get('retracted')).toBeTruthy();
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 1);
             const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
             expect(el.textContent.trim()).toBe('romeo has removed this message');
 
             await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 1);
 
             await u.waitUntil(() => view.el.querySelectorAll('.chat-msg--retracted').length === 0);
-            expect(view.model.messages.length).toBe(3);
+            expect(view.model.messages.length).toBe(1);
             expect(view.model.messages.at(0).get('retracted')).toBeFalsy();
             expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy();
             expect(view.model.messages.at(0).get('editable')).toBeTruthy();
 
-            const error_messages = view.el.querySelectorAll('.chat-error');
-            expect(error_messages.length).toBe(2);
-            expect(error_messages[0].textContent.trim()).toBe("Sorry, something went wrong while trying to retract your message.");
-            expect(error_messages[1].textContent.trim()).toBe("Timeout Error: No response from server");
+            const error_messages = view.el.querySelectorAll('.chat-msg__error');
+            expect(error_messages.length).toBe(1);
+            expect(error_messages[0].textContent.trim()).toBe('A timeout happened while while trying to retract your message.');
             done();
         }));
 
@@ -1009,7 +1001,6 @@ describe("Message Retractions", function () {
                 </message>
             `);
             spyOn(view.model, 'handleRetraction').and.callThrough();
-            const promise = new Promise(resolve => _converse.api.listen.once('messageAdded', resolve));
             _converse.connection._dataRecv(mock.createRequest(tombstone));
 
             const last_id = u.getUniqueId();
@@ -1037,8 +1028,7 @@ describe("Message Retractions", function () {
                         .c('count').t('2');
             _converse.connection._dataRecv(mock.createRequest(iq_result));
 
-            await promise;
-            expect(view.model.messages.length).toBe(1);
+            await u.waitUntil(() => view.model.messages.length === 1);
             let message = view.model.messages.at(0);
             expect(message.get('retracted')).toBeTruthy();
             expect(message.get('is_tombstone')).toBe(true);
@@ -1050,6 +1040,7 @@ describe("Message Retractions", function () {
             message = view.model.messages.at(0);
             expect(message.get('retracted')).toBeTruthy();
             expect(message.get('is_tombstone')).toBe(true);
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length);
             expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
             expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);
             const el = view.el.querySelector('.chat-msg--retracted .chat-msg__message div');
@@ -1088,7 +1079,6 @@ describe("Message Retractions", function () {
                 </message>
             `);
             spyOn(view.model, 'handleModeration').and.callThrough();
-            const promise = new Promise(resolve => _converse.api.listen.once('messageAdded', resolve));
             _converse.connection._dataRecv(mock.createRequest(tombstone));
 
             const last_id = u.getUniqueId();
@@ -1119,10 +1109,10 @@ describe("Message Retractions", function () {
                         .c('count').t('2');
             _converse.connection._dataRecv(mock.createRequest(iq_result));
 
-            await promise;
+            await u.waitUntil(() => view.model.messages.length);
             expect(view.model.messages.length).toBe(1);
             let message = view.model.messages.at(0);
-            expect(message.get('retracted')).toBeTruthy();
+            await u.waitUntil(() => message.get('retracted'));
             expect(message.get('is_tombstone')).toBe(true);
 
             await u.waitUntil(() => view.model.handleModeration.calls.count() === 2);
@@ -1134,6 +1124,8 @@ describe("Message Retractions", function () {
             expect(message.get('retracted')).toBeTruthy();
             expect(message.get('is_tombstone')).toBe(true);
             expect(message.get('moderation_reason')).toBe("This message contains inappropriate content");
+
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length, 500);
             expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
 
             expect(view.el.querySelectorAll('.chat-msg--retracted').length).toBe(1);

+ 29 - 17
spec/spoilers.js

@@ -1,7 +1,12 @@
 /* global mock */
 
+const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
+
 describe("A spoiler message", function () {
 
+    beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
+    afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
+
     it("can be received with a hint",
         mock.initConverse(
             ['rosterGroupsFetched', 'chatBoxesFetched'], {},
@@ -32,11 +37,11 @@ describe("A spoiler message", function () {
         _converse.connection._dataRecv(mock.createRequest(msg));
         await new Promise(resolve => _converse.api.listen.once('chatBoxViewInitialized', resolve));
         const view = _converse.chatboxviews.get(sender_jid);
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
         await u.waitUntil(() => view.model.vcard.get('fullname') === 'Mercutio')
         expect(view.el.querySelector('.chat-msg__author').textContent.trim()).toBe('Mercutio');
         const message_content = view.el.querySelector('.chat-msg__text');
-        expect(message_content.textContent).toBe(spoiler);
+        await u.waitUntil(() => message_content.textContent === spoiler);
         const spoiler_hint_el = view.el.querySelector('.spoiler-hint');
         expect(spoiler_hint_el.textContent).toBe(spoiler_hint);
         done();
@@ -72,9 +77,10 @@ describe("A spoiler message", function () {
         await new Promise(resolve => view.model.messages.once('rendered', resolve));
         await u.waitUntil(() => u.isVisible(view.el));
         await u.waitUntil(() => view.model.vcard.get('fullname') === 'Mercutio')
+        await u.waitUntil(() => u.isVisible(view.el.querySelector('.chat-msg__author')));
         expect(view.el.querySelector('.chat-msg__author').textContent.includes('Mercutio')).toBeTruthy();
         const message_content = view.el.querySelector('.chat-msg__text');
-        expect(message_content.textContent).toBe(spoiler);
+        await u.waitUntil(() => message_content.textContent === spoiler);
         const spoiler_hint_el = view.el.querySelector('.spoiler-hint');
         expect(spoiler_hint_el.textContent).toBe('');
         done();
@@ -117,7 +123,7 @@ describe("A spoiler message", function () {
             preventDefault: function preventDefault () {},
             keyCode: 13
         });
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
         /* Test the XML stanza
             *
@@ -136,23 +142,26 @@ describe("A spoiler message", function () {
         expect(spoiler_el === null).toBeFalsy();
         expect(spoiler_el.textContent).toBe('');
 
+        const spoiler = 'This is the spoiler';
         const body_el = stanza.querySelector('body');
-        expect(body_el.textContent).toBe('This is the spoiler');
+        expect(body_el.textContent).toBe(spoiler);
 
         /* Test the HTML spoiler message */
         expect(view.el.querySelector('.chat-msg__author').textContent.trim()).toBe('Romeo Montague');
 
+        const message_content = view.el.querySelector('.chat-msg__text');
+        await u.waitUntil(() => message_content.textContent === spoiler);
+
         const spoiler_msg_el = view.el.querySelector('.chat-msg__text.spoiler');
-        expect(spoiler_msg_el.textContent).toBe('This is the spoiler');
         expect(Array.from(spoiler_msg_el.classList).includes('collapsed')).toBeTruthy();
 
         spoiler_toggle = view.el.querySelector('.spoiler-toggle');
-        expect(spoiler_toggle.textContent).toBe('Show more');
+        expect(spoiler_toggle.textContent.trim()).toBe('Show more');
         spoiler_toggle.click();
-        expect(Array.from(spoiler_msg_el.classList).includes('collapsed')).toBeFalsy();
-        expect(spoiler_toggle.textContent).toBe('Show less');
+        await u.waitUntil(() => !Array.from(spoiler_msg_el.classList).includes('collapsed'));
+        expect(spoiler_toggle.textContent.trim()).toBe('Show less');
         spoiler_toggle.click();
-        expect(Array.from(spoiler_msg_el.classList).includes('collapsed')).toBeTruthy();
+        await u.waitUntil(() => Array.from(spoiler_msg_el.classList).includes('collapsed'));
         done();
     }));
 
@@ -197,7 +206,7 @@ describe("A spoiler message", function () {
             preventDefault: function preventDefault () {},
             keyCode: 13
         });
-        await new Promise(resolve => view.once('messageInserted', resolve));
+        await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
         /* Test the XML stanza
             *
@@ -217,23 +226,26 @@ describe("A spoiler message", function () {
         expect(spoiler_el === null).toBeFalsy();
         expect(spoiler_el.textContent).toBe('This is the hint');
 
+        const spoiler = 'This is the spoiler'
         const body_el = stanza.querySelector('body');
-        expect(body_el.textContent).toBe('This is the spoiler');
+        expect(body_el.textContent).toBe(spoiler);
 
         /* Test the HTML spoiler message */
         expect(view.el.querySelector('.chat-msg__author').textContent.trim()).toBe('Romeo Montague');
 
+        const message_content = view.el.querySelector('.chat-msg__text');
+        await u.waitUntil(() => message_content.textContent === spoiler);
+
         const spoiler_msg_el = view.el.querySelector('.chat-msg__text.spoiler');
-        expect(spoiler_msg_el.textContent).toBe('This is the spoiler');
         expect(Array.from(spoiler_msg_el.classList).includes('collapsed')).toBeTruthy();
 
         spoiler_toggle = view.el.querySelector('.spoiler-toggle');
-        expect(spoiler_toggle.textContent).toBe('Show more');
+        expect(spoiler_toggle.textContent.trim()).toBe('Show more');
         spoiler_toggle.click();
-        expect(Array.from(spoiler_msg_el.classList).includes('collapsed')).toBeFalsy();
-        expect(spoiler_toggle.textContent).toBe('Show less');
+        await u.waitUntil(() => !Array.from(spoiler_msg_el.classList).includes('collapsed'));
+        expect(spoiler_toggle.textContent.trim()).toBe('Show less');
         spoiler_toggle.click();
-        expect(Array.from(spoiler_msg_el.classList).includes('collapsed')).toBeTruthy();
+        await u.waitUntil(() => Array.from(spoiler_msg_el.classList).includes('collapsed'));
         done();
     }));
 });

+ 24 - 24
spec/xss.js

@@ -24,44 +24,44 @@ describe("XSS", function () {
             await mock.sendMessage(view, message);
             let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).toEqual("&lt;img src=x onerror=alert('XSS');&gt;");
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&lt;img src=x onerror=alert('XSS');&gt;");
             expect(window.alert).not.toHaveBeenCalled();
 
             message = "<img src=x onerror=alert('XSS')//";
             await mock.sendMessage(view, message);
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).toEqual("&lt;img src=x onerror=alert('XSS')//");
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&lt;img src=x onerror=alert('XSS')//");
 
             message = "<img src=x onerror=alert(String.fromCharCode(88,83,83));>";
             await mock.sendMessage(view, message);
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).toEqual("&lt;img src=x onerror=alert(String.fromCharCode(88,83,83));&gt;");
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&lt;img src=x onerror=alert(String.fromCharCode(88,83,83));&gt;");
 
             message = "<img src=x oneonerrorrror=alert(String.fromCharCode(88,83,83));>";
             await mock.sendMessage(view, message);
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).toEqual("&lt;img src=x oneonerrorrror=alert(String.fromCharCode(88,83,83));&gt;");
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&lt;img src=x oneonerrorrror=alert(String.fromCharCode(88,83,83));&gt;");
 
             message = "<img src=x:alert(alt) onerror=eval(src) alt=xss>";
             await mock.sendMessage(view, message);
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).toEqual("&lt;img src=x:alert(alt) onerror=eval(src) alt=xss&gt;");
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&lt;img src=x:alert(alt) onerror=eval(src) alt=xss&gt;");
 
             message = "><img src=x onerror=alert('XSS');>";
             await mock.sendMessage(view, message);
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).toEqual("&gt;&lt;img src=x onerror=alert('XSS');&gt;");
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&gt;&lt;img src=x onerror=alert('XSS');&gt;");
 
             message = "><img src=x onerror=alert(String.fromCharCode(88,83,83));>";
             await mock.sendMessage(view, message);
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).toEqual("&gt;&lt;img src=x onerror=alert(String.fromCharCode(88,83,83));&gt;");
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&gt;&lt;img src=x onerror=alert(String.fromCharCode(88,83,83));&gt;");
 
             expect(window.alert).not.toHaveBeenCalled();
             done();
@@ -84,43 +84,43 @@ describe("XSS", function () {
             await mock.sendMessage(view, message);
             let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).toEqual('&lt;svgonload=alert(1)&gt;');
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual('&lt;svgonload=alert(1)&gt;');
 
             message = "<svg/onload=alert('XSS')>";
             await mock.sendMessage(view, message);
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).toEqual("&lt;svg/onload=alert('XSS')&gt;");
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&lt;svg/onload=alert('XSS')&gt;");
 
             message = "<svg onload=alert(1)//";
             await mock.sendMessage(view, message);
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).toEqual("&lt;svg onload=alert(1)//");
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&lt;svg onload=alert(1)//");
 
             message = "<svg/onload=alert(String.fromCharCode(88,83,83))>";
             await mock.sendMessage(view, message);
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).toEqual("&lt;svg/onload=alert(String.fromCharCode(88,83,83))&gt;");
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&lt;svg/onload=alert(String.fromCharCode(88,83,83))&gt;");
 
             message = "<svg id=alert(1) onload=eval(id)>";
             await mock.sendMessage(view, message);
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).toEqual("&lt;svg id=alert(1) onload=eval(id)&gt;");
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual("&lt;svg id=alert(1) onload=eval(id)&gt;");
 
             message = '"><svg/onload=alert(String.fromCharCode(88,83,83))>';
             await mock.sendMessage(view, message);
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).toEqual('"&gt;&lt;svg/onload=alert(String.fromCharCode(88,83,83))&gt;');
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual('"&gt;&lt;svg/onload=alert(String.fromCharCode(88,83,83))&gt;');
 
             message = '"><svg/onload=alert(/XSS/)';
             await mock.sendMessage(view, message);
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).toEqual('"&gt;&lt;svg/onload=alert(/XSS/)');
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual('"&gt;&lt;svg/onload=alert(/XSS/)');
 
             expect(window.alert).not.toHaveBeenCalled();
             done();
@@ -143,7 +143,7 @@ describe("XSS", function () {
 
             let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML)
+            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>');
 
             message = 'http://www.opkode.com/"onmouseover="alert(1)"whatever';
@@ -151,21 +151,21 @@ describe("XSS", function () {
 
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).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>');
+            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>');
 
             message = "https://en.wikipedia.org/wiki/Ender's_Game";
             await mock.sendMessage(view, message);
 
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).toEqual('<a target="_blank" rel="noopener" href="https://en.wikipedia.org/wiki/Ender%27s_Game">'+message+'</a>');
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual('<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>";
             await mock.sendMessage(view, message);
 
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).toEqual(
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual(
                 `&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>';
@@ -173,7 +173,7 @@ describe("XSS", function () {
 
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).toEqual(
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual(
                 '&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`
@@ -181,7 +181,7 @@ describe("XSS", function () {
 
             msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
             expect(msg.textContent).toEqual(message);
-            expect(msg.innerHTML).toEqual(
+            expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual(
                 `<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();
         }));
@@ -226,19 +226,19 @@ describe("XSS", function () {
             function checkNonParsedURL (url) {
                 const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
                 expect(msg.textContent).toEqual(url);
-                expect(msg.innerHTML).toEqual(url);
+                expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual(url);
             }
 
             function checkParsedURL ({ entered, href }) {
                 const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
                 expect(msg.textContent).toEqual(entered);
-                expect(msg.innerHTML).toEqual(`<a target="_blank" rel="noopener" href="${href}">${entered}</a>`);
+                expect(msg.innerHTML.replace(/<!---->/g, '')).toEqual(`<a target="_blank" rel="noopener" href="${href}">${entered}</a>`);
             }
 
             function checkParsedXMPPURL ({ entered, href }) {
                 const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-                expect(msg.textContent).toEqual(entered);
-                expect(msg.innerHTML).toEqual(`<a target="_blank" rel="noopener" class="open-chatroom" href="${href}">${entered}</a>`);
+                expect(msg.textContent.trim()).toEqual(entered);
+                expect(msg.innerHTML.replace(/<!---->/g, '').trim()).toEqual(`<a target="_blank" rel="noopener" href="${href}">${entered}</a>`);
             }
 
             await mock.sendMessage(view, bad_urls[0]);

+ 2 - 2
src/components/adhoc-commands.js

@@ -1,11 +1,11 @@
 import "./autocomplete.js"
+import log from "@converse/headless/log";
+import sizzle from "sizzle";
 import { CustomElement } from './element.js';
 import { __ } from '@converse/headless/i18n';
 import { api, converse } from "@converse/headless/converse-core";
 import { html } from "lit-html";
 import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
-import log from "@converse/headless/log";
-import sizzle from "sizzle";
 
 const { Strophe, $iq } = converse.env;
 const u = converse.env.utils;

+ 41 - 0
src/components/chat_content.js

@@ -0,0 +1,41 @@
+import "../components/message-history";
+import xss from "xss/dist/xss";
+import { CustomElement } from './element.js';
+import { html } from 'lit-element';
+import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
+
+
+class ChatContent extends CustomElement {
+
+    static get properties () {
+        return {
+            chatview: { type: Object},
+            messages: { type: Array},
+            notifications: { type: String }
+        }
+    }
+
+    render () {
+        const notifications = xss.filterXSS(this.notifications, {'whiteList': {}});
+        return html`
+            <converse-message-history
+                .chatview=${this.chatview}
+                .messages=${this.messages}>
+            </converse-message-history>
+            <div class="chat-content__notifications">${unsafeHTML(notifications)}</div>
+        `;
+    }
+
+    scrollDown () {
+        if (!this.chatview.model.get('scrolled')) {
+            this.parentElement.scrollTop = this.parentElement.scrollHeight;
+        }
+        this.parentElement.scrollTop = this.parentElement.scrollHeight;
+    }
+
+    updated () {
+        this.scrollDown();
+    }
+}
+
+customElements.define('converse-chat-content', ChatContent);

+ 3 - 3
src/components/dropdown.js

@@ -1,8 +1,8 @@
-import { html } from 'lit-element';
-import { CustomElement } from './element.js';
-import { until } from 'lit-html/directives/until.js';
 import DOMNavigator from "../dom-navigator";
+import { CustomElement } from './element.js';
 import { converse } from "@converse/headless/converse-core";
+import { html } from 'lit-element';
+import { until } from 'lit-html/directives/until.js';
 
 const u = converse.env.utils;
 

+ 43 - 0
src/components/help_messages.js

@@ -0,0 +1,43 @@
+import 'fa-icons';
+import xss from "xss/dist/xss";
+import { CustomElement } from './element.js';
+import { _converse, converse } from "@converse/headless/converse-core";
+import { html } from 'lit-element';
+import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
+
+const u = converse.env.utils;
+
+
+class ChatHelp extends CustomElement {
+
+    static get properties () {
+        return {
+            chat_type: { type: String },
+            messages: { type: Array },
+            model: { type: Object },
+            type: { type: String }
+        }
+    }
+
+    render () {
+        const icon_color = this.chat_type === _converse.CHATROOMS_TYPE ? 'var(--chatroom-head-bg-color)' : 'var(--chat-head-color)';
+        const isodate = (new Date()).toISOString();
+        return [
+            html`<fa-icon class="fas fa-times close-chat-help" @click=${this.close} path-prefix="dist" color="${icon_color}" size="1em"></fa-icon>`,
+            ...this.messages.map(m => this.renderHelpMessage({
+                isodate,
+                'markup': xss.filterXSS(m, {'whiteList': {'strong': []}})
+            }))
+        ];
+    }
+
+    close () {
+        this.model.set({'show_help_messages': false});
+    }
+
+    renderHelpMessage (o) {
+        return html`<div class="message chat-${this.type}" data-isodate="${o.isodate}">${unsafeHTML(o.markup)}</div>`;
+    }
+}
+
+customElements.define('converse-chat-help', ChatHelp);

+ 29 - 0
src/components/message-body.js

@@ -0,0 +1,29 @@
+import { CustomElement } from './element.js';
+import { renderBodyText } from './../templates/directives/body';
+import { html } from 'lit-element';
+
+
+class MessageBody extends CustomElement {
+
+    static get properties () {
+        return {
+            is_only_emojis: { type: Boolean },
+            is_spoiler: { type: Boolean },
+            is_spoiler_visible: { type: Boolean },
+            is_me_message: { type: Boolean },
+            model: { type: Object },
+            text: { type: String },
+        }
+    }
+
+    render () {
+        const spoiler_classes = this.is_spoiler ? `spoiler ${this.is_spoiler_visible ? '' : 'collapsed'}` : '';
+        return html`
+            <div class="chat-msg__text ${this.is_only_emojis ? 'chat-msg__text--larger' : ''} ${spoiler_classes}"
+                >${renderBodyText(this)}</div>
+        `;
+    }
+
+}
+
+customElements.define('converse-chat-message-body', MessageBody);

+ 124 - 0
src/components/message-history.js

@@ -0,0 +1,124 @@
+import "../components/message";
+import dayjs from 'dayjs';
+import tpl_new_day from "../templates//new_day.js";
+import { CustomElement } from './element.js';
+import { __ } from '@converse/headless/i18n';
+import { api } from "@converse/headless/converse-core";
+import { html } from 'lit-element';
+import { repeat } from 'lit-html/directives/repeat.js';
+
+const i18n_no_history = __('No message history available.');
+
+const tpl_message = (o) => html`
+    <converse-chat-message
+        .chatview=${o.chatview}
+        .hats=${o.hats}
+        .model=${o.model}
+        ?allow_retry=${o.retry}
+        ?correcting=${o.correcting}
+        ?editable=${o.editable}
+        ?has_mentions=${o.has_mentions}
+        ?is_delayed=${o.is_delayed}
+        ?is_encrypted=${o.is_encrypted}
+        ?is_me_message=${o.is_me_message}
+        ?is_only_emojis=${o.is_only_emojis}
+        ?is_retracted=${o.is_retracted}
+        ?is_spoiler=${o.is_spoiler}
+        ?is_spoiler_visible=${o.is_spoiler_visible}
+        ?retractable=${o.retractable}
+        edited=${o.edited || ''}
+        error=${o.error || ''}
+        error_text=${o.error_text || ''}
+        filename=${o.filename || ''}
+        filesize=${o.filesize || ''}
+        from=${o.from}
+        message_type=${o.type || ''}
+        moderated_by=${o.moderated_by || ''}
+        moderation_reason=${o.moderation_reason || ''}
+        msgid=${o.msgid}
+        occupant_affiliation=${o.model.occupant ? o.model.occupant.get('affiliation') : ''}
+        occupant_role=${o.model.occupant ? o.model.occupant.get('role') : ''}
+        oob_url=${o.oob_url || ''}
+        pretty_type=${o.pretty_type}
+        progress=${o.progress || 0 }
+        reason=${o.reason || ''}
+        received=${o.received || ''}
+        sender=${o.sender}
+        spoiler_hint=${o.spoiler_hint || ''}
+        subject=${o.subject || ''}
+        time=${o.time}
+        username=${o.username}></converse-chat-message>
+`;
+
+
+// Return a TemplateResult indicating a new day if the passed in message is
+// more than a day later than its predecessor.
+function getDayIndicator (model) {
+    const models = model.collection.models;
+    const idx = models.indexOf(model);
+    const prev_model =  models[idx-1];
+    if (!prev_model || dayjs(model.get('time')).isAfter(dayjs(prev_model.get('time')), 'day')) {
+        const day_date = dayjs(model.get('time')).startOf('day');
+        return tpl_new_day({
+            'type': 'date',
+            'time': day_date.toISOString(),
+            'datestring': day_date.format("dddd MMM Do YYYY")
+        });
+    }
+}
+
+
+class MessageHistory extends CustomElement {
+
+    static get properties () {
+        return {
+            chatview: { type: Object},
+            messages: { type: Array}
+        }
+    }
+
+    render () {
+        const msgs = this.messages;
+        return msgs.length ?
+            html`${repeat(msgs, m => m.get('id'), m => this.renderMessage(m)) }` :
+            html`<div class="empty-history-feedback form-help"><span>${i18n_no_history}</span></div>`;
+    }
+
+    renderMessage (model) {
+        // XXX: leaky abstraction "is_only_key" from converse-omemo
+        if (model.get('dangling_retraction') || model.get('is_only_key')) {
+            return '';
+        }
+        const day = getDayIndicator(model);
+        const templates = day ? [day] : [];
+        const is_retracted = model.get('retracted') || model.get('moderated') === 'retracted';
+        const is_groupchat = model.get('type') === 'groupchat';
+
+        let hats = [];
+        if (is_groupchat) {
+            if (api.settings.get('muc_hats_from_vcard')) {
+                const role = model.vcard ? model.vcard.get('role') : null;
+                hats = role ? role.split(',') : [];
+            } else {
+                hats = model.occupant?.get('hats') || [];
+            }
+        }
+
+        const chatbox = this.chatview.model;
+        const has_mentions = is_groupchat && model.get('sender') === 'them' && chatbox.isUserMentioned(model);
+        const message = tpl_message(
+            Object.assign(model.toJSON(), {
+                'chatview': this.chatview,
+                'is_me_message': model.isMeCommand(),
+                'occupant': model.occupant,
+                'username': model.getDisplayName(),
+                has_mentions,
+                hats,
+                is_retracted,
+                model,
+            }));
+        return [...templates, message];
+    }
+}
+
+customElements.define('converse-message-history', MessageHistory);

+ 288 - 0
src/components/message.js

@@ -0,0 +1,288 @@
+import "./message-body.js";
+import MessageVersionsModal from '../modals/message-versions.js';
+import dayjs from 'dayjs';
+import filesize from "filesize";
+import tpl_spinner from '../templates/spinner.js';
+import { CustomElement } from './element.js';
+import { __ } from '@converse/headless/i18n';
+import { _converse, api, converse } from  "@converse/headless/converse-core";
+import { html } from 'lit-element';
+import { renderAvatar } from './../templates/directives/avatar';
+import { renderRetractionLink } from './../templates/directives/retraction';
+
+const { Strophe } = converse.env;
+const u = converse.env.utils;
+
+const i18n_edit_message = __('Edit this message');
+const i18n_edited = __('This message has been edited');
+const i18n_show = __('Show more');
+const i18n_show_less = __('Show less');
+const i18n_uploading = __('Uploading file:')
+
+
+class Message extends CustomElement {
+
+    static get properties () {
+        return {
+            allow_retry: { type: Boolean },
+            chatview: { type: Object},
+            correcting: { type: Boolean },
+            editable: { type: Boolean },
+            error: { type: String },
+            error_text: { type: String },
+            first_unread: { type: Boolean },
+            from: { type: String },
+            has_mentions: { type: Boolean },
+            hats: { type: Array },
+            is_delayed: { type: Boolean },
+            is_encrypted: { type: Boolean },
+            is_me_message: { type: Boolean },
+            is_only_emojis: { type: Boolean },
+            is_retracted: { type: Boolean },
+            is_spoiler: { type: Boolean },
+            is_spoiler_visible: { type: Boolean },
+            message_type: { type: String },
+            edited: { type: String },
+            model: { type: Object },
+            moderated_by: { type: String },
+            moderation_reason: { type: String },
+            msgid: { type: String },
+            occupant_affiliation: { type: String },
+            occupant_role: { type: String },
+            oob_url: { type: String },
+            progress: { type: Number },
+            reason: { type: String },
+            received: { type: String },
+            retractable: { type: Boolean },
+            sender: { type: String },
+            show_spinner: { type: Boolean },
+            spoiler_hint: { type: String },
+            subject: { type: String },
+            time: { type: String },
+            username: { type: String }
+        }
+    }
+
+    render () {
+        const format = api.settings.get('time_format');
+        this.pretty_time = dayjs(this.time).format(format);
+        if (this.show_spinner) {
+            return tpl_spinner();
+        } else if (this.model.get('file') && !this.model.get('oob_url')) {
+            return this.renderFileProgress();
+        } else if (['error', 'info'].includes(this.message_type)) {
+            return this.renderInfoMessage();
+        } else {
+            return this.renderChatMessage();
+        }
+    }
+
+    updated () {
+        // XXX: This is ugly but tests rely on this event.
+        // For "normal" chat messages the event is fired in
+        // src/templates/directives/body.js
+        if (
+            this.show_spinner ||
+            (this.model.get('file') && !this.model.get('oob_url')) ||
+            (['error', 'info'].includes(this.message_type))
+        ) {
+            this.model.collection?.trigger('rendered', this.model);
+        }
+    }
+
+    renderInfoMessage () {
+        const isodate = dayjs(this.model.get('time')).toISOString();
+        const i18n_retry = __('Retry');
+        return html`
+            <div class="message chat-info chat-${this.message_type}"
+                data-isodate="${isodate}"
+                data-type="${this.data_name}"
+                data-value="${this.data_value}">
+
+                <div class="chat-info__message">
+                    ${ this.model.getMessageText() }
+                </div>
+                ${ this.reason ? html`<q class="reason">${this.reason}</q>` : `` }
+                ${ this.error_text ? html`<q class="reason">${this.error_text}</q>` : `` }
+                ${ this.allow_retry ? html`<a class="retry" @click=${this.onRetryClicked}>${i18n_retry}</a>` : '' }
+            </div>
+        `;
+    }
+
+    renderFileProgress () {
+        const filename = this.model.file.name;
+        const size = filesize(this.model.file.size);
+        return html`
+            <div class="message chat-msg">
+                ${ renderAvatar(this) }
+                <div class="chat-msg__content">
+                    <span class="chat-msg__text">${i18n_uploading} <strong>${filename}</strong>, ${size}</span>
+                    <progress value="${this.progress}"/>
+                </div>
+            </div>`;
+    }
+
+    renderChatMessage () {
+        const is_groupchat_message = (this.message_type === 'groupchat');
+        return html`
+            <div class="message chat-msg ${this.message_type} ${this.getExtraMessageClasses()}
+                    ${ this.is_me_message ? 'chat-msg--action' : '' }
+                    ${this.isFollowup() ? 'chat-msg--followup' : ''}"
+                    data-isodate="${this.time}" data-msgid="${this.msgid}" data-from="${this.from}" data-encrypted="${this.is_encrypted}">
+
+                ${ renderAvatar(this) }
+                <div class="chat-msg__content chat-msg__content--${this.sender} ${this.is_me_message ? 'chat-msg__content--action' : ''}">
+                    ${this.first_unread ? html`<div class="message date-separator"><hr class="separator"><span class="separator-text">{{{this.__('unread messages')}}}</span></div>` : '' }
+                    <span class="chat-msg__heading">
+                        ${ (this.is_me_message) ? html`
+                            <time timestamp="${this.time}" class="chat-msg__time">${this.pretty_time}</time>
+                            ${this.hats.map(hat => html`<span class="badge badge-secondary">${hat}</span>`)}
+                        ` : '' }
+                        <span class="chat-msg__author">${ this.is_me_message ? '**' : ''}${this.username}</span>
+                        ${ !this.is_me_message ? this.renderAvatarByline() : '' }
+                        ${ this.is_encrypted ? html`<span class="fa fa-lock"></span>` : '' }
+                    </span>
+                    <div class="chat-msg__body chat-msg__body--${this.message_type} ${this.received ? 'chat-msg__body--received' : '' } ${this.is_delayed ? 'chat-msg__body--delayed' : '' }">
+                        <div class="chat-msg__message">
+                            ${ this.is_retracted ? this.renderRetraction() : this.renderMessageText() }
+                        </div>
+                        ${ (this.received && !this.is_me_message && !is_groupchat_message) ? html`<span class="fa fa-check chat-msg__receipt"></span>` : '' }
+                        ${ (this.edited) ? html`<i title="${ i18n_edited }" class="fa fa-edit chat-msg__edit-modal" @click=${this.showMessageVersionsModal}></i>` : '' }
+                        <div class="chat-msg__actions">
+                            ${ this.editable ?
+                                    html`<button
+                                        class="chat-msg__action chat-msg__action-edit"
+                                        title="${i18n_edit_message}"
+                                        @click=${this.onMessageEditButtonClicked}
+                                    >
+                                    <fa-icon class="fas fa-pencil-alt" path-prefix="dist" color="var(--text-color-lighten-15-percent)" size="1em"></fa-icon>
+                                </button>` : '' }
+                            ${ renderRetractionLink(this) }
+                        </div>
+                    </div>
+                </div>
+            </div>`;
+    }
+
+    async onRetryClicked () {
+        this.show_spinner = true;
+        await this.model.error.retry();
+        this.model.destroy();
+        this.parentElement.removeChild(this);
+    }
+
+    onMessageRetractButtonClicked (ev) {
+        ev.preventDefault();
+        this.chatview.onMessageRetractButtonClicked(this.model);
+    }
+
+    onMessageEditButtonClicked (ev) {
+        ev.preventDefault();
+        this.chatview.onMessageEditButtonClicked(this.model);
+    }
+
+    isFollowup () {
+        const messages = this.model.collection.models;
+        const idx = messages.indexOf(this.model);
+        const prev_model = idx ? messages[idx-1] : null;
+        if (prev_model === null) {
+            return false;
+        }
+        const date = dayjs(this.time);
+        return this.from === prev_model.get('from') &&
+            !this.is_me_message &&
+            !prev_model.isMeCommand() &&
+            this.message_type !== 'info' &&
+            prev_model.get('type') !== 'info' &&
+            date.isBefore(dayjs(prev_model.get('time')).add(10, 'minutes')) &&
+            this.is_encrypted === prev_model.get('is_encrypted');
+    }
+
+
+    getExtraMessageClasses () {
+        const extra_classes = [
+            ...(this.is_delayed ? ['delayed'] : []),
+            ...(this.is_retracted ? ['chat-msg--retracted'] : [])
+        ];
+        if (this.message_type === 'groupchat') {
+            this.occupant_role && extra_classes.push(this.occupant_role);
+            this.occupant_affiliation && extra_classes.push(this.occupant_affiliation);
+            if (this.sender === 'them' && this.has_mentions) {
+                extra_classes.push('mentioned');
+            }
+        }
+        this.correcting && extra_classes.push('correcting');
+        return extra_classes.filter(c => c).join(" ");
+    }
+
+    getRetractionText () {
+        if (this.message_type === 'groupchat' && this.moderated_by) {
+            const retracted_by_mod = this.moderated_by;
+            const chatbox = this.model.collection.chatbox;
+            if (!this.model.mod) {
+                this.model.mod =
+                    chatbox.occupants.findOccupant({'jid': retracted_by_mod}) ||
+                    chatbox.occupants.findOccupant({'nick': Strophe.getResourceFromJid(retracted_by_mod)});
+            }
+            const modname = this.model.mod ? this.model.mod.getDisplayName() : 'A moderator';
+            return __('%1$s has removed this message', modname);
+        } else {
+            return __('%1$s has removed this message', this.model.getDisplayName());
+        }
+    }
+
+    renderRetraction () {
+        const retraction_text = this.is_retracted ? this.getRetractionText() : null;
+        return html`
+            <div>${retraction_text}</div>
+            ${ this.moderation_reason ? html`<q class="chat-msg--retracted__reason">${this.moderation_reason}</q>` : '' }
+        `;
+    }
+
+    renderMessageText () {
+        const tpl_spoiler_hint = html`
+            <div class="chat-msg__spoiler-hint">
+                <span class="spoiler-hint">${this.spoiler_hint}</span>
+                <a class="badge badge-info spoiler-toggle" href="#" @click=${this.toggleSpoilerMessage}>
+                    <i class="fa ${this.is_spoiler_visible ? 'fa-eye-slash' : 'fa-eye'}"></i>
+                    ${ this.is_spoiler_visible ? i18n_show_less : i18n_show }
+                </a>
+            </div>
+        `;
+        return html`
+            ${ this.is_spoiler ? tpl_spoiler_hint : '' }
+            ${ this.subject ? html`<div class="chat-msg__subject">${this.subject}</div>` : '' }
+            <converse-chat-message-body
+                .model="${this.model}"
+                ?is_me_message="${this.is_me_message}"
+                ?is_only_emojis="${this.is_only_emojis}"
+                ?is_spoiler="${this.is_spoiler}"
+                ?is_spoiler_visible="${this.is_spoiler_visible}"
+                text="${this.model.getMessageText()}"></converse-chat-message-body>
+            ${ this.oob_url ? html`<div class="chat-msg__media">${u.getOOBURLMarkup(_converse, this.oob_url)}</div>` : '' }
+            <div class="chat-msg__error">${ this.error_text || this.error }</div>
+        `;
+    }
+
+    renderAvatarByline () {
+        return html`
+            ${ this.hats.map(h => html`<span class="badge badge-secondary">${h.title}</span>`) }
+            <time timestamp="${this.time}" class="chat-msg__time">${this.pretty_time}</time>
+        `;
+    }
+
+    showMessageVersionsModal (ev) {
+        ev.preventDefault();
+        if (this.message_versions_modal === undefined) {
+            this.message_versions_modal = new MessageVersionsModal({'model': this.model});
+        }
+        this.message_versions_modal.show(ev);
+    }
+
+    toggleSpoilerMessage (ev) {
+        ev?.preventDefault();
+        this.model.save({'is_spoiler_visible': !this.model.get('is_spoiler_visible')});
+    }
+}
+
+customElements.define('converse-chat-message', Message);

+ 114 - 279
src/converse-chatview.js

@@ -3,31 +3,29 @@
  * @copyright 2020, the Converse.js contributors
  * @license Mozilla Public License (MPLv2)
  */
+import "./components/chat_content.js";
+import "./components/help_messages.js";
 import "converse-chatboxviews";
-import "converse-message-view";
 import "converse-modal";
 import log from "@converse/headless/log";
 import tpl_chatbox from "templates/chatbox.js";
 import tpl_chatbox_head from "templates/chatbox_head.js";
 import tpl_chatbox_message_form from "templates/chatbox_message_form.html";
-import tpl_help_message from "templates/help_message.html";
-import tpl_info from "templates/info.html";
 import tpl_new_day from "templates/new_day.html";
 import tpl_spinner from "templates/spinner.html";
 import tpl_spoiler_button from "templates/spoiler_button.html";
 import tpl_toolbar from "templates/toolbar.html";
 import tpl_toolbar_fileupload from "templates/toolbar_fileupload.html";
 import tpl_user_details_modal from "templates/user_details_modal.js";
-import xss from "xss/dist/xss";
 import { BootstrapModal } from "./converse-modal.js";
-import { Overview } from "skeletor.js/src/overview";
+import { View } from 'skeletor.js/src/view.js';
 import { __ } from '@converse/headless/i18n';
 import { _converse, api, converse } from "@converse/headless/converse-core";
 import { debounce, isString } from "lodash";
 import { html, render } from "lit-html";
 
 
-const { Strophe, sizzle, dayjs } = converse.env;
+const { Strophe, dayjs } = converse.env;
 const u = converse.env.utils;
 
 
@@ -46,7 +44,6 @@ converse.plugins.add('converse-chatview', {
         "converse-chatboxviews",
         "converse-chat",
         "converse-disco",
-        "converse-message-view",
         "converse-modal"
     ],
 
@@ -57,9 +54,13 @@ converse.plugins.add('converse-chatview', {
         api.settings.update({
             'auto_focus': true,
             'message_limit': 0,
-            'show_send_button': true,
+            'muc_hats_from_vcard': false,
+            'show_images_inline': true,
             'show_retraction_warning': true,
+            'show_send_button': true,
             'show_toolbar': true,
+            'time_format': 'HH:mm',
+            'debounced_content_rendering': true,
             'visible_toolbar_buttons': {
                 'call': false,
                 'clear': true,
@@ -163,19 +164,16 @@ converse.plugins.add('converse-chatview', {
          * @namespace _converse.ChatBoxView
          * @memberOf _converse
          */
-        _converse.ChatBoxView = Overview.extend({
+        _converse.ChatBoxView = View.extend({
             length: 200,
             className: 'chatbox hidden',
             is_chatroom: false,  // Leaky abstraction from MUC
 
             events: {
                 'change input.fileupload': 'onFileSelection',
-                'click .chat-msg__action-edit': 'onMessageEditButtonClicked',
-                'click .chat-msg__action-retract': 'onMessageRetractButtonClicked',
                 'click .chatbox-navback': 'showControlBox',
                 'click .new-msgs-indicator': 'viewUnreadMessages',
                 'click .send-button': 'onFormSubmitted',
-                'click .spoiler-toggle': 'toggleSpoilerMessage',
                 'click .toggle-call': 'toggleCall',
                 'click .toggle-clear': 'clearMessages',
                 'click .toggle-compose-spoiler': 'toggleComposeSpoilerMessage',
@@ -191,15 +189,6 @@ converse.plugins.add('converse-chatview', {
             async initialize () {
                 this.initDebounced();
 
-                this.listenTo(this.model.messages, 'add', this.onMessageAdded);
-                this.listenTo(this.model.messages, 'rendered', this.scrollDown);
-                this.model.messages.on('reset', () => {
-                    this.msgs_container.innerHTML = '';
-                    this.removeAll();
-                });
-
-                this.listenTo(this.model.notifications, 'change', this.renderChatStateNotification);
-
                 this.listenTo(this.model, 'change:status', this.onStatusMessageChanged);
                 this.listenTo(this.model, 'destroy', this.remove);
                 this.listenTo(this.model, 'show', this.show);
@@ -217,6 +206,14 @@ converse.plugins.add('converse-chatview', {
 
                 this.listenTo(this.model.presence, 'change:show', this.onPresenceChanged);
                 this.render();
+
+                // Need to be registered after render has been called.
+                this.listenTo(this.model.messages, 'add', this.onMessageAdded);
+                this.listenTo(this.model.messages, 'change', this.renderChatHistory);
+                this.listenTo(this.model.messages, 'reset', this.renderChatHistory);
+                this.listenTo(this.model.notifications, 'change', this.renderNotifications);
+                this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages);
+
                 await this.updateAfterMessagesFetched();
                 this.model.maybeShow();
                 /**
@@ -229,11 +226,19 @@ converse.plugins.add('converse-chatview', {
             },
 
             initDebounced () {
-                this.scrollDown = debounce(this._scrollDown, 50);
                 this.markScrolled = debounce(this._markScrolled, 100);
+                // For tests that use Jasmine.Clock we want to turn of
+                // debouncing, since setTimeout breaks.
+                if (api.settings.get('debounced_content_rendering')) {
+                    this.renderChatHistory = debounce(() => this.renderChatContent(false), 100);
+                    this.renderNotifications = debounce(() => this.renderChatContent(true), 100);
+                } else {
+                    this.renderChatHistory = () => this.renderChatContent(false);
+                    this.renderNotifications = () => this.renderChatContent(true);
+                }
             },
 
-            render () {
+            async render () {
                 const result = tpl_chatbox(
                     Object.assign(
                         this.model.toJSON(), {
@@ -244,26 +249,87 @@ converse.plugins.add('converse-chatview', {
                 );
                 render(result, this.el);
                 this.content = this.el.querySelector('.chat-content');
+
                 this.notifications = this.el.querySelector('.chat-content__notifications');
                 this.msgs_container = this.el.querySelector('.chat-content__messages');
-                this.renderChatStateNotification();
+                this.help_container = this.el.querySelector('.chat-content__help');
+
+                await api.waitUntil('emojisInitialized');
+                this.renderChatContent();
                 this.renderMessageForm();
                 this.renderHeading();
                 return this;
             },
 
-            renderChatStateNotification () {
+            onMessageAdded (message) {
+                this.renderChatHistory();
+
+                if (u.isNewMessage(message)) {
+                    if (message.get('sender') === 'me') {
+                        // We remove the "scrolled" flag so that the chat area
+                        // gets scrolled down. We always want to scroll down
+                        // when the user writes a message as opposed to when a
+                        // message is received.
+                        this.model.set('scrolled', false);
+                    } else if (this.model.get('scrolled', true)) {
+                        this.showNewMessagesIndicator();
+                    }
+                }
+            },
+
+            getNotifications () {
                 if (this.model.notifications.get('chat_state') === _converse.COMPOSING) {
-                    this.notifications.innerText = __('%1$s is typing', this.model.getDisplayName());
+                    return __('%1$s is typing', this.model.getDisplayName());
                 } else if (this.model.notifications.get('chat_state') === _converse.PAUSED) {
-                    this.notifications.innerText = __('%1$s has stopped typing', this.model.getDisplayName());
+                    return __('%1$s has stopped typing', this.model.getDisplayName());
                 } else if (this.model.notifications.get('chat_state') === _converse.GONE) {
-                    this.notifications.innerText = __('%1$s has gone away', this.model.getDisplayName());
+                    return __('%1$s has gone away', this.model.getDisplayName());
                 } else {
-                    this.notifications.innerText = '';
+                    return '';
                 }
             },
 
+            getHelpMessages () {
+                return [
+                    `<strong>/clear</strong>: ${__('Remove messages')}`,
+                    `<strong>/close</strong>: ${__('Close this chat')}`,
+                    `<strong>/me</strong>: ${__('Write in the third person')}`,
+                    `<strong>/help</strong>: ${__('Show this menu')}`
+                ];
+            },
+
+            renderHelpMessages () {
+                render(
+                    html`<converse-chat-help
+                        .model=${this.model}
+                        .messages=${this.getHelpMessages()}
+                        ?hidden=${!this.model.get('show_help_messages')}
+                        type="info"
+                        chat_type="${this.model.get('type')}"></converse-chat-help>`,
+
+                    this.help_container
+                );
+            },
+
+            renderChatContent (msgs_by_ref=false) {
+                if (!this.tpl_chat_content) {
+                    this.tpl_chat_content = (o) => {
+                        return html`
+                            <converse-chat-content
+                                .chatview=${this}
+                                .messages=${o.messages}
+                                notifications=${o.notifications}>
+                            </converse-chat-content>`
+                    };
+                }
+                const msg_models = this.model.messages.models;
+                const messages = msgs_by_ref ? msg_models : Array.from(msg_models);
+                render(
+                    this.tpl_chat_content({ messages, 'notifications': this.getNotifications() }),
+                    this.msgs_container
+                );
+            },
+
             renderToolbar () {
                 if (!api.settings.get('show_toolbar')) {
                     return this;
@@ -473,10 +539,9 @@ converse.plugins.add('converse-chatview', {
 
             async updateAfterMessagesFetched () {
                 await this.model.messages.fetched;
-                await Promise.all(this.model.messages.map(m => this.onMessageAdded(m)));
+                this.renderChatContent();
                 this.insertIntoDOM();
                 this.scrollDown();
-                this.content.addEventListener('scroll', () => this.markScrolled());
                 /**
                  * Triggered whenever a `_converse.ChatBox` instance has fetched its messages from
                  * `sessionStorage` but **NOT** from the server.
@@ -484,7 +549,12 @@ converse.plugins.add('converse-chatview', {
                  * @type {_converse.ChatBoxView | _converse.ChatRoomView}
                  * @example _converse.api.listen.on('afterMessagesFetched', view => { ... });
                  */
-                api.trigger('afterMessagesFetched', this);
+                api.trigger('afterMessagesFetched', this.model);
+            },
+
+            scrollDown () {
+                const el = this.msgs_container.firstElementChild;
+                el && el.scrollDown();
             },
 
             insertIntoDOM () {
@@ -499,20 +569,6 @@ converse.plugins.add('converse-chatview', {
                 return this;
             },
 
-            showChatEvent (message) {
-                const isodate = (new Date()).toISOString();
-                this.msgs_container.insertAdjacentHTML(
-                    'beforeend',
-                    tpl_info({
-                        'extra_classes': 'chat-event',
-                        'message': message,
-                        'isodate': isodate,
-                    }));
-                this.insertDayIndicator(this.msgs_container.lastElementChild);
-                this.scrollDown();
-                return isodate;
-            },
-
             addSpinner (append=false) {
                 if (this.el.querySelector('.spinner') === null) {
                     if (append) {
@@ -557,47 +613,6 @@ converse.plugins.add('converse-chatview', {
                 }
             },
 
-            /**
-             * Return the ISO8601 format date of the latest message.
-             * @private
-             * @method _converse.ChatBoxView#getLastMessageDate
-             * @param { Date } cutoff - Moment Date cutoff date. The last
-             *      message received cutoff this date will be returned.
-             * @returns { Date }
-             */
-            getLastMessageDate (cutoff) {
-                const first_msg = u.getFirstChildElement(this.msgs_container, '.message:not(.chat-state-notification)');
-                const oldest_date = first_msg ? first_msg.getAttribute('data-isodate') : null;
-                if (oldest_date !== null && dayjs(oldest_date).isAfter(cutoff)) {
-                    return null;
-                }
-                const last_msg = u.getLastChildElement(this.msgs_container, '.message:not(.chat-state-notification)');
-                const most_recent_date = last_msg ? last_msg.getAttribute('data-isodate') : null;
-                if (most_recent_date === null) {
-                    return null;
-                }
-                if (dayjs(most_recent_date).isBefore(cutoff)) {
-                    return dayjs(most_recent_date).toDate();
-                }
-                /* XXX: We avoid .chat-state-notification messages, since they are
-                 * temporary and get removed once a new element is
-                 * inserted into the chat area, so we don't query for
-                 * them here, otherwise we get a null reference later
-                 * upon element insertion.
-                 */
-                const sel = '.message:not(.chat-state-notification)';
-                const msg_dates = sizzle(sel, this.msgs_container).map(e => e.getAttribute('data-isodate'));
-                const cutoff_iso = cutoff.toISOString();
-                msg_dates.push(cutoff_iso);
-                msg_dates.sort();
-                const idx = msg_dates.lastIndexOf(cutoff_iso);
-                if (idx === 0) {
-                    return null;
-                } else {
-                    return dayjs(msg_dates[idx-1]).toDate();
-                }
-            },
-
             setScrollPosition (message_el) {
                 /* Given a newly inserted message, determine whether we
                  * should keep the scrollbar in place (so as to not scroll
@@ -637,63 +652,10 @@ converse.plugins.add('converse-chatview', {
                 });
             },
 
-            showHelpMessages (msgs, type='info', spinner) {
-                msgs.forEach(msg => {
-                    this.msgs_container.insertAdjacentHTML(
-                        'beforeend',
-                        tpl_help_message({
-                            'isodate': (new Date()).toISOString(),
-                            'type': type,
-                            'message': xss.filterXSS(msg, {'whiteList': {'strong': []}})
-                        })
-                    );
-                });
-                if (spinner === true) {
-                    this.addSpinner();
-                } else if (spinner === false) {
-                    this.clearSpinner();
-                }
-                return this.scrollDown();
-            },
-
             shouldShowOnTextMessage () {
                 return !u.isVisible(this.el);
             },
 
-            /**
-             * Given a view representing a message, insert it into the
-             * content area of the chat box.
-             * @private
-             * @method _converse.ChatBoxView#insertMessage
-             * @param { View } message - The message View
-             */
-            insertMessage (view) {
-                if (view.model.get('type') === 'error') {
-                    const previous_msg_el = this.msgs_container.querySelector(`[data-msgid="${view.model.get('msgid')}"]`);
-                    if (previous_msg_el) {
-                        previous_msg_el.insertAdjacentElement('afterend', view.el);
-                        return this.trigger('messageInserted', view.el);
-                    }
-                }
-                const current_msg_date = dayjs(view.model.get('time')).toDate() || new Date();
-                const previous_msg_date = this.getLastMessageDate(current_msg_date);
-
-                if (previous_msg_date === null) {
-                    this.msgs_container.insertAdjacentElement('afterbegin', view.el);
-                } else {
-                    const previous_msg_el = sizzle(`[data-isodate="${previous_msg_date.toISOString()}"]:last`, this.msgs_container).pop();
-                    if (view.model.get('type') === 'error' &&
-                            u.hasClass('chat-error', previous_msg_el) &&
-                            previous_msg_el.textContent === view.model.get('message')) {
-                        // We don't show a duplicate error message
-                        return;
-                    }
-                    previous_msg_el.insertAdjacentElement('afterend', view.el);
-                    this.markFollowups(view.el);
-                }
-                return this.trigger('messageInserted', view.el);
-            },
-
             /**
              * Given a message element, determine wether it should be
              * marked as a followup message to the previous element.
@@ -733,71 +695,6 @@ converse.plugins.add('converse-chatview', {
                 }
             },
 
-            /**
-             * Inserts a chat message into the content area of the chat box.
-             * Will also insert a new day indicator if the message is on a different day.
-             * @private
-             * @method _converse.ChatBoxView#showMessage
-             * @param { _converse.Message } message - The message object
-             */
-            async showMessage (message) {
-                await message.initialized;
-                const view = this.add(message.get('id'), new _converse.MessageView({'model': message}));
-                await view.render();
-                this.insertMessage(view);
-                this.insertDayIndicator(view.el);
-                this.setScrollPosition(view.el);
-
-                if (u.isNewMessage(message)) {
-                    if (message.get('sender') === 'me') {
-                        // We remove the "scrolled" flag so that the chat area
-                        // gets scrolled down. We always want to scroll down
-                        // when the user writes a message as opposed to when a
-                        // message is received.
-                        this.model.set('scrolled', false);
-                    } else if (this.model.get('scrolled', true)) {
-                        this.showNewMessagesIndicator();
-                    }
-                }
-                if (this.shouldShowOnTextMessage()) {
-                    this.show();
-                } else {
-                    this.scrollDown();
-                }
-                if (message.get('correcting')) {
-                    this.insertIntoTextArea(message.get('message'), true, true);
-                }
-            },
-
-            /**
-             * Handler that gets called when a new message object is created.
-             * @private
-             * @method _converse.ChatBoxView#onMessageAdded
-             * @param { object } message - The message object that was added.
-             */
-            async onMessageAdded (message) {
-                const id = message.get('id');
-                if (id && this.get(id)) {
-                    // We already have a view for this message
-                    return;
-                }
-                if (!message.get('dangling_retraction')) {
-                    await this.showMessage(message);
-                }
-                /**
-                 * Triggered once a message has been added to a chatbox.
-                 * @event _converse#messageAdded
-                 * @type {object}
-                 * @property { _converse.Message } message - The message instance
-                 * @property { _converse.ChatBox | _converse.ChatRoom } chatbox - The chat model
-                 * @example _converse.api.listen.on('messageAdded', data => { ... });
-                 */
-                api.trigger('messageAdded', {
-                    'message': message,
-                    'chatbox': this.model
-                });
-            },
-
             parseMessageForCommands (text) {
                 const match = text.replace(/^\s*/, "").match(/^\/(.*)\s*$/);
                 if (match) {
@@ -808,13 +705,7 @@ converse.plugins.add('converse-chatview', {
                         this.close();
                         return true;
                     } else if (match[1] === "help") {
-                        const msgs = [
-                            `<strong>/clear</strong>: ${__('Remove messages')}`,
-                            `<strong>/close</strong>: ${__('Close this chat')}`,
-                            `<strong>/me</strong>: ${__('Write in the third person')}`,
-                            `<strong>/help</strong>: ${__('Show this menu')}`
-                            ];
-                        this.showHelpMessages(msgs);
+                        this.model.set({'show_help_messages': true});
                         return true;
                     }
                 }
@@ -829,10 +720,8 @@ converse.plugins.add('converse-chatview', {
                     return;
                 }
                 if (!_converse.connection.authenticated) {
-                    this.showHelpMessages(
-                        ['Sorry, the connection has been lost, and your message could not be sent'],
-                        'error'
-                    );
+                    const err_msg = __('Sorry, the connection has been lost, and your message could not be sent');
+                    api.alert('error', __('Error'), err_msg);
                     api.connection.reconnect();
                     return;
                 }
@@ -977,14 +866,9 @@ converse.plugins.add('converse-chatview', {
                 this.insertIntoTextArea('', true, false);
             },
 
-            async onMessageRetractButtonClicked (ev) {
-                ev.preventDefault();
-                const msg_el = u.ancestor(ev.target, '.message');
-                const msgid = msg_el.getAttribute('data-msgid');
-                const time = msg_el.getAttribute('data-isodate');
-                const message = this.model.messages.findWhere({msgid, time});
+            async onMessageRetractButtonClicked (message) {
                 if (message.get('sender') !== 'me') {
-                    return log.error("onMessageEditButtonClicked called for someone else's message!");
+                    return log.error("onMessageRetractButtonClicked called for someone else's message!");
                 }
                 const retraction_warning =
                     __("Be aware that other XMPP/Jabber clients (and servers) may "+
@@ -1001,26 +885,17 @@ converse.plugins.add('converse-chatview', {
                 }
             },
 
-            onMessageEditButtonClicked (ev) {
-                ev.preventDefault();
-
-                const idx = this.model.messages.findLastIndex('correcting'),
-                      currently_correcting = idx >=0 ? this.model.messages.at(idx) : null,
-                      message_el = u.ancestor(ev.target, '.chat-msg'),
-                      message = this.model.messages.findWhere({'msgid': message_el.getAttribute('data-msgid')});
-
-                const textarea = this.el.querySelector('.chat-textarea');
-                if (textarea.value &&
-                        ((currently_correcting === null) || currently_correcting.get('message') !== textarea.value)) {
+            onMessageEditButtonClicked (message) {
+                const currently_correcting = this.model.messages.findWhere('correcting');
+                const unsent_text = this.el.querySelector('.chat-textarea')?.value;
+                if (unsent_text && (!currently_correcting || currently_correcting.get('message') !== unsent_text)) {
                     if (! confirm(__("You have an unsent message which will be lost if you continue. Are you sure?"))) {
                         return;
                     }
                 }
 
                 if (currently_correcting !== message) {
-                    if (currently_correcting !== null) {
-                        currently_correcting.save('correcting', false);
-                    }
+                    currently_correcting?.save('correcting', false);
                     message.save('correcting', true);
                     this.insertIntoTextArea(u.prefixMentions(message), true, true);
                 } else {
@@ -1150,34 +1025,9 @@ converse.plugins.add('converse-chatview', {
                 this.focus();
             },
 
-            toggleSpoilerMessage (ev) {
-                if (ev && ev.preventDefault) {
-                    ev.preventDefault();
-                }
-                const toggle_el = ev.target,
-                    icon_el = toggle_el.firstElementChild;
-
-                u.slideToggleElement(
-                    toggle_el.parentElement.parentElement.querySelector('.spoiler')
-                );
-                if (toggle_el.getAttribute("data-toggle-state") == "closed") {
-                    toggle_el.textContent = 'Show less';
-                    icon_el.classList.remove("fa-eye");
-                    icon_el.classList.add("fa-eye-slash");
-                    toggle_el.insertAdjacentElement('afterBegin', icon_el);
-                    toggle_el.setAttribute("data-toggle-state", "open");
-                } else {
-                    toggle_el.textContent = 'Show more';
-                    icon_el.classList.remove("fa-eye-slash");
-                    icon_el.classList.add("fa-eye");
-                    toggle_el.insertAdjacentElement('afterBegin', icon_el);
-                    toggle_el.setAttribute("data-toggle-state", "closed");
-                }
-            },
-
             onPresenceChanged (item) {
-                const show = item.get('show'),
-                      fullname = this.model.getDisplayName();
+                const show = item.get('show');
+                const fullname = this.model.getDisplayName();
 
                 let text;
                 if (u.isVisible(this.el)) {
@@ -1333,21 +1183,6 @@ converse.plugins.add('converse-chatview', {
                 this.scrollDown();
             },
 
-            _scrollDown () {
-                /* Inner method that gets debounced */
-                if (this.content === undefined) {
-                    return;
-                }
-                if (u.isVisible(this.content) && !this.model.get('scrolled')) {
-                    if ((this.content.scrollTop === 0 || this.content.scrollTop < this.content.scrollHeight/2)) {
-                        u.removeClass('smooth-scroll', this.content);
-                    } else if (api.settings.get('animate')) {
-                        u.addClass('smooth-scroll', this.content);
-                    }
-                    this.content.scrollTop = this.content.scrollHeight;
-                }
-            },
-
             onScrolledDown () {
                 this.hideNewMessagesIndicator();
                 if (_converse.windowState !== 'hidden') {

+ 7 - 1
src/converse-headlines-view.js

@@ -132,7 +132,7 @@ converse.plugins.add('converse-headlines-view', {
                 this.initDebounced();
 
                 this.model.disable_mam = true; // Don't do MAM queries for this box
-                this.listenTo(this.model.messages, 'add', this.onMessageAdded);
+                this.listenTo(this.model.messages, 'add', this.renderChatHistory);
                 this.listenTo(this.model, 'show', this.show);
                 this.listenTo(this.model, 'destroy', this.hide);
                 this.listenTo(this.model, 'change:minimized', this.onMinimizedChanged);
@@ -168,6 +168,12 @@ converse.plugins.add('converse-headlines-view', {
                 return this;
             },
 
+            getNotifications () {
+                // Override method in ChatBox. We don't show notifications for
+                // headlines boxes.
+                return [];
+            },
+
             /**
              * Returns a list of objects which represent buttons for the headlines header.
              * @async

+ 0 - 386
src/converse-message-view.js

@@ -1,386 +0,0 @@
-/**
- * @module converse-message-view
- * @copyright 2020, the Converse.js contributors
- * @license Mozilla Public License (MPLv2)
- */
-import "./utils/html";
-import "@converse/headless/converse-emoji";
-import URI from "urijs";
-import filesize from "filesize";
-import log from "@converse/headless/log";
-import tpl_file_progress from "templates/file_progress.html";
-import tpl_info from "templates/info.html";
-import tpl_message from "templates/message.html";
-import tpl_message_versions_modal from "templates/message_versions_modal.js";
-import tpl_spinner from "templates/spinner.html";
-import xss from "xss/dist/xss";
-import { BootstrapModal } from "./converse-modal.js";
-import { __ } from '@converse/headless/i18n';
-import { _converse, api, converse } from  "@converse/headless/converse-core";
-import { debounce } from 'lodash'
-import { render } from "lit-html";
-
-const { Strophe, dayjs } = converse.env;
-const u = converse.env.utils;
-
-
-converse.plugins.add('converse-message-view', {
-
-    dependencies: ["converse-modal", "converse-chatboxviews"],
-
-    initialize () {
-        /* The initialize function gets called as soon as the plugin is
-         * loaded by converse.js's plugin machinery.
-         */
-
-        function onTagFoundDuringXSSFilter (tag, html, options) {
-            /* This function gets called by the XSS library whenever it finds
-             * what it thinks is a new HTML tag.
-             *
-             * It thinks that something like <https://example.com> is an HTML
-             * tag and then escapes the <> chars.
-             *
-             * We want to avoid this, because it prevents these URLs from being
-             * shown properly (whithout the trailing &gt;).
-             *
-             * The URI lib correctly trims a trailing >, but not a trailing &gt;
-             */
-            if (options.isClosing) {
-                // Closing tags don't match our use-case
-                return;
-            }
-            const uri = new URI(tag);
-            const protocol = uri.protocol().toLowerCase();
-            if (!["https", "http", "xmpp", "ftp"].includes(protocol)) {
-                // Not a URL, the tag will get filtered as usual
-                return;
-            }
-            if (uri.equals(tag) && `<${tag}>` === html.toLocaleLowerCase()) {
-                // We have something like <https://example.com>, and don't want
-                // to filter it.
-                return html;
-            }
-        }
-
-
-        api.settings.update({
-            'muc_hats_from_vcard': false,
-            'show_images_inline': true,
-            'time_format': 'HH:mm',
-        });
-
-        _converse.MessageVersionsModal = BootstrapModal.extend({
-            id: "message-versions-modal",
-            toHTML () {
-                return tpl_message_versions_modal(this.model.toJSON());
-            }
-        });
-
-
-        /**
-         * @class
-         * @namespace _converse.MessageView
-         * @memberOf _converse
-         */
-        _converse.MessageView = _converse.ViewWithAvatar.extend({
-            events: {
-                'click .chat-msg__edit-modal': 'showMessageVersionsModal',
-                'click .retry': 'onRetryClicked'
-            },
-
-            initialize () {
-                this.debouncedRender = debounce(() => {
-                    // If the model gets destroyed in the meantime,
-                    // it no longer has a collection
-                    if (this.model.collection) {
-                        this.render();
-                    }
-                }, 50);
-
-                if (this.model.rosterContactAdded) {
-                    this.model.rosterContactAdded.then(() => {
-                        this.listenTo(this.model.contact, 'change:nickname', this.debouncedRender);
-                        this.debouncedRender();
-                    });
-                }
-
-                this.model.occupant && this.addOccupantListeners();
-                this.listenTo(this.model, 'change', this.onChanged);
-                this.listenTo(this.model, 'destroy', this.fadeOut);
-                this.listenTo(this.model, 'occupantAdded', () => {
-                    this.addOccupantListeners();
-                    this.debouncedRender();
-                });
-                this.listenTo(this.model, 'vcard:change', this.debouncedRender);
-                this.debouncedRender();
-            },
-
-            async render () {
-                const is_followup = u.hasClass('chat-msg--followup', this.el);
-                if (this.model.get('file') && !this.model.get('oob_url')) {
-                    if (!this.model.file) {
-                        log.error("Attempted to render a file upload message with no file data");
-                        return this.el;
-                    }
-                    this.renderFileUploadProgresBar();
-                } else if (this.model.get('type') === 'error') {
-                    this.renderErrorMessage();
-                } else if (this.model.get('type') === 'info') {
-                    this.renderInfoMessage();
-                } else {
-                    await this.renderChatMessage();
-                }
-                is_followup && u.addClass('chat-msg--followup', this.el);
-                return this.el;
-            },
-
-            async onChanged (item) {
-                // Jot down whether it was edited because the `changed`
-                // attr gets removed when this.render() gets called further down.
-                const edited = item.changed.edited;
-                if (this.model.changed.progress) {
-                    return this.renderFileUploadProgresBar();
-                }
-                // TODO: We can remove this once we render messages via lit-html
-                const isValidChange = prop => Object.prototype.hasOwnProperty.call(this.model.changed, prop);
-                const props = [
-                    'correcting',
-                    'editable',
-                    'error',
-                    'message',
-                    'moderated',
-                    'received',
-                    'retracted',
-                    'type',
-                    'upload',
-                ];
-                if (props.filter(isValidChange).length) {
-                    await this.debouncedRender();
-                }
-                if (edited) {
-                    this.onMessageEdited();
-                }
-            },
-
-            addOccupantListeners () {
-                this.listenTo(this.model.occupant, 'change:affiliation', this.debouncedRender);
-                this.listenTo(this.model.occupant, 'change:hats', this.debouncedRender);
-                this.listenTo(this.model.occupant, 'change:role', this.debouncedRender);
-            },
-
-            fadeOut () {
-                if (api.settings.get('animate')) {
-                    setTimeout(() => this.remove(), 600);
-                    u.addClass('fade-out', this.el);
-                } else {
-                    this.remove();
-                }
-            },
-
-            async onRetryClicked () {
-                this.showSpinner();
-                await this.model.error.retry();
-                this.model.destroy();
-            },
-
-            showSpinner () {
-                this.el.innerHTML = tpl_spinner();
-            },
-
-            onMessageEdited () {
-                if (this.model.get('is_archived')) {
-                    return;
-                }
-                this.el.addEventListener(
-                    'animationend',
-                    () => u.removeClass('onload', this.el),
-                    {'once': true}
-                );
-                u.addClass('onload', this.el);
-            },
-
-            replaceElement (msg) {
-                if (this.el.parentElement) {
-                    this.el.parentElement.replaceChild(msg, this.el);
-                }
-                this.setElement(msg);
-                return this.el;
-            },
-
-            transformOOBURL (url) {
-                return u.getOOBURLMarkup(_converse, url);
-            },
-
-            async transformBodyText (text) {
-                /**
-                 * Synchronous event which provides a hook for transforming a chat message's body text
-                 * before the default transformations have been applied.
-                 * @event _converse#beforeMessageBodyTransformed
-                 * @param { _converse.MessageView } view - The view representing the message
-                 * @param { string } text - The message text
-                 * @example _converse.api.listen.on('beforeMessageBodyTransformed', (view, text) => { ... });
-                 */
-                await api.trigger('beforeMessageBodyTransformed', this, text, {'Synchronous': true});
-                text = this.model.isMeCommand() ? text.substring(4) : text;
-                text = xss.filterXSS(text, {'whiteList': {}, 'onTag': onTagFoundDuringXSSFilter});
-                text = u.geoUriToHttp(text, api.settings.get("geouri_replacement"));
-                text = u.addMentionsMarkup(text, this.model.get('references'), this.model.collection.chatbox);
-                text = u.addHyperlinks(text);
-                text = u.renderNewLines(text);
-                text = u.addEmoji(text);
-                /**
-                 * Synchronous event which provides a hook for transforming a chat message's body text
-                 * after the default transformations have been applied.
-                 * @event _converse#afterMessageBodyTransformed
-                 * @param { _converse.MessageView } view - The view representing the message
-                 * @param { string } text - The message text
-                 * @example _converse.api.listen.on('afterMessageBodyTransformed', (view, text) => { ... });
-                 */
-                await api.trigger('afterMessageBodyTransformed', this, text, {'Synchronous': true});
-                return text;
-            },
-
-            async renderChatMessage () {
-                await api.waitUntil('emojisInitialized');
-                const time = dayjs(this.model.get('time'));
-                const is_retracted = this.model.get('retracted') || this.model.get('moderated') === 'retracted';
-                const may_be_moderated = this.model.get('type') === 'groupchat' && await this.model.mayBeModerated();
-                const retractable= !is_retracted && (this.model.mayBeRetracted() || may_be_moderated);
-                const is_groupchat_message = this.model.get('type') === 'groupchat';
-
-                let hats = [];
-                if (is_groupchat_message) {
-                    if (api.settings.get('muc_hats_from_vcard')) {
-                        const role = this.model.vcard ? this.model.vcard.get('role') : null;
-                        hats = role ? role.split(',') : [];
-                    } else {
-                        hats = this.model.occupant?.get('hats') || [];
-                    }
-                }
-
-                const msg = u.stringToElement(tpl_message(
-                    Object.assign(
-                        this.model.toJSON(), {
-                         __,
-                        hats,
-                        is_groupchat_message,
-                        is_retracted,
-                        retractable,
-                        'extra_classes': this.getExtraMessageClasses(),
-                        'is_me_message': this.model.isMeCommand(),
-                        'label_show': __('Show more'),
-                        'occupant': this.model.occupant,
-                        'pretty_time': time.format(api.settings.get('time_format')),
-                        'retraction_text': is_retracted ? this.getRetractionText() : null,
-                        'time': time.toISOString(),
-                        'username': this.model.getDisplayName()
-                    })
-                ));
-
-                const url = this.model.get('oob_url');
-                url && render(this.transformOOBURL(url), msg.querySelector('.chat-msg__media'));
-
-                if (!is_retracted) {
-                    const text = this.model.getMessageText();
-                    const msg_content = msg.querySelector('.chat-msg__text');
-                    if (text && text !== url) {
-                        msg_content.innerHTML = await this.transformBodyText(text);
-                        if (api.settings.get('show_images_inline')) {
-                            u.renderImageURLs(_converse, msg_content).then(() => this.triggerRendered());
-                        }
-                    }
-                }
-                if (this.model.get('type') !== 'headline') {
-                    this.renderAvatar(msg);
-                }
-                this.replaceElement(msg);
-                this.triggerRendered();
-            },
-
-            triggerRendered () {
-                if (this.model.collection) {
-                    // If the model gets destroyed in the meantime, it no
-                    // longer has a collection.
-                    this.model.collection.trigger('rendered', this);
-                }
-            },
-
-            renderInfoMessage () {
-                const msg = u.stringToElement(
-                    tpl_info(Object.assign(this.model.toJSON(), {
-                        'extra_classes': 'chat-info',
-                        'isodate': dayjs(this.model.get('time')).toISOString()
-                    }))
-                );
-                return this.replaceElement(msg);
-            },
-
-            getRetractionText () {
-                if (this.model.get('type') === 'groupchat' && this.model.get('moderated_by')) {
-                    const retracted_by_mod = this.model.get('moderated_by');
-                    const chatbox = this.model.collection.chatbox;
-                    if (!this.model.mod) {
-                        this.model.mod =
-                            chatbox.occupants.findOccupant({'jid': retracted_by_mod}) ||
-                            chatbox.occupants.findOccupant({'nick': Strophe.getResourceFromJid(retracted_by_mod)});
-                    }
-                    const modname = this.model.mod ? this.model.mod.getDisplayName() : 'A moderator';
-                    return __('%1$s has removed this message', modname);
-                } else {
-                    return __('%1$s has removed this message', this.model.getDisplayName());
-                }
-            },
-
-            renderErrorMessage () {
-                const msg = u.stringToElement(
-                    tpl_info(Object.assign(this.model.toJSON(), {
-                        'extra_classes': 'chat-error',
-                        'isodate': dayjs(this.model.get('time')).toISOString()
-                    }))
-                );
-                return this.replaceElement(msg);
-            },
-
-            renderFileUploadProgresBar () {
-                const msg = u.stringToElement(tpl_file_progress(
-                    Object.assign(this.model.toJSON(), {
-                        '__': __,
-                        'filename': this.model.file.name,
-                        'filesize': filesize(this.model.file.size)
-                    })));
-                this.replaceElement(msg);
-                this.renderAvatar();
-            },
-
-            showMessageVersionsModal (ev) {
-                ev.preventDefault();
-                if (this.model.message_versions_modal === undefined) {
-                    this.model.message_versions_modal = new _converse.MessageVersionsModal({'model': this.model});
-                }
-                this.model.message_versions_modal.show(ev);
-            },
-
-            getExtraMessageClasses () {
-                const is_retracted = this.model.get('retracted') || this.model.get('moderated') === 'retracted';
-                const extra_classes = [
-                    ...(this.model.get('is_delayed') ? ['delayed'] : []), ...(is_retracted ? ['chat-msg--retracted'] : [])
-                ];
-                if (this.model.get('type') === 'groupchat') {
-                    if (this.model.occupant) {
-                        extra_classes.push(this.model.occupant.get('role'));
-                        extra_classes.push(this.model.occupant.get('affiliation'));
-                    }
-                    if (this.model.get('sender') === 'them' && this.model.collection.chatbox.isUserMentioned(this.model)) {
-                        // Add special class to mark groupchat messages
-                        // in which we are mentioned.
-                        extra_classes.push('mentioned');
-                    }
-                }
-                if (this.model.get('correcting')) {
-                    extra_classes.push('correcting');
-                }
-                return extra_classes.filter(c => c).join(" ");
-            }
-        });
-    }
-});

+ 48 - 103
src/converse-muc-views.js

@@ -17,7 +17,6 @@ import tpl_chatroom_destroyed from "templates/chatroom_destroyed.html";
 import tpl_chatroom_disconnect from "templates/chatroom_disconnect.html";
 import tpl_chatroom_head from "templates/chatroom_head.js";
 import tpl_chatroom_nickname_form from "templates/chatroom_nickname_form.html";
-import tpl_info from "templates/info.html";
 import tpl_list_chatrooms_modal from "templates/list_chatrooms_modal.js";
 import tpl_muc_config_form from "templates/muc_config_form.js";
 import tpl_muc_invite_modal from "templates/muc_invite_modal.js";
@@ -438,8 +437,6 @@ converse.plugins.add('converse-muc-views', {
             is_chatroom: true,
             events: {
                 'change input.fileupload': 'onFileSelection',
-                'click .chat-msg__action-edit': 'onMessageEditButtonClicked',
-                'click .chat-msg__action-retract': 'onMessageRetractButtonClicked',
                 'click .chatbox-navback': 'showControlBox',
                 'click .hide-occupants': 'hideOccupants',
                 'click .new-msgs-indicator': 'viewUnreadMessages',
@@ -463,24 +460,15 @@ converse.plugins.add('converse-muc-views', {
             async initialize () {
                 this.initDebounced();
 
-                this.listenTo(this.model.messages, 'add', this.onMessageAdded);
-                this.listenTo(this.model.messages, 'change:edited', this.onMessageEdited);
-                this.listenTo(this.model.messages, 'rendered', this.scrollDown);
-                this.model.messages.on('reset', () => {
-                    this.msgs_container.innerHTML = '';
-                    this.removeAll();
-                });
-
-                this.listenTo(this.model.session, 'change:connection_status', this.onConnectionStatusChanged);
-
                 this.listenTo(this.model, 'change', debounce(() => this.renderHeading(), 250));
                 this.listenTo(this.model, 'change:hidden_occupants', this.updateOccupantsToggle);
                 this.listenTo(this.model, 'configurationNeeded', this.getAndRenderConfigurationForm);
                 this.listenTo(this.model, 'destroy', this.hide);
                 this.listenTo(this.model, 'show', this.show);
-
                 this.listenTo(this.model.features, 'change:moderated', this.renderBottomPanel);
                 this.listenTo(this.model.features, 'change:open', this.renderHeading);
+                this.listenTo(this.model.messages, 'rendered', this.scrollDown);
+                this.listenTo(this.model.session, 'change:connection_status', this.onConnectionStatusChanged);
 
                 // Bind so that we can pass it to addEventListener and removeEventListener
                 this.onMouseMove =  this.onMouseMove.bind(this);
@@ -489,13 +477,19 @@ converse.plugins.add('converse-muc-views', {
                 await this.render();
 
                 // Need to be registered after render has been called.
+                this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages);
+                this.listenTo(this.model.messages, 'add', this.onMessageAdded);
+                this.listenTo(this.model.messages, 'change', this.renderChatHistory);
+                this.listenTo(this.model.messages, 'reset', this.renderChatHistory);
+                this.listenTo(this.model.notifications, 'change', this.renderNotifications);
+
                 this.model.occupants.forEach(o => this.onOccupantAdded(o));
                 this.listenTo(this.model.occupants, 'add', this.onOccupantAdded);
-                this.listenTo(this.model.occupants, 'remove', this.onOccupantRemoved);
-                this.listenTo(this.model.occupants, 'change:show', this.showJoinOrLeaveNotification);
-                this.listenTo(this.model.occupants, 'change:role', this.onOccupantRoleChanged);
+                this.listenTo(this.model.occupants, 'change', this.renderChatHistory);
                 this.listenTo(this.model.occupants, 'change:affiliation', this.onOccupantAffiliationChanged);
-                this.listenTo(this.model.notifications, 'change', this.renderNotifications);
+                this.listenTo(this.model.occupants, 'change:role', this.onOccupantRoleChanged);
+                this.listenTo(this.model.occupants, 'change:show', this.showJoinOrLeaveNotification);
+                this.listenTo(this.model.occupants, 'remove', this.onOccupantRemoved);
 
                 this.createSidebarView();
                 await this.updateAfterMessagesFetched();
@@ -522,9 +516,11 @@ converse.plugins.add('converse-muc-views', {
                     'muc_show_logs_before_join': _converse.muc_show_logs_before_join,
                     'show_send_button': _converse.show_send_button
                 }), this.el);
+
                 this.notifications = this.el.querySelector('.chat-content__notifications');
                 this.content = this.el.querySelector('.chat-content');
                 this.msgs_container = this.el.querySelector('.chat-content__messages');
+                this.help_container = this.el.querySelector('.chat-content__help');
 
                 this.renderBottomPanel();
                 if (!_converse.muc_show_logs_before_join &&
@@ -538,13 +534,13 @@ converse.plugins.add('converse-muc-views', {
                 !this.model.get('hidden') && this.show();
             },
 
-            renderNotifications () {
+            getNotifications () {
                 const actors_per_state = this.model.notifications.toJSON();
                 const states = api.settings.get('muc_show_join_leave') ?
                     [...converse.CHAT_STATES, ...converse.MUC_TRAFFIC_STATES, ...converse.MUC_ROLE_CHANGES] :
                     converse.CHAT_STATES;
 
-                const message = states.reduce((result, state) => {
+                return states.reduce((result, state) => {
                     const existing_actors = actors_per_state[state];
                     if (!(existing_actors?.length)) {
                         return result;
@@ -601,8 +597,34 @@ converse.plugins.add('converse-muc-views', {
                     }
                     return result;
                 }, '');
-                this.notifications.innerHTML = message;
-                message.includes('\n') && this.scrollDown();
+            },
+
+            getHelpMessages () {
+                const setting = api.settings.get("muc_disable_slash_commands");
+                const disabled_commands = Array.isArray(setting) ? setting : [];
+                return [
+                    `<strong>/admin</strong>: ${__("Change user's affiliation to admin")}`,
+                    `<strong>/ban</strong>: ${__('Ban user by changing their affiliation to outcast')}`,
+                    `<strong>/clear</strong>: ${__('Clear the chat area')}`,
+                    `<strong>/close</strong>: ${__('Close this groupchat')}`,
+                    `<strong>/deop</strong>: ${__('Change user role to participant')}`,
+                    `<strong>/destroy</strong>: ${__('Remove this groupchat')}`,
+                    `<strong>/help</strong>: ${__('Show this menu')}`,
+                    `<strong>/kick</strong>: ${__('Kick user from groupchat')}`,
+                    `<strong>/me</strong>: ${__('Write in 3rd person')}`,
+                    `<strong>/member</strong>: ${__('Grant membership to a user')}`,
+                    `<strong>/modtools</strong>: ${__('Opens up the moderator tools GUI')}`,
+                    `<strong>/mute</strong>: ${__("Remove user's ability to post messages")}`,
+                    `<strong>/nick</strong>: ${__('Change your nickname')}`,
+                    `<strong>/op</strong>: ${__('Grant moderator role to user')}`,
+                    `<strong>/owner</strong>: ${__('Grant ownership of this groupchat')}`,
+                    `<strong>/register</strong>: ${__("Register your nickname")}`,
+                    `<strong>/revoke</strong>: ${__("Revoke the user's current affiliation")}`,
+                    `<strong>/subject</strong>: ${__('Set groupchat subject')}`,
+                    `<strong>/topic</strong>: ${__('Set groupchat subject (alias for /subject)')}`,
+                    `<strong>/voice</strong>: ${__('Allow muted user to post messages')}`
+                    ].filter(line => disabled_commands.every(c => (!line.startsWith(c+'<', 9))))
+                        .filter(line => this.getAllowedCommands().some(c => line.startsWith(c+'<', 9)));
             },
 
             /**
@@ -785,12 +807,7 @@ converse.plugins.add('converse-muc-views', {
                 return _converse.ChatBoxView.prototype.onKeyUp.call(this, ev);
             },
 
-            async onMessageRetractButtonClicked (ev) {
-                ev.preventDefault();
-                const msg_el = u.ancestor(ev.target, '.message');
-                const msgid = msg_el.getAttribute('data-msgid');
-                const time = msg_el.getAttribute('data-isodate');
-                const message = this.model.messages.findWhere({msgid, time});
+            async onMessageRetractButtonClicked (message) {
                 const retraction_warning =
                     __("Be aware that other XMPP/Jabber clients (and servers) may "+
                         "not yet support retractions and that this message may not "+
@@ -801,7 +818,7 @@ converse.plugins.add('converse-muc-views', {
                     if (_converse.show_retraction_warning) {
                         messages[1] = retraction_warning;
                     }
-                    !!(await api.confirm(__('Confirm'), messages)) && this.retractOwnMessage(message);
+                    !!(await api.confirm(__('Confirm'), messages)) && this.model.retractOwnMessage(message);
                 } else if (await message.mayBeModerated()) {
                     if (message.get('sender') === 'me') {
                         let messages = [__('Are you sure you want to retract this message?')];
@@ -830,22 +847,6 @@ converse.plugins.add('converse-muc-views', {
                 }
             },
 
-            /**
-             * Retract one of your messages in this groupchat.
-             * @private
-             * @method _converse.ChatRoomView#retractOwnMessage
-             * @param { _converse.Message } message - The message which we're retracting.
-             */
-            retractOwnMessage(message) {
-                this.model.retractOwnMessage(message)
-                    .catch(e => {
-                        const message = __('Sorry, something went wrong while trying to retract your message.');
-                        this.model.createMessage({message, 'type': 'error'});
-                        !u.isErrorStanza(e) && this.model.createMessage({'message': e.message, 'type': 'error'});
-                        log.error(e);
-                    });
-            },
-
             /**
              * Retract someone else's message in this groupchat.
              * @private
@@ -1363,10 +1364,7 @@ converse.plugins.add('converse-muc-views', {
                     return false;
                 }
                 const args = text.slice(('/'+command).length+1).trim();
-                const disabled_commands = Array.isArray(_converse.muc_disable_slash_commands) ?
-                        _converse.muc_disable_slash_commands : [];
-                const allowed_commands = this.getAllowedCommands();
-                if (!allowed_commands.includes(command)) {
+                if (!this.getAllowedCommands().includes(command)) {
                     return false;
                 }
 
@@ -1401,31 +1399,7 @@ converse.plugins.add('converse-muc-views', {
                         break;
                     }
                     case 'help': {
-                        this.showHelpMessages([`<strong>${__("You can run the following commands")}</strong>`]);
-                        this.showHelpMessages([
-                            `<strong>/admin</strong>: ${__("Change user's affiliation to admin")}`,
-                            `<strong>/ban</strong>: ${__('Ban user by changing their affiliation to outcast')}`,
-                            `<strong>/clear</strong>: ${__('Clear the chat area')}`,
-                            `<strong>/close</strong>: ${__('Close this groupchat')}`,
-                            `<strong>/deop</strong>: ${__('Change user role to participant')}`,
-                            `<strong>/destroy</strong>: ${__('Remove this groupchat')}`,
-                            `<strong>/help</strong>: ${__('Show this menu')}`,
-                            `<strong>/kick</strong>: ${__('Kick user from groupchat')}`,
-                            `<strong>/me</strong>: ${__('Write in 3rd person')}`,
-                            `<strong>/member</strong>: ${__('Grant membership to a user')}`,
-                            `<strong>/modtools</strong>: ${__('Opens up the moderator tools GUI')}`,
-                            `<strong>/mute</strong>: ${__("Remove user's ability to post messages")}`,
-                            `<strong>/nick</strong>: ${__('Change your nickname')}`,
-                            `<strong>/op</strong>: ${__('Grant moderator role to user')}`,
-                            `<strong>/owner</strong>: ${__('Grant ownership of this groupchat')}`,
-                            `<strong>/register</strong>: ${__("Register your nickname")}`,
-                            `<strong>/revoke</strong>: ${__("Revoke the user's current affiliation")}`,
-                            `<strong>/subject</strong>: ${__('Set groupchat subject')}`,
-                            `<strong>/topic</strong>: ${__('Set groupchat subject (alias for /subject)')}`,
-                            `<strong>/voice</strong>: ${__('Allow muted user to post messages')}`
-                            ].filter(line => disabled_commands.every(c => (!line.startsWith(c+'<', 9))))
-                             .filter(line => allowed_commands.some(c => line.startsWith(c+'<', 9)))
-                        );
+                        this.model.set({'show_help_messages': true});
                         break;
                     } case 'kick': {
                         this.setRole(command, args, [], ['moderator']);
@@ -1673,35 +1647,6 @@ converse.plugins.add('converse-muc-views', {
                 u.showElement(container);
             },
 
-            removeEmptyHistoryFeedback () {
-                const el = this.msgs_container.firstElementChild;
-                if (_converse.muc_show_logs_before_join && el && el.matches('.empty-history-feedback')) {
-                    this.msgs_container.removeChild(this.msgs_container.firstElementChild);
-                }
-            },
-
-            insertDayIndicator () {
-                this.removeEmptyHistoryFeedback();
-                return _converse.ChatBoxView.prototype.insertDayIndicator.apply(this, arguments);
-            },
-
-            insertMessage (view) {
-                this.removeEmptyHistoryFeedback();
-                return _converse.ChatBoxView.prototype.insertMessage.call(this, view);
-            },
-
-            insertNotification (message) {
-                this.removeEmptyHistoryFeedback();
-                this.msgs_container.insertAdjacentHTML(
-                    'beforeend',
-                    tpl_info({
-                        'isodate': (new Date()).toISOString(),
-                        'extra_classes': 'chat-event',
-                        'message': message
-                    })
-                );
-            },
-
             onOccupantAdded (occupant) {
                 if (occupant.get('jid') === _converse.bare_jid) {
                     this.renderHeading();

+ 0 - 7
src/converse-omemo.js

@@ -194,13 +194,6 @@ converse.plugins.add('converse-omemo', {
                 this.__super__.initialize.apply(this, arguments);
                 this.listenTo(this.model, 'change:omemo_active', this.renderOMEMOToolbarButton);
                 this.listenTo(this.model, 'change:omemo_supported', this.onOMEMOSupportedDetermined);
-            },
-
-            showMessage (message) {
-                // We don't show a message if it's only keying material
-                if (!message.get('is_only_key')) {
-                    return this.__super__.showMessage.apply(this, arguments);
-                }
             }
         },
 

+ 0 - 1
src/converse.js

@@ -45,7 +45,6 @@ const WHITELISTED_PLUGINS = [
     'converse-emoji-views',
     'converse-fullscreen',
     'converse-mam-views',
-    'converse-message-view',
     'converse-minimize',
     'converse-modal',
     'converse-muc-views',

+ 62 - 25
src/headless/converse-chat.js

@@ -331,8 +331,8 @@ converse.plugins.add('converse-chat', {
                     return;
                 }
                 this.set({'box_id': `box-${btoa(jid)}`});
-                this.initMessages();
                 this.initNotifications();
+                this.initMessages();
 
                 if (this.get('type') === _converse.PRIVATE_CHAT_TYPE) {
                     this.presence = _converse.presences.findWhere({'jid': jid}) || _converse.presences.create({'jid': jid});
@@ -395,9 +395,39 @@ converse.plugins.add('converse-chat', {
                 return this.messages.fetched;
             },
 
-            async handleErrormessageStanza (stanza) {
-                if (await this.shouldShowErrorMessage(stanza)) {
-                    this.createMessage(await st.parseMessage(stanza, _converse));
+            async handleErrorMessageStanza (stanza) {
+                const attrs = await st.parseMessage(stanza, _converse);
+                if (!await this.shouldShowErrorMessage(attrs)) {
+                    return;
+                }
+                const message = this.getMessageReferencedByError(attrs);
+                if (message) {
+                    const new_attrs = {
+                        'error': attrs.error,
+                        'error_condition': attrs.error_condition,
+                        'error_text': attrs.error_text,
+                        'error_type': attrs.error_type,
+                    };
+                    if (attrs.msgid === message.get('retraction_id')) {
+                        // The error message refers to a retraction
+                        new_attrs.retraction_id = undefined;
+                        if (!attrs.error) {
+                            if (attrs.error_condition === 'forbidden') {
+                                new_attrs.error = __("You're not allowed to retract your message.");
+                            } else {
+                                new_attrs.error = __('Sorry, an error occurred while trying to retract your message.');
+                            }
+                        }
+                    } else if (!attrs.error) {
+                        if (attrs.error_condition === 'forbidden') {
+                            new_attrs.error = __("You're not allowed to send a message.");
+                        } else {
+                            new_attrs.error = __('Sorry, an error occurred while trying to send your message.');
+                        }
+                    }
+                    message.save(new_attrs);
+                } else {
+                    this.createMessage(attrs);
                 }
             },
 
@@ -510,7 +540,11 @@ converse.plugins.add('converse-chat', {
 
             async createMessageFromError (error) {
                 if (error instanceof _converse.TimeoutError) {
-                    const msg = await this.createMessage({'type': 'error', 'message': error.message, 'retry': true});
+                    const msg = await this.createMessage({
+                        'type': 'error',
+                        'message': error.message,
+                        'retry': true
+                    });
                     msg.error = error;
                 }
             },
@@ -579,27 +613,29 @@ converse.plugins.add('converse-chat', {
                 return this;
             },
 
+            /**
+             * Given an error `<message>` stanza's attributes, find the saved message model which is
+             * referenced by that error.
+             * @param { Object } attrs
+             */
+            getMessageReferencedByError (attrs) {
+                const id = attrs.msgid;
+                return id && this.messages.models.find(m => [m.get('msgid'), m.get('retraction_id')].includes(id));
+            },
+
             /**
              * @private
              * @method _converse.ChatBox#shouldShowErrorMessage
              * @returns {boolean}
              */
-            shouldShowErrorMessage (stanza) {
-                const id = stanza.getAttribute('id');
-                if (id) {
-                    const msgs = this.messages.where({'msgid': id});
-                    const referenced_msgs = msgs.filter(m => m.get('type') !== 'error');
-                    if (!referenced_msgs.length && stanza.querySelector('body') === null) {
-                        // If the error refers to a message not included in our store,
-                        // and it doesn't have a <body> tag, we assume that this was a
-                        // CSI message (which we don't store).
-                        // See https://github.com/conversejs/converse.js/issues/1317
-                        return;
-                    }
-                    const dupes = msgs.filter(m => m.get('type') === 'error');
-                    if (dupes.length) {
-                        return;
-                    }
+            shouldShowErrorMessage (attrs) {
+                const msg = this.getMessageReferencedByError(attrs);
+                if (!msg && attrs.body === null) {
+                    // If the error refers to a message not included in our store,
+                    // and it doesn't have a <body> tag, we assume that this was a
+                    // CSI message (which we don't store).
+                    // See https://github.com/conversejs/converse.js/issues/1317
+                    return;
                 }
                 // Gets overridden in ChatRoom
                 return true;
@@ -765,6 +801,7 @@ converse.plugins.add('converse-chat', {
                 message.save({
                     'retracted': (new Date()).toISOString(),
                     'retracted_id': message.get('origin_id'),
+                    'retraction_id': message.get('id'),
                     'is_ephemeral': true,
                     'editable': false
                 });
@@ -1044,9 +1081,9 @@ converse.plugins.add('converse-chat', {
                     });
                     return;
                 }
-                const data = item.dataforms.where({'FORM_TYPE': {'value': Strophe.NS.HTTPUPLOAD, 'type': "hidden"}}).pop(),
-                      max_file_size = window.parseInt((data?.attributes || {})['max-file-size']?.value),
-                      slot_request_url = item?.id;
+                const data = item.dataforms.where({'FORM_TYPE': {'value': Strophe.NS.HTTPUPLOAD, 'type': "hidden"}}).pop();
+                const max_file_size = window.parseInt((data?.attributes || {})['max-file-size']?.value);
+                const slot_request_url = item?.id;
 
                 if (!slot_request_url) {
                     this.createMessage({
@@ -1147,7 +1184,7 @@ converse.plugins.add('converse-chat', {
                 return;
             }
             const chatbox = await api.chatboxes.get(from_jid);
-            chatbox?.handleErrormessageStanza(stanza);
+            chatbox?.handleErrorMessageStanza(stanza);
         }
 
 

+ 63 - 43
src/headless/converse-muc.js

@@ -382,8 +382,8 @@ converse.plugins.add('converse-muc', {
                 this.initialized = u.getResolveablePromise();
                 this.debouncedRejoin = debounce(this.rejoin, 250);
                 this.set('box_id', `box-${btoa(this.get('jid'))}`);
-                this.initMessages();
                 this.initNotifications();
+                this.initMessages();
                 this.initOccupants();
                 this.initDiscoModels(); // sendChatState depends on this.features
                 this.registerHandlers();
@@ -618,15 +618,43 @@ converse.plugins.add('converse-muc', {
                 }
             },
 
-            async handleErrormessageStanza (stanza) {
-                if (await this.shouldShowErrorMessage(stanza)) {
-                    const attrs = await st.parseMUCMessage(stanza, this, _converse);
-                    const message = attrs.msgid && this.messages.findWhere({'msgid': attrs.msgid});
-                    if (message) {
-                        message.save({'error': attrs.error});
-                    } else {
-                        this.createMessage(attrs);
+            async handleErrorMessageStanza (stanza) {
+                const attrs = await st.parseMUCMessage(stanza, this, _converse);
+                if (!await this.shouldShowErrorMessage(attrs)) {
+                    return;
+                }
+                const message = this.getMessageReferencedByError(attrs);
+                if (message) {
+                    const new_attrs = {
+                        'error': attrs.error,
+                        'error_condition': attrs.error_condition,
+                        'error_text': attrs.error_text,
+                        'error_type': attrs.error_type,
+                    };
+                    if (attrs.msgid === message.get('retraction_id')) {
+                        // The error message refers to a retraction
+                        new_attrs.retraction_id = undefined;
+                        if (!attrs.error) {
+                            if (attrs.error_condition === 'forbidden') {
+                                new_attrs.error = __("You're not allowed to retract your message.");
+                            } else if (attrs.error_condition === 'not-acceptable') {
+                                new_attrs.error = __("Your retraction was not delivered because you're not present in the groupchat.");
+                            } else {
+                                new_attrs.error = __('Sorry, an error occurred while trying to retract your message.');
+                            }
+                        }
+                    } else if (!attrs.error) {
+                        if (attrs.error_condition === 'forbidden') {
+                            new_attrs.error = __("Your message was not delivered because you weren't allowed to send it.");
+                        } else if (attrs.error_condition === 'not-acceptable') {
+                            new_attrs.error = __("Your message was not delivered because you're not present in the groupchat.");
+                        } else {
+                            new_attrs.error = __('Sorry, an error occurred while trying to send your message.');
+                        }
                     }
+                    message.save(new_attrs);
+                } else {
+                    this.createMessage(attrs);
                 }
             },
 
@@ -749,20 +777,38 @@ converse.plugins.add('converse-muc', {
              * @param { _converse.Message } message - The message which we're retracting.
              */
             async retractOwnMessage(message) {
+                const origin_id = message.get('origin_id');
+                if (!origin_id) {
+                    throw new Error("Can't retract message without a XEP-0359 Origin ID");
+                }
                 const editable = message.get('editable');
+                const stanza = $msg({
+                        'id': u.getUniqueId(),
+                        'to': this.get('jid'),
+                        'type': "groupchat"
+                    })
+                    .c('store', {xmlns: Strophe.NS.HINTS}).up()
+                    .c("apply-to", {
+                        'id': origin_id,
+                        'xmlns': Strophe.NS.FASTEN
+                    }).c('retract', {xmlns: Strophe.NS.RETRACT});
+
                 // Optimistic save
-                message.save({
+                message.set({
                     'retracted': (new Date()).toISOString(),
-                    'retracted_id': message.get('origin_id'),
+                    'retracted_id': origin_id,
+                    'retraction_id': stanza.nodeTree.getAttribute('id'),
                     'editable': false
                 });
                 try {
-                    await this.sendRetractionMessage(message)
+                    await this.sendTimedMessage(stanza);
                 } catch (e) {
                     message.save({
                         editable,
+                        'error_type': 'timeout',
+                        'error': __('A timeout happened while while trying to retract your message.'),
                         'retracted': undefined,
-                        'retracted_id': undefined,
+                        'retracted_id': undefined
                     });
                     throw e;
                 }
@@ -799,30 +845,6 @@ converse.plugins.add('converse-muc', {
                 return result;
             },
 
-            /**
-             * Sends a message stanza to retract a message in this groupchat.
-             * @private
-             * @method _converse.ChatRoom#sendRetractionMessage
-             * @param { _converse.Message } message - The message which we're retracting.
-             */
-            sendRetractionMessage (message) {
-                const origin_id = message.get('origin_id');
-                if (!origin_id) {
-                    throw new Error("Can't retract message without a XEP-0359 Origin ID");
-                }
-                const msg = $msg({
-                        'id': u.getUniqueId(),
-                        'to': this.get('jid'),
-                        'type': "groupchat"
-                    })
-                    .c('store', {xmlns: Strophe.NS.HINTS}).up()
-                    .c("apply-to", {
-                        'id': origin_id,
-                        'xmlns': Strophe.NS.FASTEN
-                    }).c('retract', {xmlns: Strophe.NS.RETRACT});
-                return this.sendTimedMessage(msg);
-            },
-
             /**
              * Sends an IQ stanza to the XMPP server to retract a message in this groupchat.
              * @private
@@ -1815,13 +1837,11 @@ converse.plugins.add('converse-muc', {
              * @method _converse.ChatRoom#shouldShowErrorMessage
              * @returns {Promise<boolean>}
              */
-            async shouldShowErrorMessage (stanza) {
-                if (sizzle(`not-acceptable[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length) {
-                    if (await this.rejoinIfNecessary()) {
-                        return false;
-                    }
+            async shouldShowErrorMessage (attrs) {
+                if (attrs['error_condition'] === 'not-acceptable' && await this.rejoinIfNecessary()) {
+                    return false;
                 }
-                return _converse.ChatBox.prototype.shouldShowErrorMessage.call(this, stanza);
+                return _converse.ChatBox.prototype.shouldShowErrorMessage.call(this, attrs);
             },
 
             /**

+ 0 - 10
src/headless/utils/core.js

@@ -463,16 +463,6 @@ u.triggerEvent = function (el, name, type="Event", bubbles=true, cancelable=true
     el.dispatchEvent(evt);
 };
 
-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) {
-    const replacement = 'geo:$1,$2';
-    return text.replace(_converse.api.settings.get("geouri_regex"), replacement);
-};
-
 u.getSelectValues = function (select) {
     const result = [];
     const options = select && select.options;

+ 21 - 32
src/headless/utils/stanza.js

@@ -3,7 +3,6 @@ import dayjs from 'dayjs';
 import sizzle from 'sizzle';
 import u from '@converse/headless/utils/core';
 import log from "../log";
-import { __ } from '@converse/headless/i18n';
 import { api } from "@converse/headless/converse-core";
 
 const Strophe = strophe.default.Strophe;
@@ -243,20 +242,6 @@ function getReferences (stanza) {
     });
 }
 
-/**
- * Returns the human readable error message contained in an message stanza of type 'error'.
- * @private
- * @param { XMLElement } stanza - The message stanza
- */
-function getErrorMessage (stanza) {
-    if (stanza.getAttribute('type') === 'error') {
-        const error = stanza.querySelector('error');
-        return error.querySelector('text')?.textContent ||
-            __('Sorry, an error occurred:') + ' ' + error.innerHTML;
-    }
-}
-
-
 function rejectMessage (stanza, text) {
     // Reject an incoming message by replying with an error message of type "cancel".
     api.send(
@@ -278,20 +263,18 @@ function rejectMessage (stanza, text) {
  * @private
  * @param { XMLElement } stanza - The message stanza
  */
-function getMUCErrorMessage (stanza) {
+function getErrorAttributes (stanza) {
     if (stanza.getAttribute('type') === 'error') {
-        const forbidden = sizzle(`error forbidden[xmlns="${Strophe.NS.STANZAS}"]`, stanza).pop();
-        const text = sizzle(`error text[xmlns="${Strophe.NS.STANZAS}"]`, stanza).pop();
-        if (forbidden) {
-            const msg = __("Your message was not delivered because you weren't allowed to send it.");
-            const server_msg = text ? __('The message from the server is: "%1$s"', text.textContent) : '';
-            return server_msg ? `${msg} ${server_msg}` : msg;
-        } else if (sizzle(`not-acceptable[xmlns="${Strophe.NS.STANZAS}"]`, stanza).length) {
-            return __("Your message was not delivered because you're not present in the groupchat.");
-        } else {
-            return text?.textContent;
+        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 {};
 }
 
 
@@ -458,6 +441,7 @@ const st = {
          * @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?
@@ -469,8 +453,10 @@ const st = {
          * @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 } edit - An ISO8601 string recording the time that the message was edited per XEP-0308
-         * @property { String } error - The error message, in case it's an error stanza
+         * @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
@@ -503,7 +489,6 @@ const st = {
                 is_server_message,
                 'body': stanza.querySelector('body')?.textContent?.trim(),
                 'chat_state': getChatState(stanza),
-                'error': getErrorMessage(stanza),
                 'from': Strophe.getBareJidFromJid(stanza.getAttribute('from')),
                 'is_archived': st.isArchived(original_stanza),
                 'is_carbon': isCarbon(original_stanza),
@@ -523,6 +508,7 @@ const st = {
                 'to': stanza.getAttribute('to'),
                 'type': stanza.getAttribute('type')
             },
+            getErrorAttributes(stanza),
             getOutOfBandAttributes(stanza),
             getSpoilerAttributes(stanza),
             getCorrectionAttributes(stanza, original_stanza),
@@ -589,6 +575,7 @@ const st = {
          * @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?
@@ -599,8 +586,10 @@ const st = {
          * @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 } edit - An ISO8601 string recording the time that the message was edited per XEP-0308
-         * @property { String } error - The error message, in case it's an error stanza
+         * @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 } from_muc - The JID of the MUC from which this message was sent
          * @property { String } fullname - The full name of the sender
@@ -632,7 +621,6 @@ const st = {
                 from,
                 'body': stanza.querySelector('body')?.textContent?.trim(),
                 'chat_state': getChatState(stanza),
-                'error': getMUCErrorMessage(stanza),
                 'from_muc': Strophe.getBareJidFromJid(from),
                 'is_archived': st.isArchived(original_stanza),
                 'is_carbon': isCarbon(original_stanza),
@@ -652,6 +640,7 @@ const st = {
                 'to': stanza.getAttribute('to'),
                 'type': stanza.getAttribute('type'),
             },
+            getErrorAttributes(stanza),
             getOutOfBandAttributes(stanza),
             getSpoilerAttributes(stanza),
             getCorrectionAttributes(stanza, original_stanza),

+ 11 - 0
src/modals/message-versions.js

@@ -0,0 +1,11 @@
+import { BootstrapModal } from "../converse-modal.js";
+import tpl_message_versions_modal from "../templates/message_versions_modal.js";
+
+
+export default BootstrapModal.extend({
+    // FIXME: this isn't globally unique
+    id: "message-versions-modal",
+    toHTML () {
+        return tpl_message_versions_modal(this.model.toJSON());
+    }
+});

+ 3 - 2
src/templates/avatar.js

@@ -1,5 +1,6 @@
 import { html } from "lit-html";
 
 export default  (o) => html`
-    <img alt="${o.alt_text}" class="avatar align-self-center ${o.extra_classes}"
-            height="${o.height}" width="${o.width}" src="data:${o.image_type};base64,${o.image}"/>`;
+    <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="${o.classes}" width="${o.width}" height="${o.height}">
+        <image width="${o.width}" height="${o.height}" preserveAspectRatio="xMidYMid meet" xlink:href="${o.image}"/>
+    </svg>`;

+ 3 - 3
src/templates/chatbox.js

@@ -4,9 +4,9 @@ export default (o) => html`
     <div class="flyout box-flyout">
         <div class="chat-head chat-head-chatbox row no-gutters"></div>
         <div class="chat-body">
-            <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" @scroll=${o.markScrolled} aria-live="polite">
-                <div class="chat-content__messages"></div>
-                <div class="chat-content__notifications"></div>
+            <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
+                <div class="chat-content__messages smooth-scroll" @scroll=${o.markScrolled}></div>
+                <div class="chat-content__help"></div>
             </div>
             <div class="bottom-panel">
                 <div class="emoji-picker__container dropup"></div>

+ 2 - 8
src/templates/chatroom.js

@@ -1,8 +1,4 @@
 import { html } from "lit-html";
-import { __ } from '@converse/headless/i18n';
-
-const i18n_no_history = __('No message history available.');
-
 
 export default (o) => html`
     <div class="flyout box-flyout">
@@ -10,10 +6,8 @@ export default (o) => html`
         <div class="chat-body chatroom-body row no-gutters">
             <div class="chat-area col">
                 <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
-                    <div class="chat-content__messages">
-                        ${ o.muc_show_logs_before_join ? html`<div class="empty-history-feedback"><span>${ i18n_no_history }</span></div>`  : '' }
-                    </div>
-                    <div class="chat-content__notifications"></div>
+                    <div class="chat-content__messages smooth-scroll" @scroll=${o.markScrolled}></div>
+                    <div class="chat-content__help"></div>
                 </div>
                 <div class="bottom-panel"></div>
             </div>

+ 2 - 4
src/templates/chatroom_head.js

@@ -1,10 +1,8 @@
 import '../components/dropdown.js';
 import { __ } from '@converse/headless/i18n';
 import { html } from "lit-html";
-import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
 import { until } from 'lit-html/directives/until.js';
 import { converse } from "@converse/headless/converse-core";
-import xss from "xss/dist/xss";
 
 const u = converse.env.utils;
 const i18n_hide_topic = __('Hide the groupchat topic');
@@ -15,7 +13,7 @@ const tpl_standalone_btns = (o) => o.standalone_btns.reverse().map(b => until(b,
 
 
 export default (o) => {
-    const subject = o.subject ? u.addHyperlinks(xss.filterXSS(o.subject.text, {'whiteList': {}})) : '';
+    const subject = o.subject ? u.addHyperlinks(o.subject.text) : '';
     const show_subject = (subject && !o.subject_hidden);
     return html`
         <div class="chatbox-title ${ show_subject ? '' :  "chatbox-title--no-desc"}">
@@ -28,6 +26,6 @@ export default (o) => {
                 ${ o.dropdown_btns.length ? html`<converse-dropdown .items=${o.dropdown_btns}></converse-dropdown>` : '' }
             </div>
         </div>
-        ${ show_subject ? html`<p class="chat-head__desc" title="${i18n_hide_topic}">${unsafeHTML(subject)}</p>` : '' }
+        ${ show_subject ? html`<p class="chat-head__desc" title="${i18n_hide_topic}">${subject}</p>` : '' }
     `;
 }

+ 31 - 0
src/templates/directives/avatar.js

@@ -0,0 +1,31 @@
+import tpl_avatar from "templates/avatar.svg";
+import xss from "xss/dist/xss";
+import { directive, html } from "lit-html";
+import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
+
+
+export const renderAvatar = directive(o => part => {
+    if (o.type === 'headline' || o.is_me_message) {
+        part.setValue('');
+        return;
+    }
+
+    if (o.model.vcard) {
+        const data = {
+            'classes': 'avatar chat-msg__avatar',
+            'width': 36,
+            'height': 36,
+        }
+        const image_type = o.model.vcard.get('image_type');
+        const image = o.model.vcard.get('image');
+        data['image'] = "data:" + image_type + ";base64," + image;
+        const avatar = tpl_avatar(data);
+        const opts = {
+            'whiteList': {
+                'svg': ['xmlns', 'xmlns:xlink', 'class', 'width', 'height'],
+                'image': ['width', 'height', 'preserveAspectRatio', 'xlink:href']
+            }
+        };
+        part.setValue(html`${unsafeHTML(xss.filterXSS(avatar, opts))}`);
+    }
+});

+ 111 - 0
src/templates/directives/body.js

@@ -0,0 +1,111 @@
+import { _converse, api, converse } from  "@converse/headless/converse-core";
+import { directive, html } from "lit-html";
+import { isString } from "lodash";
+
+const u = converse.env.utils;
+
+
+class MessageBodyRenderer extends String {
+
+    constructor (component) {
+        super();
+        this.text = component.model.getMessageText();
+        this.model = component.model;
+        this.component = component;
+    }
+
+    async transform () {
+        /**
+         * Synchronous event which provides a hook for transforming a chat message's body text
+         * before the default transformations have been applied.
+         * @event _converse#beforeMessageBodyTransformed
+         * @param { _converse.Message } model - The model representing the message
+         * @param { string } text - The message 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);
+
+        const process = (text) => {
+            text = u.addEmoji(text);
+            return addMentionsMarkup(text, this.model.get('references'), this.model.collection.chatbox);
+        }
+        const list = await Promise.all(u.addHyperlinks(text));
+        this.list = list.reduce((acc, i) => isString(i) ? [...acc, ...process(i)] : [...acc, i], []);
+        /**
+         * Synchronous event which provides a hook for transforming a chat message's body text
+         * after the default transformations have been applied.
+         * @event _converse#afterMessageBodyTransformed
+         * @param { _converse.Message } model - The model representing the message
+         * @param { string } text - The message text
+         * @example _converse.api.listen.on('afterMessageBodyTransformed', (view, text) => { ... });
+         */
+        await api.trigger('afterMessageBodyTransformed', this.model, text, {'Synchronous': true});
+
+        return this.list;
+    }
+
+    async render () {
+        return html`${await this.transform()}`
+    }
+
+    get length () {
+        return this.text.length;
+    }
+
+    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];
+    }
+}
+
+
+export const renderBodyText = directive(component => async part => {
+    const model = component.model;
+    const renderer = new MessageBodyRenderer(component);
+    part.setValue(await renderer.render());
+    part.commit();
+    model.collection?.trigger('rendered', model);
+});

+ 23 - 0
src/templates/directives/retraction.js

@@ -0,0 +1,23 @@
+import { directive, html } from "lit-html";
+import { __ } from '@converse/headless/i18n';
+
+
+const i18n_retract_message = __('Retract this message');
+const tpl_retract = (o) => html`
+    <button class="chat-msg__action chat-msg__action-retract" title="${i18n_retract_message}" @click=${o.onMessageRetractButtonClicked}>
+        <fa-icon class="fas fa-trash-alt" path-prefix="/dist" color="var(--text-color-lighten-15-percent)" size="1em"></fa-icon>
+    </button>
+`;
+
+
+export const renderRetractionLink = directive(o => async part => {
+    const may_be_moderated = o.model.get('type') === 'groupchat' && await o.model.mayBeModerated();
+    const retractable = !o.is_retracted && (o.model.mayBeRetracted() || may_be_moderated);
+
+    if (retractable) {
+        part.setValue(tpl_retract(o));
+    } else {
+        part.setValue('');
+    }
+    part.commit();
+});

+ 0 - 7
src/templates/file_progress.html

@@ -1,7 +0,0 @@
-<div class="message chat-msg" data-isodate="{{{o.time}}}" data-msgid="{{{o.msgid}}}">
-    <canvas class="avatar chat-msg__avatar" height="36" width="36"></canvas>
-    <div class="chat-msg__content">
-        <span class="chat-msg__text">{{{o.__('Uploading file:')}}} <strong>{{{o.filename}}}</strong>, {{{o.filesize}}}</span>
-        <progress value="{{{o.progress}}}"/>
-    </div>
-</div>

+ 16 - 0
src/templates/file_progress.js

@@ -0,0 +1,16 @@
+import { __ } from '@converse/headless/i18n';
+import { html } from "lit-html";
+import { renderAvatar } from './../templates/directives/avatar';
+
+const i18n_uploading = __('Uploading file:')
+
+
+export default (o) => html`
+    <div class="message chat-msg" data-isodate="${o.time}" data-msgid="${o.msgid}">
+        ${ renderAvatar(this) }
+        <div class="chat-msg__content">
+            <span class="chat-msg__text">${i18n_uploading} <strong>${o.filename}</strong>, ${o.filesize}</span>
+            <progress value="${o.progress}"/>
+        </div>
+    </div>
+`;

+ 0 - 1
src/templates/help_message.html

@@ -1 +0,0 @@
-<div class="message chat-info {[ if (o.type !== 'info') { ]} chat-{{{o.type}}} {[ } ]}" data-isodate="{{{o.isodate}}}">{{o.message}}</div>

+ 0 - 13
src/templates/info.html

@@ -1,13 +0,0 @@
-<div class="message chat-info {{{o.extra_classes}}}" data-isodate="{{{o.isodate}}}" {[ if (o.data_name) { ]} data-{{{o.data_name}}}="{{{o.data_value}}}"{[ } ]}>
-{[ if (o.render_message) {
-    // XXX: Should only ever be rendered if the message text has been sanitized already
-]}
-    {{o.message}}
-{[ } else { ]}
-<div class="chat-info__message">{{{o.message}}}</div>
-    {[ if (o.reason) { ]}<q class="reason">{{{o.reason}}}</q>{[ } ]}
-{[ } ]}
-{[ if (o.retry) { ]}
-    <a class="retry">Retry</a>
-{[ } ]}
-</div>

+ 0 - 52
src/templates/message.html

@@ -1,52 +0,0 @@
-<div class="message chat-msg {{{o.type}}} {{{o.extra_classes}}} {[ if (o.is_me_message) { ]} chat-msg--action {[ } ]}"
-        data-isodate="{{{o.time}}}" data-msgid="{{{o.msgid}}}" data-from="{{{o.from}}}" data-encrypted="{{{o.is_encrypted}}}">
-    {[ if (o.type !== 'headline' && !o.is_me_message) { ]}
-    <canvas class="avatar chat-msg__avatar" height="36" width="36"></canvas>
-    {[ } ]}
-    <div class="chat-msg__content chat-msg__content--{{{o.sender}}} {{{o.is_me_message ? 'chat-msg__content--action' : ''}}}">
-        {[ if (o.first_unread) { ]}
-            <div class="message date-separator"><hr class="separator"><span class="separator-text">{{{o.__('unread messages')}}}</span></div>
-        {[ } ]}
-        <span class="chat-msg__heading">{[ if (o.is_me_message) { ]}
-            <time timestamp="{{{o.isodate}}}" class="chat-msg__time">{{{o.pretty_time}}}</time>
-            {[ } ]}<span class="chat-msg__author">{[ if (o.is_me_message) { ]}**{[ }; ]}{{{o.username}}}</span>
-            {[ if (!o.is_me_message) { ]}{[o.hats.forEach(function (hat) { ]}<span class="badge badge-secondary">{{{hat.title}}}</span>
-            {[ }); ]}<time timestamp="{{{o.isodate}}}" class="chat-msg__time">{{{o.pretty_time}}}</time>{[ } ]}{[ if (o.is_encrypted) { ]}
-            <span class="fa fa-lock"></span>
-        {[ } ]}</span>
-        <div class="chat-msg__body chat-msg__body--{{{o.type}}} {{{o.received ? 'chat-msg__body--received' : '' }}} {{{o.is_delayed ? 'chat-msg__body--delayed' : '' }}}">
-            <div class="chat-msg__message">
-                {[ if (o.is_retracted) { ]}
-                    <div>{{{o.retraction_text}}}</div>
-                    {[ if (o.moderation_reason) { ]}<q class="chat-msg--retracted__reason">{{{o.moderation_reason}}}</q>{[ } ]}
-                {[ } else { ]}
-                    {[ if (o.is_spoiler) { ]}
-                        <div class="chat-msg__spoiler-hint">
-                            <span class="spoiler-hint">{{{o.spoiler_hint}}}</span>
-                            <a class="badge badge-info spoiler-toggle" data-toggle-state="closed" href="#"><i class="fa fa-eye"></i>{{{o.label_show}}}</a>
-                        </div>
-                    {[ } ]}
-
-                    {[ if (o.subject) { ]}
-                        <div class="chat-msg__subject">{{{ o.subject }}}</div>
-                    {[ } ]}
-                    <div class="chat-msg__text
-                        {[ if (o.is_only_emojis) { ]} chat-msg__text--larger{[ } ]}
-                        {[ if (o.is_spoiler) { ]} spoiler collapsed{[ } ]}"><!-- message gets added here via renderMessage --></div>
-                    <div class="chat-msg__media"></div>
-                    <div class="chat-msg__error">{{{o.error}}}</div>
-                {[ } ]}
-            </div>
-            {[ if (o.received && !o.is_me_message && !o.is_groupchat_message) { ]} <span class="fa fa-check chat-msg__receipt"></span> {[ } ]}
-            {[ if (o.edited) { ]} <i title="{{{o.__('This message has been edited')}}}" class="fa fa-edit chat-msg__edit-modal"></i> {[ } ]}
-            <div class="chat-msg__actions">
-                {[ if (o.editable) { ]}
-                    <button class="chat-msg__action chat-msg__action-edit fa fa-pencil-alt" title="{{{o.__('Edit this message')}}}"></button>
-                {[ } ]}
-                {[ if (o.retractable) { ]}
-                    <button class="chat-msg__action chat-msg__action-retract fa fa-trash-alt" title="{{{o.__('Retract this message')}}}"></button>
-                {[ } ]}
-            </div>
-        </div>
-    </div>
-</div>

+ 9 - 0
src/templates/new_day.js

@@ -0,0 +1,9 @@
+import { html } from "lit-html";
+
+
+export default (o) => html`
+    <div class="message date-separator" data-isodate="${o.time}">
+        <hr class="separator"/>
+        <time class="separator-text" datetime="${o.time}"><span>${o.datestring}</span></time>
+    </div>
+`;

+ 77 - 97
src/utils/html.js

@@ -4,7 +4,6 @@
  * @description This is the DOM/HTML utilities module.
  */
 import URI from "urijs";
-import { isFunction } from "lodash";
 import log from '@converse/headless/log';
 import sizzle from "sizzle";
 import tpl_audio from  "../templates/audio.js";
@@ -20,8 +19,10 @@ import tpl_image from "../templates/image.js";
 import tpl_select_option from "../templates/select_option.html";
 import tpl_video from "../templates/video.js";
 import u from "../headless/utils/core";
+import { api } from  "@converse/headless/converse-core";
+import { html } from "lit-html";
+import { isFunction } from "lodash";
 
-const URL_REGEX = /\b(https?\:\/\/|www\.|https?:\/\/www\.)[^\s<>]{2,200}\b\/?/g;
 const APPROVED_URL_PROTOCOLS = ['http', 'https', 'xmpp', 'mailto'];
 
 function getAutoCompleteProperty (name, options) {
@@ -96,7 +97,7 @@ function renderAudioURL (_converse, uri) {
 
 function renderImageURL (_converse, uri) {
     if (!_converse.api.settings.get('show_images_inline')) {
-        return u.convertUriToHyperlink(uri);
+        return u.convertURIoHyperlink(uri);
     }
     const { __ } = _converse;
     return tpl_image({
@@ -179,60 +180,6 @@ function loadImage (url) {
 }
 
 
-async function renderImage (img_url, link_url, el, callback) {
-    if (u.isImageURL(img_url)) {
-        let img;
-        try {
-            img = await loadImage(img_url);
-        } catch (e) {
-            log.error(e);
-            return callback();
-        }
-        sizzle(`a[href="${link_url}"]`, el).forEach(a => {
-            a.innerHTML = "";
-            u.addClass('chat-image__link', a);
-            u.addClass('chat-image', img);
-            u.addClass('img-thumbnail', img);
-            a.insertAdjacentElement('afterBegin', img);
-        });
-    }
-    callback();
-}
-
-
-/**
- * Returns a Promise which resolves once all images have been loaded.
- * @method u#renderImageURLs
- * @param { _converse }
- * @param { HTMLElement }
- * @returns { Promise }
- */
-u.renderImageURLs = function (_converse, el) {
-    if (!_converse.api.settings.get('show_images_inline')) {
-        return Promise.resolve();
-    }
-    const list = el.textContent.match(URL_REGEX) || [];
-    return Promise.all(
-        list.map(url =>
-            new Promise(resolve => {
-                let image_url = getURI(url);
-                if (['imgur.com', 'pbs.twimg.com'].includes(image_url.hostname()) && !u.isImageURL(url)) {
-                    const format = (image_url.hostname() === 'pbs.twimg.com') ? image_url.search(true).format : 'png';
-                    image_url = image_url.removeSearch(/.*/).toString() + `.${format}`;
-                    renderImage(image_url, url, el, resolve);
-                } else {
-                    renderImage(url, url, el, resolve);
-                }
-            })
-        )
-    )
-};
-
-
-u.renderNewLines = function (text) {
-    return text.replace(/\n\n+/g, '<br/><br/>').replace(/\n/g, '<br/>');
-};
-
 u.calculateElementHeight = function (el) {
     /* Return the height of the passed in DOM element,
      * based on the heights of its children.
@@ -364,42 +311,43 @@ u.escapeHTML = function (string) {
         .replace(/"/g, "&quot;");
 };
 
-
-u.addMentionsMarkup = function (text, references, chatbox) {
-    if (chatbox.get('message_type') !== 'groupchat') {
-        return text;
+u.convertToImageTag = async function (url) {
+    const uri = getURI(url);
+    const img_url_without_ext = ['imgur.com', 'pbs.twimg.com'].includes(uri.hostname());
+    let src;
+    if (u.isImageURL(url) || img_url_without_ext) {
+        if (img_url_without_ext) {
+            const format = (uri.hostname() === 'pbs.twimg.com') ? uri.search(true).format : 'png';
+            src = uri.removeSearch(/.*/).toString() + `.${format}`;
+        } else {
+            src = url;
+        }
+        try {
+            await loadImage(src);
+        } catch (e) {
+            log.error(e);
+            return u.convertUrlToHyperlink(url);
+        }
+        return tpl_image({url, src});
     }
-    const nick = chatbox.get('nick');
-    references
-        .sort((a, b) => b.begin - a.begin)
-        .forEach(ref => {
-            const prefix = text.slice(0, ref.begin);
-            const offset = ((prefix.match(/&lt;/g) || []).length + (prefix.match(/&gt;/g) || []).length) * 3;
-            const begin = parseInt(ref.begin, 10) + parseInt(offset, 10);
-            const end = parseInt(ref.end, 10) + parseInt(offset, 10);
-            const mention = text.slice(begin, end)
-            chatbox;
-
-            if (mention === nick) {
-                text = text.slice(0, begin) + `<span class="mention mention--self badge badge-info">${mention}</span>` + text.slice(end);
-            } else {
-                text = text.slice(0, begin) + `<span class="mention">${mention}</span>` + text.slice(end);
-            }
-        });
-    return text;
-};
+}
+
 
-u.convertUriToHyperlink = function (uri, urlAsTyped) {
-    let normalizedUrl = uri.normalize()._string;
-    const pretty_url = uri._parts.urn ? normalizedUrl : uri.readable();
-    const visibleUrl = u.escapeHTML(urlAsTyped || pretty_url);
-    if (!uri._parts.protocol && !normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
-        normalizedUrl = 'http://' + normalizedUrl;
+u.convertURIoHyperlink = function (uri, urlAsTyped) {
+    let normalized_url = uri.normalize()._string;
+    const pretty_url = uri._parts.urn ? normalized_url : uri.readable();
+    const visible_url = urlAsTyped || pretty_url;
+    if (!uri._parts.protocol && !normalized_url.startsWith('http://') && !normalized_url.startsWith('https://')) {
+        normalized_url = 'http://' + normalized_url;
     }
     if (uri._parts.protocol === 'xmpp' && uri._parts.query === 'join') {
-        return `<a target="_blank" rel="noopener" class="open-chatroom" href="${normalizedUrl}">${visibleUrl}</a>`;
+        return html`
+            <a target="_blank"
+               rel="noopener"
+               @click=${ev => api.rooms.open(ev.target.href)}
+               href="${normalized_url}">${visible_url}</a>`;
     }
-    return `<a target="_blank" rel="noopener" href="${normalizedUrl}">${visibleUrl}</a>`;
+    return html`<a target="_blank" rel="noopener" href="${normalized_url}">${visible_url}</a>`;
 };
 
 function isProtocolApproved (protocol, safeProtocolsList = APPROVED_URL_PROTOCOLS) {
@@ -417,27 +365,59 @@ function isUrlValid (urlString) {
 }
 
 u.convertUrlToHyperlink = function (url) {
-    const urlWithProtocol = RegExp('^w{3}.', 'ig').test(url) ? `http://${url}` : url;
+    const http_url = RegExp('^w{3}.', 'ig').test(url) ? `http://${url}` : url;
     const uri = getURI(url);
-    if (uri !== null && isUrlValid(urlWithProtocol) && (isProtocolApproved(uri._parts.protocol) || !uri._parts.protocol)) {
-        const hyperlink = this.convertUriToHyperlink(uri, url);
-        return hyperlink;
+    if (uri !== null && isUrlValid(http_url) && (isProtocolApproved(uri._parts.protocol) || !uri._parts.protocol)) {
+        return this.convertURIoHyperlink(uri, url);
     }
     return url;
 };
 
 u.addHyperlinks = function (text) {
+    const objs = [];
+    const parse_options = { 'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi };
     try {
-        const parse_options = {
-            'start': /\b(?:([a-z][a-z0-9.+-]*:\/\/)|xmpp:|mailto:|www\.)/gi
-        };
-        return URI.withinString(text, url => u.convertUrlToHyperlink(url), parse_options);
+        URI.withinString(text, (url, start, end) => {
+            objs.push({url, start, end})
+            return url;
+        } , parse_options);
     } catch (error) {
         log.debug(error);
-        return text;
+        return [text];
+    }
+
+    const show_images = api.settings.get('show_images_inline');
+
+    let list = [text];
+    if (objs.length) {
+        objs.sort((a, b) => b.start - a.start)
+            .forEach(url_obj => {
+                const text = list.shift();
+                const url_text = text.slice(url_obj.start, url_obj.end);
+                list = [
+                    text.slice(0, url_obj.start),
+                    show_images && u.isImageURL(url_text) ?
+                        u.convertToImageTag(url_text) :
+                        u.convertUrlToHyperlink(url_text),
+                    text.slice(url_obj.end),
+                    ...list
+                ];
+            });
+    } else {
+        list = [text];
     }
+    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) {
+    const replacement = 'geo:$1,$2';
+    return text.replace(_converse.api.settings.get("geouri_regex"), replacement);
+};
 
 u.slideInAllElements = function (elements, duration=300) {
     return Promise.all(Array.from(elements).map(e => u.slideIn(e, duration)));

+ 1 - 0
webpack.prod.js

@@ -20,6 +20,7 @@ module.exports = merge(common, {
         new MiniCssExtractPlugin({filename: '../dist/converse.min.css'}),
         new CopyWebpackPlugin([
             {from: 'sounds'},
+            {from: 'node_modules/@fortawesome/fontawesome-free/sprites/solid.svg', to: '@fortawesome/fontawesome-free/sprites/solid.svg'},
             {from: 'images/favicon.ico', to: 'images/favicon.ico'},
             {from: 'images/custom_emojis', to: 'images/custom_emojis'},
             {from: 'logo/conversejs-filled-192.png', to: 'images/logo'},