浏览代码

Render `converse-chat-content` declaratively

JC Brand 4 年之前
父节点
当前提交
790caf9f5a

+ 1 - 1
spec/chatbox.js

@@ -280,7 +280,7 @@ describe("Chatboxes", function () {
                     keyCode: 13 // Enter
                     keyCode: 13 // Enter
                 };
                 };
                 view.onKeyDown(ev);
                 view.onKeyDown(ev);
-                await new Promise(resolve => view.model.messages.once('rendered', resolve));
+                await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
                 view.onKeyUp(ev);
                 view.onKeyUp(ev);
                 expect(counter.textContent).toBe('200');
                 expect(counter.textContent).toBe('200');
 
 

+ 18 - 15
spec/corrections.js

@@ -27,7 +27,7 @@ describe("A Chat Message", function () {
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
         });
         });
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         expect(view.querySelector('.chat-msg__text').textContent)
         expect(view.querySelector('.chat-msg__text').textContent)
             .toBe('But soft, what light through yonder airlock breaks?');
             .toBe('But soft, what light through yonder airlock breaks?');
@@ -44,14 +44,15 @@ describe("A Chat Message", function () {
         await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
         await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
 
 
         spyOn(_converse.connection, 'send');
         spyOn(_converse.connection, 'send');
-        textarea.value = 'But soft, what light through yonder window breaks?';
+        let new_text = 'But soft, what light through yonder window breaks?';
+        textarea.value = new_text;
         view.onKeyDown({
         view.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
         });
         });
         expect(_converse.connection.send).toHaveBeenCalled();
         expect(_converse.connection.send).toHaveBeenCalled();
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.replace(/<!---->/g, '') === new_text);
 
 
         const msg = _converse.connection.send.calls.all()[0].args[0];
         const msg = _converse.connection.send.calls.all()[0].args[0];
         expect(msg.toLocaleString())
         expect(msg.toLocaleString())
@@ -97,13 +98,15 @@ describe("A Chat Message", function () {
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         await u.waitUntil(() => (u.hasClass('correcting', view.querySelector('.chat-msg')) === false), 500);
         await u.waitUntil(() => (u.hasClass('correcting', view.querySelector('.chat-msg')) === false), 500);
 
 
-        textarea.value = 'It is the east, and Juliet is the one.';
+        new_text = 'It is the east, and Juliet is the one.';
+        textarea.value = new_text;
         view.onKeyDown({
         view.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
         });
         });
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text'))
+            .filter(m => m.textContent.replace(/<!---->/g, '') === new_text).length);
         expect(view.querySelectorAll('.chat-msg').length).toBe(2);
         expect(view.querySelectorAll('.chat-msg').length).toBe(2);
 
 
         textarea.value =  'Arise, fair sun, and kill the envious moon';
         textarea.value =  'Arise, fair sun, and kill the envious moon';
@@ -112,18 +115,17 @@ describe("A Chat Message", function () {
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
         });
         });
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
-        expect(view.querySelectorAll('.chat-msg').length).toBe(3);
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
 
 
         view.onKeyDown({
         view.onKeyDown({
             target: textarea,
             target: textarea,
             keyCode: 38 // Up arrow
             keyCode: 38 // Up arrow
         });
         });
         expect(textarea.value).toBe('Arise, fair sun, and kill the envious moon');
         expect(textarea.value).toBe('Arise, fair sun, and kill the envious moon');
+        await u.waitUntil(() => view.model.messages.at(2).get('correcting') === true);
         expect(view.model.messages.at(0).get('correcting')).toBeFalsy();
         expect(view.model.messages.at(0).get('correcting')).toBeFalsy();
         expect(view.model.messages.at(1).get('correcting')).toBeFalsy();
         expect(view.model.messages.at(1).get('correcting')).toBeFalsy();
-        expect(view.model.messages.at(2).get('correcting')).toBe(true);
-        await u.waitUntil(() => u.hasClass('correcting', sizzle('.chat-msg:last', view.el).pop()), 500);
+        await u.waitUntil(() => u.hasClass('correcting', sizzle('.chat-msg:last', view.el).pop()), 750);
 
 
         textarea.selectionEnd = 0; // Happens by pressing up,
         textarea.selectionEnd = 0; // Happens by pressing up,
                                 // but for some reason not in tests, so we set it manually.
                                 // but for some reason not in tests, so we set it manually.
@@ -143,7 +145,6 @@ describe("A Chat Message", function () {
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
         });
         });
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
         await u.waitUntil(() => textarea.value === '');
         await u.waitUntil(() => textarea.value === '');
         const messages = view.querySelectorAll('.chat-msg');
         const messages = view.querySelectorAll('.chat-msg');
         expect(messages.length).toBe(3);
         expect(messages.length).toBe(3);
@@ -177,12 +178,12 @@ describe("A Chat Message", function () {
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
         });
         });
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
 
 
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         expect(view.querySelector('.chat-msg__text').textContent)
         expect(view.querySelector('.chat-msg__text').textContent)
             .toBe('But soft, what light through yonder airlock breaks?');
             .toBe('But soft, what light through yonder airlock breaks?');
-        expect(textarea.value).toBe('');
+        await u.waitUntil(() => textarea.value === '');
 
 
         const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
         const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
         await u.waitUntil(() => view.querySelectorAll('.chat-msg .chat-msg__action').length === 2);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg .chat-msg__action').length === 2);
@@ -546,14 +547,16 @@ describe("A Groupchat Message", function () {
         await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')));
         await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')));
 
 
         spyOn(_converse.connection, 'send');
         spyOn(_converse.connection, 'send');
-        textarea.value = 'But soft, what light through yonder window breaks?';
+        const new_text = 'But soft, what light through yonder window breaks?'
+        textarea.value = new_text;
         view.onKeyDown({
         view.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
         });
         });
         expect(_converse.connection.send).toHaveBeenCalled();
         expect(_converse.connection.send).toHaveBeenCalled();
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text'))
+            .filter(m => m.textContent.replace(/<!---->/g, '') === new_text).length);
 
 
         const msg = _converse.connection.send.calls.all()[0].args[0];
         const msg = _converse.connection.send.calls.all()[0].args[0];
         expect(msg.toLocaleString())
         expect(msg.toLocaleString())
@@ -586,7 +589,7 @@ describe("A Groupchat Message", function () {
             'to': 'romeo@montague.lit',
             'to': 'romeo@montague.lit',
             'type': 'groupchat'
             'type': 'groupchat'
         }).c('body').t('Hello world').tree());
         }).c('body').t('Hello world').tree());
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
         expect(view.querySelectorAll('.chat-msg').length).toBe(2);
         expect(view.querySelectorAll('.chat-msg').length).toBe(2);
 
 
         // Test that pressing the down arrow cancels message correction
         // Test that pressing the down arrow cancels message correction

+ 9 - 8
spec/emojis.js

@@ -263,7 +263,7 @@ describe("Emojis", function () {
                 .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
                 .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
             await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
             await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
             const view = _converse.api.chatviews.get(sender_jid);
             const view = _converse.api.chatviews.get(sender_jid);
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
             await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.content.querySelector('.chat-msg__text')));
             await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.content.querySelector('.chat-msg__text')));
 
 
             _converse.handleMessageStanza($msg({
             _converse.handleMessageStanza($msg({
@@ -273,7 +273,7 @@ describe("Emojis", function () {
                     'id': _converse.connection.getUniqueId()
                     'id': _converse.connection.getUniqueId()
                 }).c('body').t('😇 Hello world! 😇 😇').up()
                 }).c('body').t('😇 Hello world! 😇 😇').up()
                 .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
                 .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
 
 
             let sel = '.message:last-child .chat-msg__text';
             let sel = '.message:last-child .chat-msg__text';
             await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.content.querySelector(sel)));
             await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.content.querySelector(sel)));
@@ -287,8 +287,7 @@ describe("Emojis", function () {
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13 // Enter
                 keyCode: 13 // Enter
             });
             });
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
-            expect(view.querySelectorAll('.chat-msg').length).toBe(3);
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
             const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
             const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
             await u.waitUntil(() => view.content.querySelector(last_msg_sel).textContent === '💩 😇');
             await u.waitUntil(() => view.content.querySelector(last_msg_sel).textContent === '💩 😇');
 
 
@@ -301,13 +300,15 @@ describe("Emojis", function () {
             expect(view.model.messages.at(2).get('correcting')).toBe(true);
             expect(view.model.messages.at(2).get('correcting')).toBe(true);
             sel = 'converse-chat-message:last-child .chat-msg'
             sel = 'converse-chat-message:last-child .chat-msg'
             await u.waitUntil(() => u.hasClass('correcting', view.querySelector(sel)), 500);
             await u.waitUntil(() => u.hasClass('correcting', view.querySelector(sel)), 500);
-            textarea.value = textarea.value += 'This is no longer an emoji-only message';
+            const edited_text = textarea.value += 'This is no longer an emoji-only message';
+            textarea.value = edited_text;
             view.onKeyDown({
             view.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13 // Enter
                 keyCode: 13 // Enter
             });
             });
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+            await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text'))
+                .filter(el => el.textContent === edited_text).length);
             expect(view.model.messages.models.length).toBe(3);
             expect(view.model.messages.models.length).toBe(3);
             let message = view.content.querySelector(last_msg_sel);
             let message = view.content.querySelector(last_msg_sel);
             expect(u.hasClass('chat-msg__text--larger', message)).toBe(false);
             expect(u.hasClass('chat-msg__text--larger', message)).toBe(false);
@@ -318,7 +319,7 @@ describe("Emojis", function () {
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13 // Enter
                 keyCode: 13 // Enter
             });
             });
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
 
 
             textarea.value = ':smile: :smiley: :imp:';
             textarea.value = ':smile: :smiley: :imp:';
             view.onKeyDown({
             view.onKeyDown({
@@ -326,7 +327,7 @@ describe("Emojis", function () {
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13 // Enter
                 keyCode: 13 // Enter
             });
             });
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 5);
 
 
             message = view.content.querySelector('.message:last-child .chat-msg__text');
             message = view.content.querySelector('.message:last-child .chat-msg__text');
             expect(u.hasClass('chat-msg__text--larger', message)).toBe(true);
             expect(u.hasClass('chat-msg__text--larger', message)).toBe(true);

+ 26 - 33
spec/http-file-upload.js

@@ -237,7 +237,6 @@ describe("XEP-0363: HTTP File Upload", function () {
                         'name': "my-juliet.jpg"
                         'name': "my-juliet.jpg"
                     };
                     };
                     view.model.sendFiles([file]);
                     view.model.sendFiles([file]);
-                    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);
                     await u.waitUntil(() => _.filter(IQ_stanzas, iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length);
                     const iq = IQ_stanzas.pop();
                     const iq = IQ_stanzas.pop();
@@ -270,22 +269,20 @@ describe("XEP-0363: HTTP File Upload", function () {
                         </slot>
                         </slot>
                         </iq>`);
                         </iq>`);
 
 
-                    spyOn(XMLHttpRequest.prototype, 'send').and.callFake(function () {
+                    spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async function () {
                         const message = view.model.messages.at(0);
                         const message = view.model.messages.at(0);
-                        expect(view.querySelector('.chat-content progress').getAttribute('value')).toBe('0');
+                        const el = await u.waitUntil(() => view.querySelector('.chat-content progress'));
+                        expect(el.getAttribute('value')).toBe('0');
                         message.set('progress', 0.5);
                         message.set('progress', 0.5);
-                        u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '0.5')
-                        .then(() => {
-                            message.set('progress', 1);
-                            u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '1')
-                        }).then(() => {
-                            message.save({
-                                'upload': _converse.SUCCESS,
-                                'oob_url': message.get('get'),
-                                'message': message.get('get')
-                            });
-                            return new Promise(resolve => view.model.messages.once('rendered', resolve));
+                        await u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '0.5')
+                        message.set('progress', 1);
+                        await u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '1')
+                        message.save({
+                            'upload': _converse.SUCCESS,
+                            'oob_url': message.get('get'),
+                            'message': message.get('get')
                         });
                         });
+                        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
                     });
                     });
                     let sent_stanza;
                     let sent_stanza;
                     spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza));
                     spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza));
@@ -319,8 +316,7 @@ describe("XEP-0363: HTTP File Upload", function () {
                     done();
                     done();
                 }));
                 }));
 
 
-                it("is uploaded and sent out from a groupchat", mock.initConverse(async (done, _converse) => {
-
+                it("is uploaded and sent out from a groupchat", mock.initConverse(['chatBoxesFetched'], {} ,async (done, _converse) => {
                     const base_url = 'https://conversejs.org';
                     const base_url = 'https://conversejs.org';
                     await mock.waitUntilDiscoConfirmed(
                     await mock.waitUntilDiscoConfirmed(
                         _converse, _converse.domain,
                         _converse, _converse.domain,
@@ -346,7 +342,6 @@ describe("XEP-0363: HTTP File Upload", function () {
                         'name': "my-juliet.jpg"
                         'name': "my-juliet.jpg"
                     };
                     };
                     view.model.sendFiles([file]);
                     view.model.sendFiles([file]);
-                    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);
                     await u.waitUntil(() => _.filter(IQ_stanzas, iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length);
                     const iq = IQ_stanzas.pop();
                     const iq = IQ_stanzas.pop();
@@ -378,22 +373,20 @@ describe("XEP-0363: HTTP File Upload", function () {
                         </slot>
                         </slot>
                         </iq>`);
                         </iq>`);
 
 
-                    spyOn(XMLHttpRequest.prototype, 'send').and.callFake(function () {
+                    spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async function () {
                         const message = view.model.messages.at(0);
                         const message = view.model.messages.at(0);
-                        expect(view.querySelector('.chat-content progress').getAttribute('value')).toBe('0');
+                        const el = await u.waitUntil(() => view.querySelector('.chat-content progress'));
+                        expect(el.getAttribute('value')).toBe('0');
                         message.set('progress', 0.5);
                         message.set('progress', 0.5);
-                        u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '0.5')
-                        .then(() => {
-                            message.set('progress', 1);
-                            u.waitUntil(() => view.querySelector('.chat-content progress')?.getAttribute('value') === '1')
-                        }).then(() => {
-                            message.save({
-                                'upload': _converse.SUCCESS,
-                                'oob_url': message.get('get'),
-                                'message': message.get('get')
-                            });
-                            return new Promise(resolve => view.model.messages.once('rendered', resolve));
+                        await u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '0.5')
+                        message.set('progress', 1);
+                        await u.waitUntil(() => view.querySelector('.chat-content progress')?.getAttribute('value') === '1')
+                        message.save({
+                            'upload': _converse.SUCCESS,
+                            'oob_url': message.get('get'),
+                            'message': message.get('get')
                         });
                         });
+                        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
                     });
                     });
                     let sent_stanza;
                     let sent_stanza;
                     spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza));
                     spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza));
@@ -570,8 +563,7 @@ describe("XEP-0363: HTTP File Upload", function () {
                     'name': "my-juliet.jpg"
                     'name': "my-juliet.jpg"
                 };
                 };
                 view.model.sendFiles([file]);
                 view.model.sendFiles([file]);
-                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)
+                await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length)
                 const iq = IQ_stanzas.pop();
                 const iq = IQ_stanzas.pop();
                 expect(Strophe.serialize(iq)).toBe(
                 expect(Strophe.serialize(iq)).toBe(
                     `<iq from="romeo@montague.lit/orchard" `+
                     `<iq from="romeo@montague.lit/orchard" `+
@@ -604,7 +596,8 @@ describe("XEP-0363: HTTP File Upload", function () {
 
 
                 spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async () => {
                 spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async () => {
                     const message = view.model.messages.at(0);
                     const message = view.model.messages.at(0);
-                    expect(view.querySelector('.chat-content progress').getAttribute('value')).toBe('0');
+                    const el = await u.waitUntil(() => view.querySelector('.chat-content progress'));
+                    expect(el.getAttribute('value')).toBe('0');
                     message.set('progress', 0.5);
                     message.set('progress', 0.5);
                     await u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '0.5');
                     await u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '0.5');
                     message.set('progress', 1);
                     message.set('progress', 1);

+ 4 - 3
spec/mam.js

@@ -64,7 +64,7 @@ describe("Message Archive Management", function () {
                         .c('count').t('16');
                         .c('count').t('16');
             _converse.connection._dataRecv(mock.createRequest(iq_result));
             _converse.connection._dataRecv(mock.createRequest(iq_result));
 
 
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
             expect(view.model.messages.length).toBe(2);
             expect(view.model.messages.length).toBe(2);
 
 
             while (sent_IQs.length) { sent_IQs.pop(); }
             while (sent_IQs.length) { sent_IQs.pop(); }
@@ -379,7 +379,8 @@ describe("Message Archive Management", function () {
                             .c('count').t('16');
                             .c('count').t('16');
                 _converse.connection._dataRecv(mock.createRequest(iq_result));
                 _converse.connection._dataRecv(mock.createRequest(iq_result));
 
 
-                await new Promise(resolve => view.model.messages.once('rendered', resolve));
+                await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text'))
+                    .filter(el => el.textContent === "Thrice the brinded cat hath mew'd.").length, 1000);
                 expect(view.model.messages.length).toBe(1);
                 expect(view.model.messages.length).toBe(1);
                 expect(view.model.messages.at(0).get('message')).toBe("Thrice the brinded cat hath mew'd.");
                 expect(view.model.messages.at(0).get('message')).toBe("Thrice the brinded cat hath mew'd.");
                 done();
                 done();
@@ -433,7 +434,7 @@ describe("Message Archive Management", function () {
                             .c('count').t('16');
                             .c('count').t('16');
                 _converse.connection._dataRecv(mock.createRequest(iq_result));
                 _converse.connection._dataRecv(mock.createRequest(iq_result));
 
 
-                await new Promise(resolve => view.model.messages.once('rendered', resolve));
+                await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
                 expect(view.model.messages.length).toBe(2);
                 expect(view.model.messages.length).toBe(2);
                 expect(view.model.messages.at(0).get('message')).toBe("Meet me at the dance");
                 expect(view.model.messages.at(0).get('message')).toBe("Meet me at the dance");
                 expect(view.model.messages.at(1).get('message')).toBe("Thrice the brinded cat hath mew'd.");
                 expect(view.model.messages.at(1).get('message')).toBe("Thrice the brinded cat hath mew'd.");

+ 3 - 3
spec/markers.js

@@ -91,7 +91,7 @@ describe("A XEP-0333 Chat Marker", function () {
                 <stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/>
                 <stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/>
             </message>`);
             </message>`);
         _converse.connection._dataRecv(mock.createRequest(stanza));
         _converse.connection._dataRecv(mock.createRequest(stanza));
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         expect(view.model.messages.length).toBe(1);
         expect(view.model.messages.length).toBe(1);
 
 
@@ -130,9 +130,9 @@ describe("A XEP-0333 Chat Marker", function () {
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
         });
         });
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
-        expect(view.querySelector('.chat-msg .chat-msg__body').textContent.trim())
+        expect(view.querySelector('.chat-msg .chat-msg__text').textContent.trim())
             .toBe("But soft, what light through yonder airlock breaks?");
             .toBe("But soft, what light through yonder airlock breaks?");
 
 
         const msg_obj = view.model.messages.at(0);
         const msg_obj = view.model.messages.at(0);

+ 3 - 3
spec/me-messages.js

@@ -23,8 +23,8 @@ describe("A Groupchat Message", function () {
             }).c('body').t(message).tree();
             }).c('body').t(message).tree();
         await view.model.handleMessageStanza(msg);
         await view.model.handleMessageStanza(msg);
         await u.waitUntil(() => sizzle('.chat-msg:last .chat-msg__text', view.content).pop());
         await u.waitUntil(() => sizzle('.chat-msg:last .chat-msg__text', view.content).pop());
+        await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.trim() === 'is tired');
         expect(view.querySelector('.chat-msg__author').textContent.includes('**Dyon van de Wege')).toBeTruthy();
         expect(view.querySelector('.chat-msg__author').textContent.includes('**Dyon van de Wege')).toBeTruthy();
-        expect(view.querySelector('.chat-msg__text').textContent.trim()).toBe('is tired');
 
 
         message = '/me is as well';
         message = '/me is as well';
         msg = $msg({
         msg = $msg({
@@ -35,8 +35,8 @@ describe("A Groupchat Message", function () {
         }).c('body').t(message).tree();
         }).c('body').t(message).tree();
         await view.model.handleMessageStanza(msg);
         await view.model.handleMessageStanza(msg);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
+        await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text')).pop().textContent.trim() === 'is as well');
         expect(sizzle('.chat-msg__author:last', view).pop().textContent.includes('**Romeo Montague')).toBeTruthy();
         expect(sizzle('.chat-msg__author:last', view).pop().textContent.includes('**Romeo Montague')).toBeTruthy();
-        expect(sizzle('.chat-msg__text:last', view).pop().textContent.trim()).toBe('is as well');
 
 
         // Check rendering of a mention inside a me message
         // Check rendering of a mention inside a me message
         const msg_text = "/me mentions romeo";
         const msg_text = "/me mentions romeo";
@@ -77,8 +77,8 @@ describe("A Message", function () {
         const view = _converse.chatboxviews.get(sender_jid);
         const view = _converse.chatboxviews.get(sender_jid);
         await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         expect(view.querySelectorAll('.chat-msg--action').length).toBe(1);
         expect(view.querySelectorAll('.chat-msg--action').length).toBe(1);
+        await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.trim() === 'is tired');
         expect(view.querySelector('.chat-msg__author').textContent.includes('**Mercutio')).toBeTruthy();
         expect(view.querySelector('.chat-msg__author').textContent.includes('**Mercutio')).toBeTruthy();
-        expect(view.querySelector('.chat-msg__text').textContent).toBe('is tired');
 
 
         message = '/me is as well';
         message = '/me is as well';
         await mock.sendMessage(view, message);
         await mock.sendMessage(view, message);

+ 11 - 11
spec/mentions.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 /*global mock, converse */
 
 
-const { Promise, Strophe, $msg, $pres } = converse.env;
+const { Strophe, $msg, $pres } = converse.env;
 const u = converse.env.utils;
 const u = converse.env.utils;
 
 
 
 
@@ -22,7 +22,7 @@ describe("An incoming groupchat message", function () {
                 type: 'groupchat'
                 type: 'groupchat'
             }).c('body').t(message).tree();
             }).c('body').t(message).tree();
         await view.model.handleMessageStanza(msg);
         await view.model.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
         expect(u.hasClass('mentioned', view.querySelector('.chat-msg'))).toBeTruthy();
         expect(u.hasClass('mentioned', view.querySelector('.chat-msg'))).toBeTruthy();
         done();
         done();
     }));
     }));
@@ -58,12 +58,12 @@ describe("An incoming groupchat message", function () {
                 .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'11', 'end':'14', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up()
                 .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'11', 'end':'14', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up()
                 .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'15', 'end':'23', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree;
                 .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'15', 'end':'23', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree;
         await view.model.handleMessageStanza(msg);
         await view.model.handleMessageStanza(msg);
-        let message = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
-        expect(message.classList.length).toEqual(1);
-        expect(message.innerHTML.replace(/<!---->/g, '')).toBe(
+        await u.waitUntil(() => view.querySelector('.chat-msg__text')?.innerHTML.replace(/<!---->/g, '') ===
             'hello <span class="mention">z3r0</span> '+
             'hello <span class="mention">z3r0</span> '+
             '<span class="mention mention--self badge badge-info">tom</span> '+
             '<span class="mention mention--self badge badge-info">tom</span> '+
             '<span class="mention">mr.robot</span>, how are you?');
             '<span class="mention">mr.robot</span>, how are you?');
+        let message = view.querySelector('.chat-msg__text')
+        expect(message.classList.length).toEqual(1);
 
 
         msg = $msg({
         msg = $msg({
                 from: 'lounge@montague.lit/sw0rdf1sh',
                 from: 'lounge@montague.lit/sw0rdf1sh',
@@ -113,10 +113,10 @@ describe("An incoming groupchat message", function () {
                 .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'16', 'end':'24', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree;
                 .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'16', 'end':'24', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree;
 
 
         await view.model.handleMessageStanza(msg);
         await view.model.handleMessageStanza(msg);
-        const message = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
-        expect(message.classList.length).toEqual(1);
-        expect(message.innerHTML.replace(/<!---->/g, '')).toBe(
+        await u.waitUntil(() => view.querySelector('.chat-msg__text')?.innerHTML.replace(/<!---->/g, '') ===
             '<blockquote>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?</blockquote>');
             '<blockquote>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?</blockquote>');
+        const message = view.querySelector('.chat-msg__text');
+        expect(message.classList.length).toEqual(1);
         done();
         done();
     }));
     }));
 });
 });
@@ -316,7 +316,7 @@ describe("A sent groupchat message", function () {
             }
             }
             spyOn(_converse.connection, 'send');
             spyOn(_converse.connection, 'send');
             view.onKeyDown(enter_event);
             view.onKeyDown(enter_event);
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
             const msg = _converse.connection.send.calls.all()[1].args[0];
             const msg = _converse.connection.send.calls.all()[1].args[0];
             expect(msg.toLocaleString())
             expect(msg.toLocaleString())
                 .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
                 .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
@@ -375,7 +375,7 @@ describe("A sent groupchat message", function () {
             }
             }
             spyOn(_converse.connection, 'send');
             spyOn(_converse.connection, 'send');
             view.onKeyDown(enter_event);
             view.onKeyDown(enter_event);
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
 
 
             const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
             const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
             await u.waitUntil(() =>
             await u.waitUntil(() =>
@@ -458,7 +458,7 @@ describe("A sent groupchat message", function () {
                 'keyCode': 13 // Enter
                 'keyCode': 13 // Enter
             }
             }
             view.onKeyDown(enter_event);
             view.onKeyDown(enter_event);
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
 
 
             const msg = _converse.connection.send.calls.all()[1].args[0];
             const msg = _converse.connection.send.calls.all()[1].args[0];
             expect(msg.toLocaleString())
             expect(msg.toLocaleString())

+ 43 - 46
spec/messages.js

@@ -111,7 +111,7 @@ describe("A Chat Message", function () {
             .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2017-12-31T22:08:25Z'})
             .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2017-12-31T22:08:25Z'})
             .tree();
             .tree();
         _converse.handleMessageStanza(msg);
         _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
 
 
         msg = $msg({
         msg = $msg({
                 'xmlns': 'jabber:client',
                 'xmlns': 'jabber:client',
@@ -123,7 +123,7 @@ describe("A Chat Message", function () {
             .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'})
             .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'})
             .tree();
             .tree();
         _converse.handleMessageStanza(msg);
         _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 3);
 
 
         msg = $msg({
         msg = $msg({
                 'xmlns': 'jabber:client',
                 'xmlns': 'jabber:client',
@@ -135,7 +135,7 @@ describe("A Chat Message", function () {
             .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'})
             .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'})
             .tree();
             .tree();
         _converse.handleMessageStanza(msg);
         _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 4);
 
 
         msg = $msg({
         msg = $msg({
                 'xmlns': 'jabber:client',
                 'xmlns': 'jabber:client',
@@ -147,7 +147,7 @@ describe("A Chat Message", function () {
             .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T12:18:23Z'})
             .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T12:18:23Z'})
             .tree();
             .tree();
         _converse.handleMessageStanza(msg);
         _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 5);
 
 
         msg = $msg({
         msg = $msg({
                 'xmlns': 'jabber:client',
                 'xmlns': 'jabber:client',
@@ -159,7 +159,7 @@ describe("A Chat Message", function () {
             .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T22:28:23Z'})
             .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T22:28:23Z'})
             .tree();
             .tree();
         _converse.handleMessageStanza(msg);
         _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 6);
 
 
         // Insert <composing> message, to also check that
         // Insert <composing> message, to also check that
         // text messages are inserted correctly with
         // text messages are inserted correctly with
@@ -185,8 +185,9 @@ describe("A Chat Message", function () {
             .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
             .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
             .c('body').t("latest message")
             .c('body').t("latest message")
             .tree();
             .tree();
+
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 7);
 
 
         view.clearSpinner(); //cleanup
         view.clearSpinner(); //cleanup
         expect(view.content.querySelectorAll('.date-separator').length).toEqual(4);
         expect(view.content.querySelectorAll('.date-separator').length).toEqual(4);
@@ -435,7 +436,7 @@ describe("A Chat Message", function () {
         .c('delay', { xmlns:'urn:xmpp:delay', from: 'montague.lit', stamp: one_day_ago.toISOString() })
         .c('delay', { xmlns:'urn:xmpp:delay', from: 'montague.lit', stamp: one_day_ago.toISOString() })
         .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
         .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
 
 
         expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
         expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
         expect(chatbox.messages.length).toEqual(1);
         expect(chatbox.messages.length).toEqual(1);
@@ -446,16 +447,16 @@ describe("A Chat Message", function () {
         expect(msg_obj.get('sender')).toEqual('them');
         expect(msg_obj.get('sender')).toEqual('them');
         expect(msg_obj.get('is_delayed')).toEqual(true);
         expect(msg_obj.get('is_delayed')).toEqual(true);
         await u.waitUntil(() => chatbox.vcard.get('fullname') === 'Juliet Capulet')
         await u.waitUntil(() => chatbox.vcard.get('fullname') === 'Juliet Capulet')
-        expect(view.msgs_container.querySelector('.chat-msg .chat-msg__text').textContent).toEqual(message);
-        expect(view.msgs_container.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
-        expect(view.msgs_container.querySelector('span.chat-msg__author').textContent.trim()).toBe('Juliet Capulet');
+        expect(view.querySelector('.chat-msg .chat-msg__text').textContent).toEqual(message);
+        expect(view.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
+        expect(view.querySelector('span.chat-msg__author').textContent.trim()).toBe('Juliet Capulet');
 
 
-        expect(view.msgs_container.querySelectorAll('.date-separator').length).toEqual(1);
-        let day = view.msgs_container.querySelector('.date-separator');
+        expect(view.querySelectorAll('.date-separator').length).toEqual(1);
+        let day = view.querySelector('.date-separator');
         expect(day.getAttribute('class')).toEqual('message date-separator');
         expect(day.getAttribute('class')).toEqual('message date-separator');
         expect(day.getAttribute('data-isodate')).toEqual(dayjs(one_day_ago.startOf('day')).toISOString());
         expect(day.getAttribute('data-isodate')).toEqual(dayjs(one_day_ago.startOf('day')).toISOString());
 
 
-        let time = view.msgs_container.querySelector('time.separator-text');
+        let time = view.querySelector('time.separator-text');
         expect(time.textContent).toEqual(dayjs(one_day_ago.startOf('day')).format("dddd MMM Do YYYY"));
         expect(time.textContent).toEqual(dayjs(one_day_ago.startOf('day')).format("dddd MMM Do YYYY"));
 
 
         message = 'This is a current message';
         message = 'This is a current message';
@@ -467,19 +468,19 @@ describe("A Chat Message", function () {
         }).c('body').t(message).up()
         }).c('body').t(message).up()
         .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
         .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
 
 
         expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
         expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
         // Check that there is a <time> element, with the required props.
         // Check that there is a <time> element, with the required props.
-        expect(view.msgs_container.querySelectorAll('time.separator-text').length).toEqual(2); // There are now two time elements
+        expect(view.querySelectorAll('time.separator-text').length).toEqual(2); // There are now two time elements
 
 
         const message_date = new Date();
         const message_date = new Date();
-        day = sizzle('.date-separator:last', view.msgs_container);
+        day = sizzle('.date-separator:last', view);
         expect(day.length).toEqual(1);
         expect(day.length).toEqual(1);
         expect(day[0].getAttribute('class')).toEqual('message date-separator');
         expect(day[0].getAttribute('class')).toEqual('message date-separator');
         expect(day[0].getAttribute('data-isodate')).toEqual(dayjs(message_date).startOf('day').toISOString());
         expect(day[0].getAttribute('data-isodate')).toEqual(dayjs(message_date).startOf('day').toISOString());
 
 
-        time = sizzle('time.separator-text:last', view.msgs_container).pop();
+        time = sizzle('time.separator-text:last', view).pop();
         expect(time.textContent).toEqual(dayjs(message_date).startOf('day').format("dddd MMM Do YYYY"));
         expect(time.textContent).toEqual(dayjs(message_date).startOf('day').format("dddd MMM Do YYYY"));
 
 
         // Normal checks for the 2nd message
         // Normal checks for the 2nd message
@@ -489,12 +490,12 @@ describe("A Chat Message", function () {
         expect(msg_obj.get('fullname')).toBeUndefined();
         expect(msg_obj.get('fullname')).toBeUndefined();
         expect(msg_obj.get('sender')).toEqual('them');
         expect(msg_obj.get('sender')).toEqual('them');
         expect(msg_obj.get('is_delayed')).toEqual(false);
         expect(msg_obj.get('is_delayed')).toEqual(false);
-        const msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.msgs_container).pop().textContent;
+        const msg_txt = sizzle('.chat-msg:last .chat-msg__text', view).pop().textContent;
         expect(msg_txt).toEqual(message);
         expect(msg_txt).toEqual(message);
 
 
-        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');
+        expect(view.querySelector('converse-chat-message:last-child .chat-msg__text').textContent).toEqual(message);
+        expect(view.querySelector('converse-chat-message:last-child .chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
+        expect(view.querySelector('converse-chat-message:last-child .chat-msg__author').textContent.trim()).toBe('Juliet Capulet');
         done();
         done();
     }));
     }));
 
 
@@ -528,7 +529,7 @@ describe("A Chat Message", function () {
         spyOn(view.model, 'sendMessage').and.callThrough();
         spyOn(view.model, 'sendMessage').and.callThrough();
         mock.sendMessage(view, message);
         mock.sendMessage(view, message);
         expect(view.model.sendMessage).toHaveBeenCalled();
         expect(view.model.sendMessage).toHaveBeenCalled();
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
         const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
         const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
         expect(msg.textContent).toEqual(message);
         expect(msg.textContent).toEqual(message);
         await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') ===
@@ -547,9 +548,8 @@ describe("A Chat Message", function () {
         const view = _converse.api.chatviews.get(contact_jid);
         const view = _converse.api.chatviews.get(contact_jid);
         let message = 'This message contains a hyperlink with forbidden query params: https://www.opkode.com/?id=0&utm_content=1&utm_medium=2&s=1';
         let message = 'This message contains a hyperlink with forbidden query params: https://www.opkode.com/?id=0&utm_content=1&utm_medium=2&s=1';
         mock.sendMessage(view, message);
         mock.sendMessage(view, message);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
         let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
         let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-        expect(msg.textContent).toEqual(message);
         await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') ===
             'This message contains a hyperlink with forbidden query params: <a target="_blank" rel="noopener" href="https://www.opkode.com/?id=0">https://www.opkode.com/?id=0</a>');
             'This message contains a hyperlink with forbidden query params: <a target="_blank" rel="noopener" href="https://www.opkode.com/?id=0">https://www.opkode.com/?id=0</a>');
 
 
@@ -557,9 +557,9 @@ describe("A Chat Message", function () {
         _converse.api.settings.set('filter_url_query_params', 'utm_medium');
         _converse.api.settings.set('filter_url_query_params', 'utm_medium');
         message = 'Another message with a hyperlink with forbidden query params: https://www.opkode.com/?id=0&utm_content=1&utm_medium=2&s=1';
         message = 'Another message with a hyperlink with forbidden query params: https://www.opkode.com/?id=0&utm_content=1&utm_medium=2&s=1';
         mock.sendMessage(view, message);
         mock.sendMessage(view, message);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
         msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
         msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view.el).pop();
-        expect(msg.textContent).toEqual(message);
+        expect(msg.textContent).toEqual('Another message with a hyperlink with forbidden query params: https://www.opkode.com/?id=0&utm_content=1&s=1');
         await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg.innerHTML.replace(/<!---->/g, '') ===
             'Another message with a hyperlink with forbidden query params: '+
             'Another message with a hyperlink with forbidden query params: '+
             '<a target="_blank" rel="noopener" href="https://www.opkode.com/?id=0&amp;utm_content=1&amp;s=1">https://www.opkode.com/?id=0&amp;utm_content=1&amp;s=1</a>');
             '<a target="_blank" rel="noopener" href="https://www.opkode.com/?id=0&amp;utm_content=1&amp;s=1">https://www.opkode.com/?id=0&amp;utm_content=1&amp;s=1</a>');
@@ -577,7 +577,7 @@ describe("A Chat Message", function () {
                 <body>Hey\nHave you heard the news?</body>
                 <body>Hey\nHave you heard the news?</body>
             </message>`);
             </message>`);
         _converse.connection._dataRecv(mock.createRequest(stanza));
         _converse.connection._dataRecv(mock.createRequest(stanza));
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
         expect(view.content.querySelector('.chat-msg__text').innerHTML.replace(/<!---->/g, '')).toBe('Hey\nHave you heard the news?');
         expect(view.content.querySelector('.chat-msg__text').innerHTML.replace(/<!---->/g, '')).toBe('Hey\nHave you heard the news?');
         stanza = u.toStanza(`
         stanza = u.toStanza(`
             <message from="${contact_jid}"
             <message from="${contact_jid}"
@@ -586,8 +586,9 @@ describe("A Chat Message", function () {
                 <body>Hey\n\n\nHave you heard the news?</body>
                 <body>Hey\n\n\nHave you heard the news?</body>
             </message>`);
             </message>`);
         _converse.connection._dataRecv(mock.createRequest(stanza));
         _converse.connection._dataRecv(mock.createRequest(stanza));
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
-        await u.waitUntil(() => view.content.querySelector('converse-chat-message:last-child .chat-msg__text').innerHTML.replace(/<!---->/g, '') === 'Hey\n\n\nHave you heard the news?');
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+        const text = view.content.querySelector('converse-chat-message:last-child .chat-msg__text').innerHTML.replace(/<!---->/g, '');
+        expect(text).toBe('Hey\n\u200B\nHave you heard the news?');
         stanza = u.toStanza(`
         stanza = u.toStanza(`
             <message from="${contact_jid}"
             <message from="${contact_jid}"
                      type="chat"
                      type="chat"
@@ -595,7 +596,7 @@ describe("A Chat Message", function () {
                 <body>Hey\nHave you heard\nthe news?</body>
                 <body>Hey\nHave you heard\nthe news?</body>
             </message>`);
             </message>`);
         _converse.connection._dataRecv(mock.createRequest(stanza));
         _converse.connection._dataRecv(mock.createRequest(stanza));
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
         expect(view.content.querySelector('converse-chat-message:last-child .chat-msg__text').innerHTML.replace(/<!---->/g, '')).toBe('Hey\nHave you heard\nthe news?');
         expect(view.content.querySelector('converse-chat-message:last-child .chat-msg__text').innerHTML.replace(/<!---->/g, '')).toBe('Hey\nHave you heard\nthe news?');
 
 
         stanza = u.toStanza(`
         stanza = u.toStanza(`
@@ -605,7 +606,7 @@ describe("A Chat Message", function () {
                 <body>Hey\nHave you heard\n\n\nthe news?\nhttps://conversejs.org</body>
                 <body>Hey\nHave you heard\n\n\nthe news?\nhttps://conversejs.org</body>
             </message>`);
             </message>`);
         _converse.connection._dataRecv(mock.createRequest(stanza));
         _converse.connection._dataRecv(mock.createRequest(stanza));
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
         await u.waitUntil(() => {
         await u.waitUntil(() => {
             const text = view.content.querySelector('converse-chat-message:last-child .chat-msg__text').innerHTML.replace(/<!---->/g, '');
             const text = view.content.querySelector('converse-chat-message:last-child .chat-msg__text').innerHTML.replace(/<!---->/g, '');
             return text === 'Hey\nHave you heard\n\u200B\nthe news?\n<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>';
             return text === 'Hey\nHave you heard\n\u200B\nthe news?\n<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>';
@@ -1067,7 +1068,7 @@ describe("A Chat Message", function () {
 
 
                 await _converse.handleMessageStanza(msg);
                 await _converse.handleMessageStanza(msg);
                 const view = await u.waitUntil(() => _converse.api.chatviews.get(sender_jid));
                 const view = await u.waitUntil(() => _converse.api.chatviews.get(sender_jid));
-                await new Promise(resolve => view.model.messages.once('rendered', resolve));
+                await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
                 expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
                 expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
 
 
                 // Check that the chatbox and its view now exist
                 // Check that the chatbox and its view now exist
@@ -1081,7 +1082,7 @@ describe("A Chat Message", function () {
                 expect(_converse.api.vcard.get).toHaveBeenCalled();
                 expect(_converse.api.vcard.get).toHaveBeenCalled();
                 await u.waitUntil(() => chatbox.vcard.get('fullname') === mock.cur_names[0])
                 await u.waitUntil(() => chatbox.vcard.get('fullname') === mock.cur_names[0])
                 author_el = view.querySelector('.chat-msg__author');
                 author_el = view.querySelector('.chat-msg__author');
-                expect( _.includes(author_el.textContent.trim(), 'Mercutio')).toBeTruthy();
+                expect(author_el.textContent.trim().includes('Mercutio')).toBeTruthy();
                 done();
                 done();
             }));
             }));
         });
         });
@@ -1119,7 +1120,7 @@ describe("A Chat Message", function () {
                 _converse.allow_non_roster_messaging = true;
                 _converse.allow_non_roster_messaging = true;
                 await _converse.handleMessageStanza(msg);
                 await _converse.handleMessageStanza(msg);
                 view = _converse.chatboxviews.get(sender_jid);
                 view = _converse.chatboxviews.get(sender_jid);
-                await new Promise(resolve => view.model.messages.once('rendered', resolve));
+                await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
                 expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
                 expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
                 // Check that the chatbox and its view now exist
                 // Check that the chatbox and its view now exist
                 chatbox = await _converse.api.chats.get(sender_jid);
                 chatbox = await _converse.api.chats.get(sender_jid);
@@ -1170,7 +1171,7 @@ describe("A Chat Message", function () {
                 let msg_text = 'This message will not be sent, due to an error';
                 let msg_text = 'This message will not be sent, due to an error';
                 const view = _converse.api.chatviews.get(sender_jid);
                 const view = _converse.api.chatviews.get(sender_jid);
                 const message = await view.model.sendMessage(msg_text);
                 const message = await view.model.sendMessage(msg_text);
-                await new Promise(resolve => view.model.messages.once('rendered', resolve));
+                await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
                 let msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.content).pop().textContent;
                 let msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.content).pop().textContent;
                 expect(msg_txt).toEqual(msg_text);
                 expect(msg_txt).toEqual(msg_text);
 
 
@@ -1253,18 +1254,14 @@ describe("A Chat Message", function () {
                         .t('Something else went wrong as well');
                         .t('Something else went wrong as well');
                 _converse.connection._dataRecv(mock.createRequest(stanza));
                 _converse.connection._dataRecv(mock.createRequest(stanza));
                 await u.waitUntil(() => view.model.messages.length > 2);
                 await u.waitUntil(() => view.model.messages.length > 2);
-                await new Promise(resolve => view.model.messages.once('rendered', resolve));
-                expect(view.content.querySelectorAll('.chat-msg__error').length).toEqual(3);
+                await u.waitUntil(() => view.querySelectorAll('.chat-msg__error').length === 3);
 
 
-                // Ensure messages with error are not editable
-                document.querySelectorAll('.chat-msg__actions').forEach(elem => {
-                    expect(elem.querySelector('.chat-msg__action-edit')).toBe(null)
+                // Ensure messages with error are not editable or retractable
+                await u.waitUntil(() => !view.model.messages.models.reduce((acc, m) => acc || m.get('editable'), false), 1000);
+                view.querySelectorAll('.chat-msg').forEach(el => {
+                    expect(el.querySelector('.chat-msg__action-edit')).toBe(null)
+                    expect(el.querySelector('.chat-msg__action-retract')).toBe(null)
                 })
                 })
-                view.model.messages.forEach(message => {
-                    const isEditable = message.get('editable');
-                    isEditable && expect(isEditable).toBe(false);
-                })
-
                 done();
                 done();
             }));
             }));
 
 
@@ -1302,7 +1299,7 @@ describe("A Chat Message", function () {
                 const view = _converse.chatboxviews.get(contact_jid);
                 const view = _converse.chatboxviews.get(contact_jid);
                 const msg_text = 'This message will show!';
                 const msg_text = 'This message will show!';
                 await view.model.sendMessage(msg_text);
                 await view.model.sendMessage(msg_text);
-                await new Promise(resolve => view.model.messages.once('rendered', resolve));
+                await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
                 expect(view.content.querySelectorAll('.chat-error').length).toEqual(0);
                 expect(view.content.querySelectorAll('.chat-error').length).toEqual(0);
                 done();
                 done();
             }));
             }));

+ 12 - 11
spec/muc.js

@@ -664,7 +664,7 @@ describe("Groupchats", function () {
                         <body>This is a message</body>
                         <body>This is a message</body>
                     </message>`);
                     </message>`);
                 _converse.connection._dataRecv(mock.createRequest(stanza));
                 _converse.connection._dataRecv(mock.createRequest(stanza));
-                await new Promise(resolve => view.model.messages.once('rendered', resolve));
+                await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
                 expect(sizzle('.chat-msg__subject', view).length).toBe(1);
                 expect(sizzle('.chat-msg__subject', view).length).toBe(1);
                 expect(sizzle('.chat-msg__subject', view).pop().textContent.trim()).toBe('This is a message subject');
                 expect(sizzle('.chat-msg__subject', view).pop().textContent.trim()).toBe('This is a message subject');
                 expect(sizzle('.chat-msg__text').length).toBe(1);
                 expect(sizzle('.chat-msg__text').length).toBe(1);
@@ -695,7 +695,7 @@ describe("Groupchats", function () {
                         <body>This is a message</body>
                         <body>This is a message</body>
                     </message>`);
                     </message>`);
                 _converse.connection._dataRecv(mock.createRequest(stanza));
                 _converse.connection._dataRecv(mock.createRequest(stanza));
-                await new Promise(resolve => view.model.messages.once('rendered', resolve));
+                await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
                 expect(sizzle('.chat-msg__subject', view).length).toBe(1);
                 expect(sizzle('.chat-msg__subject', view).length).toBe(1);
                 expect(sizzle('.chat-msg__subject', view).pop().textContent.trim()).toBe('This is a message subject');
                 expect(sizzle('.chat-msg__subject', view).pop().textContent.trim()).toBe('This is a message subject');
                 expect(sizzle('.chat-msg__text').length).toBe(1);
                 expect(sizzle('.chat-msg__text').length).toBe(1);
@@ -1009,7 +1009,7 @@ describe("Groupchats", function () {
                 'type': 'groupchat'
                 'type': 'groupchat'
             }).c('body').t('hello world').tree();
             }).c('body').t('hello world').tree();
             _converse.connection._dataRecv(mock.createRequest(msg));
             _converse.connection._dataRecv(mock.createRequest(msg));
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
 
 
             // Add another entrant, otherwise the above message will be
             // Add another entrant, otherwise the above message will be
             // collapsed if "newguy" leaves immediately again
             // collapsed if "newguy" leaves immediately again
@@ -2112,7 +2112,7 @@ describe("Groupchats", function () {
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13
                 keyCode: 13
             });
             });
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
 
 
             expect(_converse.api.trigger).toHaveBeenCalledWith('messageSend', jasmine.any(_converse.Message));
             expect(_converse.api.trigger).toHaveBeenCalledWith('messageSend', jasmine.any(_converse.Message));
             expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
             expect(view.content.querySelectorAll('.chat-msg').length).toBe(1);
@@ -2170,7 +2170,7 @@ describe("Groupchats", function () {
                         type: 'groupchat',
                         type: 'groupchat',
                         id: u.getUniqueId(),
                         id: u.getUniqueId(),
                     }).c('body').t(message).tree());
                     }).c('body').t(message).tree());
-                await new Promise(resolve => view.model.messages.once('rendered', resolve));
+                await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 21);
                 // Now check that the message appears inside the chatbox in the DOM
                 // 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;
                 const msg_txt = sizzle('.chat-msg:last .chat-msg__text', view.content).pop().textContent;
                 expect(msg_txt).toEqual(message);
                 expect(msg_txt).toEqual(message);
@@ -2858,8 +2858,8 @@ describe("Groupchats", function () {
                         'role': 'participant'
                         'role': 'participant'
                     });
                     });
             _converse.connection._dataRecv(mock.createRequest(presence));
             _converse.connection._dataRecv(mock.createRequest(presence));
-            const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
-            expect(csntext.trim()).toEqual("romeo and annoyingGuy have entered the groupchat");
+            await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+                "romeo and annoyingGuy have entered the groupchat");
 
 
             presence = $pres({
             presence = $pres({
                     'from': 'lounge@montague.lit/annoyingGuy',
                     'from': 'lounge@montague.lit/annoyingGuy',
@@ -2906,6 +2906,7 @@ describe("Groupchats", function () {
                 Array.from(view.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
                 Array.from(view.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() ===
                 "annoyingGuy is no longer a member of this groupchat"
                 "annoyingGuy is no longer a member of this groupchat"
             );
             );
+            expect(1).toBe(1);
             done();
             done();
         }));
         }));
 
 
@@ -3614,8 +3615,8 @@ describe("Groupchats", function () {
                         'role': 'participant'
                         'role': 'participant'
                     });
                     });
             _converse.connection._dataRecv(mock.createRequest(presence));
             _converse.connection._dataRecv(mock.createRequest(presence));
-            const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
-            expect(csntext.trim()).toEqual("romeo and trustworthyguy have entered the groupchat");
+            await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+                "romeo and trustworthyguy have entered the groupchat");
 
 
             const textarea = view.querySelector('.chat-textarea')
             const textarea = view.querySelector('.chat-textarea')
             textarea.value = '/op';
             textarea.value = '/op';
@@ -3753,8 +3754,8 @@ describe("Groupchats", function () {
                         'role': 'participant'
                         'role': 'participant'
                     });
                     });
             _converse.connection._dataRecv(mock.createRequest(presence));
             _converse.connection._dataRecv(mock.createRequest(presence));
-            const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
-            expect(csntext.trim()).toEqual("romeo and annoyingGuy have entered the groupchat");
+            await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() ===
+                "romeo and annoyingGuy have entered the groupchat");
 
 
             const textarea = view.querySelector('.chat-textarea')
             const textarea = view.querySelector('.chat-textarea')
             textarea.value = '/mute';
             textarea.value = '/mute';

+ 4 - 5
spec/muc_messages.js

@@ -174,8 +174,8 @@ describe("A Groupchat Message", function () {
               .c('active', {'xmlns': "http://jabber.org/protocol/chatstates"})
               .c('active', {'xmlns': "http://jabber.org/protocol/chatstates"})
               .tree();
               .tree();
         await view.model.handleMessageStanza(msg);
         await view.model.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
-        expect(view.querySelector('.chat-msg')).not.toBe(null);
+        const el = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
+        expect(el.textContent).toBe(message);
         done();
         done();
     }));
     }));
 
 
@@ -399,10 +399,9 @@ describe("A Groupchat Message", function () {
             type: 'groupchat'
             type: 'groupchat'
         }).c('body').t('Another message!').tree();
         }).c('body').t('Another message!').tree();
         await view.model.handleMessageStanza(msg);
         await view.model.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
         expect(view.model.messages.last().occupant.get('affiliation')).toBe('member');
         expect(view.model.messages.last().occupant.get('affiliation')).toBe('member');
         expect(view.model.messages.last().occupant.get('role')).toBe('participant');
         expect(view.model.messages.last().occupant.get('role')).toBe('participant');
-        expect(view.querySelectorAll('.chat-msg').length).toBe(2);
         expect(sizzle('.chat-msg', view.el).pop().classList.value.trim()).toBe('message chat-msg groupchat chat-msg--with-avatar participant member');
         expect(sizzle('.chat-msg', view.el).pop().classList.value.trim()).toBe('message chat-msg groupchat chat-msg--with-avatar participant member');
 
 
         presence = $pres({
         presence = $pres({
@@ -436,7 +435,7 @@ describe("A Groupchat Message", function () {
             type: 'groupchat'
             type: 'groupchat'
         }).c('body').t('Message from someone not in the MUC right now').tree();
         }).c('body').t('Message from someone not in the MUC right now').tree();
         await view.model.handleMessageStanza(msg);
         await view.model.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 4);
         expect(view.model.messages.last().occupant).toBeUndefined();
         expect(view.model.messages.last().occupant).toBeUndefined();
         // Check that there's a new "add" event handler, for when the occupant appears.
         // 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);
         expect(view.model.occupants._events.add.length).toBe(add_events+1);

+ 1 - 1
spec/retractions.js

@@ -790,7 +790,7 @@ describe("Message Retractions", function () {
             expect(view.model.messages.length).toBe(1);
             expect(view.model.messages.length).toBe(1);
             expect(view.model.messages.at(0).get('editable')).toBe(true);
             expect(view.model.messages.at(0).get('editable')).toBe(true);
 
 
-            const retract_button = await u.waitUntil(() => view.msgs_container.querySelector('.chat-msg__content .chat-msg__action-retract'));
+            const retract_button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-retract'));
             retract_button.click();
             retract_button.click();
             await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
             await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal')));
             const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');
             const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]');

+ 27 - 27
spec/styling.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 /*global mock, converse */
 
 
-const { u, Promise, $msg } = converse.env;
+const { u, $msg } = converse.env;
 
 
 describe("An incoming chat Message", function () {
 describe("An incoming chat Message", function () {
 
 
@@ -79,7 +79,7 @@ describe("An incoming chat Message", function () {
         msg_text = "This *message _contains_* styling hints! \`Here's *some* code\`";
         msg_text = "This *message _contains_* styling hints! \`Here's *some* code\`";
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
         msg_el = view.querySelector('converse-chat-message-body');
         msg_el = view.querySelector('converse-chat-message-body');
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -92,8 +92,8 @@ describe("An incoming chat Message", function () {
 
 
         msg_text = "Here's a ~strikethrough section~";
         msg_text = "Here's a ~strikethrough section~";
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
-                await _converse.handleMessageStanza(msg);
-                await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await _converse.handleMessageStanza(msg);
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -103,7 +103,7 @@ describe("An incoming chat Message", function () {
         msg_text = "~Check out this site: https://conversejs.org~"
         msg_text = "~Check out this site: https://conversejs.org~"
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -116,7 +116,7 @@ describe("An incoming chat Message", function () {
         msg_text = `*${base_url}/logo/conversejs-filled.svg*`;
         msg_text = `*${base_url}/logo/conversejs-filled.svg*`;
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -128,7 +128,7 @@ describe("An incoming chat Message", function () {
         msg_text = `~ Hello! :poop: ~`;
         msg_text = `~ Hello! :poop: ~`;
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 5);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -138,7 +138,7 @@ describe("An incoming chat Message", function () {
         msg_text = "This *is not a styling hint \n * _But this is_!";
         msg_text = "This *is not a styling hint \n * _But this is_!";
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 6);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -148,7 +148,7 @@ describe("An incoming chat Message", function () {
         msg_text = `(There are three blocks in this body marked by parens,)\n (but there is no *formatting)\n (as spans* may not escape blocks.)\n ~strikethrough~`;
         msg_text = `(There are three blocks in this body marked by parens,)\n (but there is no *formatting)\n (as spans* may not escape blocks.)\n ~strikethrough~`;
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 7);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -161,7 +161,7 @@ describe("An incoming chat Message", function () {
         msg_text = `__ hello world _`;
         msg_text = `__ hello world _`;
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 8);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -171,7 +171,7 @@ describe("An incoming chat Message", function () {
         msg_text = `Go to ~https://conversejs.org~now _please_`;
         msg_text = `Go to ~https://conversejs.org~now _please_`;
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 9);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -180,7 +180,7 @@ describe("An incoming chat Message", function () {
         msg_text = `Go to _https://converse_js.org_ _please_`;
         msg_text = `Go to _https://converse_js.org_ _please_`;
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 10);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -204,7 +204,7 @@ describe("An incoming chat Message", function () {
         msg_text = `Here's a code block: \n\`\`\`\nInside the code-block, <code>hello</code> we don't enable *styling hints* like ~these~\n\`\`\``;
         msg_text = `Here's a code block: \n\`\`\`\nInside the code-block, <code>hello</code> we don't enable *styling hints* like ~these~\n\`\`\``;
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -216,7 +216,7 @@ describe("An incoming chat Message", function () {
         msg_text = "```\nignored\n(println \"Hello, world!\")\n```\nThis should show up as monospace, preformatted text ^";
         msg_text = "```\nignored\n(println \"Hello, world!\")\n```\nThis should show up as monospace, preformatted text ^";
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -229,7 +229,7 @@ describe("An incoming chat Message", function () {
         msg_text = "```ignored\n (println \"Hello, world!\")\n ```\n\n This should not show up as monospace, *preformatted* text ^";
         msg_text = "```ignored\n (println \"Hello, world!\")\n ```\n\n This should not show up as monospace, *preformatted* text ^";
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -252,7 +252,7 @@ describe("An incoming chat Message", function () {
         msg_text = `> This is quoted text\n>This is also quoted\nThis is not quoted`;
         msg_text = `> This is quoted text\n>This is also quoted\nThis is not quoted`;
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -261,7 +261,7 @@ describe("An incoming chat Message", function () {
         msg_text = `> This is *quoted* text\n>This is \`also _quoted_\`\nThis is not quoted`;
         msg_text = `> This is *quoted* text\n>This is \`also _quoted_\`\nThis is not quoted`;
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -272,7 +272,7 @@ describe("An incoming chat Message", function () {
         msg_text = `> > This is doubly quoted text`;
         msg_text = `> > This is doubly quoted text`;
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
                 await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') === "<blockquote><blockquote>This is doubly quoted text</blockquote></blockquote>");
                 await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') === "<blockquote><blockquote>This is doubly quoted text</blockquote></blockquote>");
@@ -280,7 +280,7 @@ describe("An incoming chat Message", function () {
         msg_text = `>> This is doubly quoted text`;
         msg_text = `>> This is doubly quoted text`;
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
                 await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') === "<blockquote><blockquote>This is doubly quoted text</blockquote></blockquote>");
                 await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') === "<blockquote><blockquote>This is doubly quoted text</blockquote></blockquote>");
@@ -288,7 +288,7 @@ describe("An incoming chat Message", function () {
         msg_text = ">```\n>ignored\n> <span></span> (println \"Hello, world!\")\n>```\n> This should show up as monospace, preformatted text ^";
         msg_text = ">```\n>ignored\n> <span></span> (println \"Hello, world!\")\n>```\n> This should show up as monospace, preformatted text ^";
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 5);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -302,7 +302,7 @@ describe("An incoming chat Message", function () {
         msg_text = '> ```\n> (println "Hello, world!")\n\nThe entire blockquote is a preformatted text block, but this line is plaintext!';
         msg_text = '> ```\n> (println "Hello, world!")\n\nThe entire blockquote is a preformatted text block, but this line is plaintext!';
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 6);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -312,7 +312,7 @@ describe("An incoming chat Message", function () {
         msg_text = '> Also, icons.js is loaded from /dist, instead of dist.\nhttps://conversejs.org/docs/html/configuration.html#assets-path'
         msg_text = '> Also, icons.js is loaded from /dist, instead of dist.\nhttps://conversejs.org/docs/html/configuration.html#assets-path'
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 7);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -322,7 +322,7 @@ describe("An incoming chat Message", function () {
         msg_text = '> Where is it located?\ngeo:37.786971,-122.399677';
         msg_text = '> Where is it located?\ngeo:37.786971,-122.399677';
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 8);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -333,7 +333,7 @@ describe("An incoming chat Message", function () {
         msg_text = '> What do you think of it?\n :poop:';
         msg_text = '> What do you think of it?\n :poop:';
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 9);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -342,7 +342,7 @@ describe("An incoming chat Message", function () {
         msg_text = '> What do you think of it?\n~hello~';
         msg_text = '> What do you think of it?\n~hello~';
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 10);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
@@ -351,7 +351,7 @@ describe("An incoming chat Message", function () {
         msg_text = 'hello world > this is not a quote';
         msg_text = 'hello world > this is not a quote';
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 11);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') === 'hello world &gt; this is not a quote');
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') === 'hello world &gt; this is not a quote');
@@ -379,7 +379,7 @@ describe("An incoming chat Message", function () {
                     }).nodeTree;
                     }).nodeTree;
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
 
 
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 12);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerText).toBe(msg_text);
         expect(msg_el.innerText).toBe(msg_text);
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!---->/g, '') ===

+ 16 - 5
src/components/chat_content.js

@@ -9,18 +9,29 @@ export default class ChatContent extends CustomElement {
 
 
     static get properties () {
     static get properties () {
         return {
         return {
-            chatview: { type: Object},
-            messages: { type: Array},
-            notifications: { type: String }
+            chatview: { type: Object}
+        }
+    }
+
+    connectedCallback () {
+        super.connectedCallback();
+        const model = this.chatview.model;
+        this.listenTo(model.messages, 'add', this.requestUpdate);
+        this.listenTo(model.messages, 'change', this.requestUpdate);
+        this.listenTo(model.messages, 'remove', this.requestUpdate);
+        this.listenTo(model.messages, 'reset', this.requestUpdate);
+        this.listenTo(model.notifications, 'change', this.requestUpdate);
+        if (model.occupants) {
+            this.listenTo(model.occupants, 'change', this.requestUpdate);
         }
         }
     }
     }
 
 
     render () {
     render () {
-        const notifications = xss.filterXSS(this.notifications, {'whiteList': {}});
+        const notifications = xss.filterXSS(this.chatview.getNotifications(), {'whiteList': {}});
         return html`
         return html`
             <converse-message-history
             <converse-message-history
                 .chatview=${this.chatview}
                 .chatview=${this.chatview}
-                .messages=${this.messages}>
+                .messages=${[...this.chatview.model.messages.models]}>
             </converse-message-history>
             </converse-message-history>
             <div class="chat-content__notifications">${unsafeHTML(notifications)}</div>
             <div class="chat-content__notifications">${unsafeHTML(notifications)}</div>
         `;
         `;

+ 0 - 13
src/components/message.js

@@ -90,19 +90,6 @@ export default class Message extends CustomElement {
         vcard && this.listenTo(vcard, 'change', () => this.requestUpdate());
         vcard && this.listenTo(vcard, 'change', () => this.requestUpdate());
     }
     }
 
 
-    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 () {
     renderInfoMessage () {
         const isodate = dayjs(this.model.get('time')).toISOString();
         const isodate = dayjs(this.model.get('time')).toISOString();
         const i18n_retry = __('Retry');
         const i18n_retry = __('Retry');

+ 2 - 1
src/headless/plugins/chat/message.js

@@ -89,7 +89,8 @@ const MessageMixin = {
      */
      */
     mayBeRetracted () {
     mayBeRetracted () {
         const is_own_message = this.get('sender') === 'me';
         const is_own_message = this.get('sender') === 'me';
-        return is_own_message && ['all', 'own'].includes(api.settings.get('allow_message_retraction'));
+        const not_canceled = this.get('error_type') !== 'cancel';
+        return is_own_message && not_canceled && ['all', 'own'].includes(api.settings.get('allow_message_retraction'));
     },
     },
 
 
     safeDestroy () {
     safeDestroy () {

+ 6 - 7
src/plugins/chatview/view.js

@@ -61,10 +61,7 @@ export default class ChatView extends BaseChatView {
 
 
         // Need to be registered after render has been called.
         // Need to be registered after render has been called.
         this.listenTo(this.model.messages, 'add', this.onMessageAdded);
         this.listenTo(this.model.messages, 'add', this.onMessageAdded);
-        this.listenTo(this.model.messages, 'remove', this.renderChatHistory);
         this.listenTo(this.model.messages, 'rendered', this.maybeScrollDown);
         this.listenTo(this.model.messages, 'rendered', this.maybeScrollDown);
-        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);
         this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages);
 
 
         await this.model.messages.fetched;
         await this.model.messages.fetched;
@@ -79,13 +76,15 @@ export default class ChatView extends BaseChatView {
     }
     }
 
 
     render () {
     render () {
-        const result = tpl_chatbox(Object.assign(this.model.toJSON(), { 'markScrolled': ev => this.markScrolled(ev) }));
+        const result = tpl_chatbox(Object.assign(
+            this.model.toJSON(), {
+                'markScrolled': ev => this.markScrolled(ev),
+                'chatview': this
+            })
+        );
         render(result, this);
         render(result, this);
         this.content = this.querySelector('.chat-content');
         this.content = this.querySelector('.chat-content');
-        this.notifications = this.querySelector('.chat-content__notifications');
-        this.msgs_container = this.querySelector('.chat-content__messages');
         this.help_container = this.querySelector('.chat-content__help');
         this.help_container = this.querySelector('.chat-content__help');
-        this.renderChatContent();
         this.renderMessageForm();
         this.renderMessageForm();
         this.renderHeading();
         this.renderHeading();
         return this;
         return this;

+ 1 - 5
src/plugins/headlines-view/view.js

@@ -51,17 +51,13 @@ class HeadlinesView extends BaseChatView {
         this.setAttribute('id', this.model.get('box_id'));
         this.setAttribute('id', this.model.get('box_id'));
         const result = tpl_chatbox(
         const result = tpl_chatbox(
             Object.assign(this.model.toJSON(), {
             Object.assign(this.model.toJSON(), {
-                info_close: '',
-                label_personal_message: '',
+                chatview: this,
                 show_send_button: false,
                 show_send_button: false,
                 show_toolbar: false,
                 show_toolbar: false,
-                unread_msgs: ''
             })
             })
         );
         );
         render(result, this);
         render(result, this);
         this.content = this.querySelector('.chat-content');
         this.content = this.querySelector('.chat-content');
-        this.msgs_container = this.querySelector('.chat-content__messages');
-        this.renderChatContent();
         this.renderHeading();
         this.renderHeading();
         return this;
         return this;
     }
     }

+ 1 - 7
src/plugins/muc-views/muc.js

@@ -108,20 +108,14 @@ export default class MUCView extends BaseChatView {
         // Need to be registered after render has been called.
         // Need to be registered after render has been called.
         this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages);
         this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages);
         this.listenTo(this.model.messages, 'add', this.onMessageAdded);
         this.listenTo(this.model.messages, 'add', this.onMessageAdded);
-        this.listenTo(this.model.messages, 'change', this.renderChatHistory);
-        this.listenTo(this.model.messages, 'remove', 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.model.occupants.forEach(o => this.onOccupantAdded(o));
         this.listenTo(this.model.occupants, 'add', this.onOccupantAdded);
         this.listenTo(this.model.occupants, 'add', this.onOccupantAdded);
-        this.listenTo(this.model.occupants, 'change', this.renderChatHistory);
         this.listenTo(this.model.occupants, 'change:affiliation', this.onOccupantAffiliationChanged);
         this.listenTo(this.model.occupants, 'change:affiliation', this.onOccupantAffiliationChanged);
         this.listenTo(this.model.occupants, 'change:role', this.onOccupantRoleChanged);
         this.listenTo(this.model.occupants, 'change:role', this.onOccupantRoleChanged);
         this.listenTo(this.model.occupants, 'change:show', this.showJoinOrLeaveNotification);
         this.listenTo(this.model.occupants, 'change:show', this.showJoinOrLeaveNotification);
         this.listenTo(this.model.occupants, 'remove', this.onOccupantRemoved);
         this.listenTo(this.model.occupants, 'remove', this.onOccupantRemoved);
 
 
-        this.renderChatContent();
         // Register later due to await
         // Register later due to await
         const user_settings = await _converse.api.user.settings.getModel();
         const user_settings = await _converse.api.user.settings.getModel();
         this.listenTo(user_settings, 'change:mucs_with_hidden_subject', this.renderHeading);
         this.listenTo(user_settings, 'change:mucs_with_hidden_subject', this.renderHeading);
@@ -143,6 +137,7 @@ export default class MUCView extends BaseChatView {
         render(
         render(
             tpl_chatroom({
             tpl_chatroom({
                 sidebar_hidden,
                 sidebar_hidden,
+                'chatview': this,
                 'model': this.model,
                 'model': this.model,
                 'occupants': this.model.occupants,
                 'occupants': this.model.occupants,
                 'show_sidebar':
                 'show_sidebar':
@@ -157,7 +152,6 @@ export default class MUCView extends BaseChatView {
 
 
         this.notifications = this.querySelector('.chat-content__notifications');
         this.notifications = this.querySelector('.chat-content__notifications');
         this.content = this.querySelector('.chat-content');
         this.content = this.querySelector('.chat-content');
-        this.msgs_container = this.querySelector('.chat-content__messages');
         this.help_container = this.querySelector('.chat-content__help');
         this.help_container = this.querySelector('.chat-content__help');
 
 
         this.renderBottomPanel();
         this.renderBottomPanel();

+ 14 - 35
src/shared/chatview.js

@@ -15,16 +15,6 @@ export default class BaseChatView extends ElementView {
     initDebounced () {
     initDebounced () {
         this.markScrolled = debounce(this._markScrolled, 100);
         this.markScrolled = debounce(this._markScrolled, 100);
         this.debouncedScrollDown = debounce(this.scrollDown, 100);
         this.debouncedScrollDown = debounce(this.scrollDown, 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);
-        }
     }
     }
 
 
     async renderHeading () {
     async renderHeading () {
@@ -32,20 +22,6 @@ export default class BaseChatView extends ElementView {
         render(tpl, this.querySelector('.chat-head-chatbox'));
         render(tpl, this.querySelector('.chat-head-chatbox'));
     }
     }
 
 
-    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);
-    }
-
     renderHelpMessages () {
     renderHelpMessages () {
         render(
         render(
             html`
             html`
@@ -183,7 +159,8 @@ export default class BaseChatView extends ElementView {
     maintainScrollTop () {
     maintainScrollTop () {
         const pos = this.model.get('scrollTop');
         const pos = this.model.get('scrollTop');
         if (pos) {
         if (pos) {
-            this.msgs_container.scrollTop = pos;
+            const msgs_container = this.querySelector('.chat-content__messages');
+            msgs_container.scrollTop = pos;
         } else {
         } else {
             this.scrollDown();
             this.scrollDown();
         }
         }
@@ -312,8 +289,6 @@ export default class BaseChatView extends ElementView {
     }
     }
 
 
     onMessageAdded (message) {
     onMessageAdded (message) {
-        this.renderChatHistory();
-
         if (u.isNewMessage(message)) {
         if (u.isNewMessage(message)) {
             if (message.get('sender') === 'me') {
             if (message.get('sender') === 'me') {
                 // We remove the "scrolled" flag so that the chat area
                 // We remove the "scrolled" flag so that the chat area
@@ -403,13 +378,14 @@ export default class BaseChatView extends ElementView {
     _markScrolled (ev) {
     _markScrolled (ev) {
         let scrolled = true;
         let scrolled = true;
         let scrollTop = null;
         let scrollTop = null;
+        const msgs_container = this.querySelector('.chat-content__messages');
         const is_at_bottom =
         const is_at_bottom =
-            this.msgs_container.scrollTop + this.msgs_container.clientHeight >= this.msgs_container.scrollHeight - 62; // sigh...
+            msgs_container.scrollTop + msgs_container.clientHeight >= msgs_container.scrollHeight - 62; // sigh...
 
 
         if (is_at_bottom) {
         if (is_at_bottom) {
             scrolled = false;
             scrolled = false;
             this.onScrolledDown();
             this.onScrolledDown();
-        } else if (this.msgs_container.scrollTop === 0) {
+        } else if (msgs_container.scrollTop === 0) {
             /**
             /**
              * Triggered once the chat's message area has been scrolled to the top
              * Triggered once the chat's message area has been scrolled to the top
              * @event _converse#chatBoxScrolledUp
              * @event _converse#chatBoxScrolledUp
@@ -439,11 +415,12 @@ export default class BaseChatView extends ElementView {
                 'scrollTop': null
                 'scrollTop': null
             });
             });
         }
         }
-        if (this.msgs_container.scrollTo) {
-            const behavior = this.msgs_container.scrollTop ? 'smooth' : 'auto';
-            this.msgs_container.scrollTo({ 'top': this.msgs_container.scrollHeight, behavior });
+        const msgs_container = this.querySelector('.chat-content__messages');
+        if (msgs_container.scrollTo) {
+            const behavior = msgs_container.scrollTop ? 'smooth' : 'auto';
+            msgs_container.scrollTo({ 'top': msgs_container.scrollHeight, behavior });
         } else {
         } else {
-            this.msgs_container.scrollTop = this.msgs_container.scrollHeight;
+            msgs_container.scrollTop = msgs_container.scrollHeight;
         }
         }
         this.onScrolledDown();
         this.onScrolledDown();
     }
     }
@@ -524,14 +501,16 @@ export default class BaseChatView extends ElementView {
         if (api.settings.get('view_mode') === 'overlayed') {
         if (api.settings.get('view_mode') === 'overlayed') {
             // XXX: Chrome flexbug workaround. The .chat-content area
             // XXX: Chrome flexbug workaround. The .chat-content area
             // doesn't resize when the textarea is resized to its original size.
             // doesn't resize when the textarea is resized to its original size.
-            this.msgs_container.parentElement.style.display = 'none';
+            const msgs_container = this.querySelector('.chat-content__messages');
+            msgs_container.parentElement.style.display = 'none';
         }
         }
         textarea.removeAttribute('disabled');
         textarea.removeAttribute('disabled');
         u.removeClass('disabled', textarea);
         u.removeClass('disabled', textarea);
 
 
         if (api.settings.get('view_mode') === 'overlayed') {
         if (api.settings.get('view_mode') === 'overlayed') {
             // XXX: Chrome flexbug workaround.
             // XXX: Chrome flexbug workaround.
-            this.msgs_container.parentElement.style.display = '';
+            const msgs_container = this.querySelector('.chat-content__messages');
+            msgs_container.parentElement.style.display = '';
         }
         }
         // Suppress events, otherwise superfluous CSN gets set
         // Suppress events, otherwise superfluous CSN gets set
         // immediately after the message, causing rate-limiting issues.
         // immediately after the message, causing rate-limiting issues.

+ 5 - 1
src/templates/chatbox.js

@@ -6,7 +6,11 @@ export default (o) => html`
         <div class="chat-head chat-head-chatbox row no-gutters"></div>
         <div class="chat-head chat-head-chatbox row no-gutters"></div>
         <div class="chat-body">
         <div class="chat-body">
             <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
             <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
-                <div class="chat-content__messages" @scroll=${o.markScrolled}></div>
+                <converse-chat-content
+                    class="chat-content__messages"
+                    .chatview=${o.chatview}
+                    @scroll=${o.markScrolled}></converse-chat-content>
+
                 <div class="chat-content__help"></div>
                 <div class="chat-content__help"></div>
             </div>
             </div>
             <div class="bottom-panel">
             <div class="bottom-panel">

+ 5 - 1
src/templates/chatroom.js

@@ -7,7 +7,11 @@ export default (o) => html`
         <div class="chat-body chatroom-body row no-gutters">
         <div class="chat-body chatroom-body row no-gutters">
             <div class="chat-area col">
             <div class="chat-area col">
                 <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
                 <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
-                    <div class="chat-content__messages" @scroll=${o.markScrolled}></div>
+                    <converse-chat-content
+                        class="chat-content__messages"
+                        .chatview=${o.chatview}
+                        @scroll=${o.markScrolled}></converse-chat-content>
+
                     <div class="chat-content__help"></div>
                     <div class="chat-content__help"></div>
                 </div>
                 </div>
                 <div class="bottom-panel"></div>
                 <div class="bottom-panel"></div>