Browse Source

Move MUC message tests to spec/muc_messages.js

JC Brand 6 years ago
parent
commit
8b93e0f70a
3 changed files with 853 additions and 842 deletions
  1. 0 842
      spec/messages.js
  2. 852 0
      spec/muc_messages.js
  3. 1 0
      tests/runner.js

+ 0 - 842
spec/messages.js

@@ -2256,846 +2256,4 @@
             done();
             done();
         }));
         }));
     });
     });
-
-
-    describe("A Groupchat Message", function () {
-
-        it("is specially marked when you are mentioned in it",
-            mock.initConverse(
-                null, ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                async function (done, _converse) {
-
-            const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.api.chatviews.get(muc_jid);
-            if (!view.el.querySelectorAll('.chat-area').length) { view.renderChatArea(); }
-            const message = 'romeo: Your attention is required';
-            const nick = mock.chatroom_names[0],
-                msg = $msg({
-                    from: 'lounge@montague.lit/'+nick,
-                    id: (new Date()).getTime(),
-                    to: 'romeo@montague.lit',
-                    type: 'groupchat'
-                }).c('body').t(message).tree();
-            await view.model.onMessage(msg);
-            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
-            expect(u.hasClass('mentioned', view.el.querySelector('.chat-msg'))).toBeTruthy();
-            done();
-        }));
-
-        it("can not be expected to have a unique id attribute",
-            mock.initConverse(
-                null, ['rosterGroupsFetched', 'chatBoxesFetched'], {},
-                async function (done, _converse) {
-
-            const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.api.chatviews.get(muc_jid);
-            if (!view.el.querySelectorAll('.chat-area').length) { view.renderChatArea(); }
-            const id = u.getUniqueId();
-            let msg = $msg({
-                    from: 'lounge@montague.lit/some1',
-                    id: id,
-                    to: 'romeo@montague.lit',
-                    type: 'groupchat'
-                }).c('body').t('First message').tree();
-            await view.model.onMessage(msg);
-            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-
-            msg = $msg({
-                    from: 'lounge@montague.lit/some2',
-                    id: id,
-                    to: 'romeo@montague.lit',
-                    type: 'groupchat'
-                }).c('body').t('Another message').tree();
-            await view.model.onMessage(msg);
-            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
-            done();
-        }));
-
-        it("is ignored if it has the same stanza-id of an already received on",
-            mock.initConverse(
-                null, ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            const muc_jid = 'room@muc.example.com';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.api.chatviews.get(muc_jid);
-            spyOn(view.model, 'findDuplicateFromStanzaID').and.callThrough();
-            let stanza = u.toStanza(`
-                <message xmlns="jabber:client"
-                         from="room@muc.example.com/some1"
-                         to="${_converse.connection.jid}"
-                         type="groupchat">
-                    <body>Typical body text</body>
-                    <stanza-id xmlns="urn:xmpp:sid:0"
-                               id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
-                               by="room@muc.example.com"/>
-                </message>`);
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => _converse.api.chats.get().length);
-            await u.waitUntil(() => view.model.messages.length === 1);
-            await u.waitUntil(() => view.model.findDuplicateFromStanzaID.calls.count() === 1);
-            let result = await view.model.findDuplicateFromStanzaID.calls.all()[0].returnValue;
-            expect(result).toBe(undefined);
-
-            stanza = u.toStanza(`
-                <message xmlns="jabber:client"
-                         from="room@muc.example.com/some1"
-                         to="${_converse.connection.jid}"
-                         type="groupchat">
-                    <body>Typical body text</body>
-                    <stanza-id xmlns="urn:xmpp:sid:0"
-                               id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
-                               by="room@muc.example.com"/>
-                </message>`);
-            spyOn(view.model, 'updateMessage');
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => view.model.findDuplicateFromStanzaID.calls.count() === 2);
-            result = await view.model.findDuplicateFromStanzaID.calls.all()[1].returnValue;
-            expect(result instanceof _converse.Message).toBe(true);
-            expect(view.model.messages.length).toBe(1);
-            await u.waitUntil(() => view.model.updateMessage.calls.count());
-            done();
-        }));
-
-        it("keeps track of the sender's role and affiliation",
-            mock.initConverse(
-                null, ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.api.chatviews.get(muc_jid);
-            let msg = $msg({
-                from: 'lounge@montague.lit/romeo',
-                id: (new Date()).getTime(),
-                to: 'romeo@montague.lit',
-                type: 'groupchat'
-            }).c('body').t('I wrote this message!').tree();
-            await view.model.onMessage(msg);
-            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
-            expect(view.model.messages.last().occupant.get('affiliation')).toBe('owner');
-            expect(view.model.messages.last().occupant.get('role')).toBe('moderator');
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(sizzle('.chat-msg', view.el).pop().classList.value.trim()).toBe('message chat-msg groupchat moderator owner');
-
-            let presence = $pres({
-                    to:'romeo@montague.lit/orchard',
-                    from:'lounge@montague.lit/romeo',
-                    id: u.getUniqueId()
-            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                .c('item').attrs({
-                    affiliation: 'member',
-                    jid: 'romeo@montague.lit/orchard',
-                    role: 'participant'
-                }).up()
-                .c('status').attrs({code:'110'}).up()
-                .c('status').attrs({code:'210'}).nodeTree;
-            _converse.connection._dataRecv(test_utils.createRequest(presence));
-
-            msg = $msg({
-                from: 'lounge@montague.lit/romeo',
-                id: (new Date()).getTime(),
-                to: 'romeo@montague.lit',
-                type: 'groupchat'
-            }).c('body').t('Another message!').tree();
-            await view.model.onMessage(msg);
-            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
-            expect(view.model.messages.last().occupant.get('affiliation')).toBe('member');
-            expect(view.model.messages.last().occupant.get('role')).toBe('participant');
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
-            expect(sizzle('.chat-msg', view.el).pop().classList.value.trim()).toBe('message chat-msg groupchat participant member');
-
-            presence = $pres({
-                    to:'romeo@montague.lit/orchard',
-                    from:'lounge@montague.lit/romeo',
-                    id: u.getUniqueId()
-            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
-                .c('item').attrs({
-                    affiliation: 'owner',
-                    jid: 'romeo@montague.lit/orchard',
-                    role: 'moderator'
-                }).up()
-                .c('status').attrs({code:'110'}).up()
-                .c('status').attrs({code:'210'}).nodeTree;
-            _converse.connection._dataRecv(test_utils.createRequest(presence));
-            view.model.sendMessage('hello world');
-            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 3);
-
-            expect(view.model.messages.last().occupant.get('affiliation')).toBe('owner');
-            expect(view.model.messages.last().occupant.get('role')).toBe('moderator');
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(3);
-            expect(sizzle('.chat-msg', view.el).pop().classList.value.trim()).toBe('message chat-msg groupchat moderator owner');
-            done();
-        }));
-
-
-        it("keeps track whether you are the sender or not",
-            mock.initConverse(
-                null, ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.api.chatviews.get(muc_jid);
-            const msg = $msg({
-                    from: 'lounge@montague.lit/romeo',
-                    id: (new Date()).getTime(),
-                    to: 'romeo@montague.lit',
-                    type: 'groupchat'
-                }).c('body').t('I wrote this message!').tree();
-            await view.model.onMessage(msg);
-            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
-            expect(view.model.messages.last().get('sender')).toBe('me');
-            done();
-        }));
-
-        it("can be replaced with a correction",
-            mock.initConverse(
-                null, ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.api.chatviews.get(muc_jid);
-            const stanza = $pres({
-                    to: 'romeo@montague.lit/_converse.js-29092160',
-                    from: 'coven@chat.shakespeare.lit/newguy'
-                })
-                .c('x', {xmlns: Strophe.NS.MUC_USER})
-                .c('item', {
-                    'affiliation': 'none',
-                    'jid': 'newguy@montague.lit/_converse.js-290929789',
-                    'role': 'participant'
-                }).tree();
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            const msg_id = u.getUniqueId();
-            await view.model.onMessage($msg({
-                    'from': 'lounge@montague.lit/newguy',
-                    'to': _converse.connection.jid,
-                    'type': 'groupchat',
-                    'id': msg_id,
-                }).c('body').t('But soft, what light through yonder airlock breaks?').tree());
-            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.el.querySelector('.chat-msg__text').textContent)
-                .toBe('But soft, what light through yonder airlock breaks?');
-
-            await view.model.onMessage($msg({
-                    'from': 'lounge@montague.lit/newguy',
-                    'to': _converse.connection.jid,
-                    'type': 'groupchat',
-                    'id': u.getUniqueId(),
-                }).c('body').t('But soft, what light through yonder chimney breaks?').up()
-                    .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
-            await u.waitUntil(() => view.el.querySelector('.chat-msg__text').textContent ===
-                'But soft, what light through yonder chimney breaks?', 500);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
-
-            await view.model.onMessage($msg({
-                    'from': 'lounge@montague.lit/newguy',
-                    'to': _converse.connection.jid,
-                    'type': 'groupchat',
-                    'id': u.getUniqueId(),
-                }).c('body').t('But soft, what light through yonder window breaks?').up()
-                    .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
-
-            await u.waitUntil(() => view.el.querySelector('.chat-msg__text').textContent ===
-                'But soft, what light through yonder window breaks?', 500);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
-            view.el.querySelector('.chat-msg__content .fa-edit').click();
-            const modal = view.model.messages.at(0).message_versions_modal;
-            await u.waitUntil(() => u.isVisible(modal.el), 1000);
-            const older_msgs = modal.el.querySelectorAll('.older-msg');
-            expect(older_msgs.length).toBe(2);
-            expect(older_msgs[0].childNodes[1].textContent).toBe(': But soft, what light through yonder airlock breaks?');
-            expect(older_msgs[0].childNodes[0].nodeName).toBe('TIME');
-            expect(older_msgs[1].childNodes[0].nodeName).toBe('TIME');
-            expect(older_msgs[1].childNodes[1].textContent).toBe(': But soft, what light through yonder chimney breaks?');
-            done();
-        }));
-
-        it("can be sent as a correction by using the up arrow",
-            mock.initConverse(
-                null, ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.api.chatviews.get(muc_jid);
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-            expect(textarea.value).toBe('');
-            view.onKeyDown({
-                target: textarea,
-                keyCode: 38 // Up arrow
-            });
-            expect(textarea.value).toBe('');
-
-            textarea.value = 'But soft, what light through yonder airlock breaks?';
-            view.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.el.querySelector('.chat-msg__text').textContent)
-                .toBe('But soft, what light through yonder airlock breaks?');
-
-            const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
-            expect(textarea.value).toBe('');
-            view.onKeyDown({
-                target: textarea,
-                keyCode: 38 // Up arrow
-            });
-            await new Promise((resolve, reject) => view.model.messages.once('rendered', resolve));
-            expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
-            expect(view.model.messages.at(0).get('correcting')).toBe(true);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(u.hasClass('correcting', view.el.querySelector('.chat-msg'))).toBe(true);
-
-            spyOn(_converse.connection, 'send');
-            textarea.value = 'But soft, what light through yonder window breaks?';
-            view.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            expect(_converse.connection.send).toHaveBeenCalled();
-            await new Promise((resolve, reject) => view.model.messages.once('rendered', resolve));
-
-            const msg = _converse.connection.send.calls.all()[0].args[0];
-            expect(msg.toLocaleString())
-            .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
-                    `to="lounge@montague.lit" type="groupchat" `+
-                    `xmlns="jabber:client">`+
-                        `<body>But soft, what light through yonder window breaks?</body>`+
-                        `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                        `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
-                        `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                `</message>`);
-
-            expect(view.model.messages.models.length).toBe(1);
-            const corrected_message = view.model.messages.at(0);
-            expect(corrected_message.get('msgid')).toBe(first_msg.get('msgid'));
-            expect(corrected_message.get('correcting')).toBe(false);
-
-            const older_versions = corrected_message.get('older_versions');
-            const keys = Object.keys(older_versions);
-            expect(keys.length).toBe(1);
-            expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?');
-
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(u.hasClass('correcting', view.el.querySelector('.chat-msg'))).toBe(false);
-
-            // Check that messages from other users are skipped
-            await view.model.onMessage($msg({
-                'from': muc_jid+'/someone-else',
-                'id': (new Date()).getTime(),
-                'to': 'romeo@montague.lit',
-                'type': 'groupchat'
-            }).c('body').t('Hello world').tree());
-            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
-
-            // Test that pressing the down arrow cancels message correction
-            expect(textarea.value).toBe('');
-            view.onKeyDown({
-                target: textarea,
-                keyCode: 38 // Up arrow
-            });
-            expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
-            expect(view.model.messages.at(0).get('correcting')).toBe(true);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
-            await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
-            expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
-            view.onKeyDown({
-                target: textarea,
-                keyCode: 40 // Down arrow
-            });
-            expect(textarea.value).toBe('');
-            expect(view.model.messages.at(0).get('correcting')).toBe(false);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
-            await u.waitUntil(() => !u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
-            done();
-        }));
-
-        it("will be shown as received upon MUC reflection",
-            mock.initConverse(
-                null, ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            await test_utils.waitForRoster(_converse, 'current');
-            const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.api.chatviews.get(muc_jid);
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-            textarea.value = 'But soft, what light through yonder airlock breaks?';
-            view.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
-            expect(view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length).toBe(0);
-
-            const msg_obj = view.model.messages.at(0);
-            const stanza = u.toStanza(`
-                <message xmlns="jabber:client"
-                         from="${msg_obj.get('from')}"
-                         to="${_converse.connection.jid}"
-                         type="groupchat">
-                    <msg_body>${msg_obj.get('message')}</msg_body>
-                    <stanza-id xmlns="urn:xmpp:sid:0"
-                               id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
-                               by="lounge@montague.lit"/>
-                    <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
-                </message>`);
-            await view.model.onMessage(stanza);
-            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500);
-            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
-            expect(view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length).toBe(1);
-            expect(view.model.messages.length).toBe(1);
-
-            const message = view.model.messages.at(0);
-            expect(message.get('stanza_id lounge@montague.lit')).toBe('5f3dbc5e-e1d3-4077-a492-693f3769c7ad');
-            expect(message.get('origin_id')).toBe(msg_obj.get('origin_id'));
-            done();
-        }));
-
-        it("gets updated with its stanza-id upon MUC reflection",
-            mock.initConverse(
-                null, ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            const muc_jid = 'room@muc.example.com';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.api.chatviews.get(muc_jid);
-
-            view.model.sendMessage('hello world');
-            await u.waitUntil(() => _converse.api.chats.get().length);
-            await u.waitUntil(() => view.model.messages.length === 1);
-            const msg = view.model.messages.at(0);
-            expect(msg.get('stanza_id')).toBeUndefined();
-            expect(msg.get('origin_id')).toBe(msg.get('origin_id'));
-
-            const stanza = u.toStanza(`
-                <message xmlns="jabber:client"
-                         from="room@muc.example.com/romeo"
-                         to="${_converse.connection.jid}"
-                         type="groupchat">
-                    <body>Hello world</body>
-                    <stanza-id xmlns="urn:xmpp:sid:0"
-                               id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
-                               by="room@muc.example.com"/>
-                    <origin-id xmlns="urn:xmpp:sid:0" id="${msg.get('origin_id')}"/>
-                </message>`);
-            spyOn(view.model, 'updateMessage').and.callThrough();
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => view.model.updateMessage.calls.count() === 1);
-            expect(view.model.messages.length).toBe(1);
-            expect(view.model.messages.at(0).get('stanza_id room@muc.example.com')).toBe("5f3dbc5e-e1d3-4077-a492-693f3769c7ad");
-            expect(view.model.messages.at(0).get('origin_id')).toBe(msg.get('origin_id'));
-            done();
-        }));
-
-        it("can cause a delivery receipt to be returned",
-            mock.initConverse(
-                null, ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            await test_utils.waitForRoster(_converse, 'current');
-            const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.api.chatviews.get(muc_jid);
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-            textarea.value = 'But soft, what light through yonder airlock breaks?';
-            view.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-
-            const msg_obj = view.model.messages.at(0);
-            const stanza = u.toStanza(`
-                <message xml:lang="en" to="romeo@montague.lit/orchard"
-                         from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
-                    <received xmlns="urn:xmpp:receipts" id="${msg_obj.get('msgid')}"/>
-                    <origin-id xmlns="urn:xmpp:sid:0" id="CE08D448-5ED8-4B6A-BB5B-07ED9DFE4FF0"/>
-                </message>`);
-            spyOn(_converse.api, "trigger").and.callThrough();
-            spyOn(view.model, "isReceipt").and.callThrough();
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => view.model.isReceipt.calls.count() === 1);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
-            expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
-            done();
-        }));
-
-        it("can cause a chat marker to be returned",
-            mock.initConverse(
-                null, ['rosterGroupsFetched'], {},
-                async function (done, _converse) {
-
-            await test_utils.waitForRoster(_converse, 'current');
-            const muc_jid = 'lounge@montague.lit';
-            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.api.chatviews.get(muc_jid);
-            const textarea = view.el.querySelector('textarea.chat-textarea');
-            textarea.value = 'But soft, what light through yonder airlock breaks?';
-            view.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.el.querySelector('.chat-msg .chat-msg__body').textContent.trim())
-                .toBe("But soft, what light through yonder airlock breaks?");
-
-            const msg_obj = view.model.messages.at(0);
-            let stanza = u.toStanza(`
-                <message xml:lang="en" to="romeo@montague.lit/orchard"
-                         from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
-                    <received xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/>
-                </message>`);
-            spyOn(view.model, "isChatMarker").and.callThrough();
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => view.model.isChatMarker.calls.count() === 1);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
-
-            stanza = u.toStanza(`
-                <message xml:lang="en" to="romeo@montague.lit/orchard"
-                         from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
-                    <displayed xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/>
-                </message>`);
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => view.model.isChatMarker.calls.count() === 2);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
-
-            stanza = u.toStanza(`
-                <message xml:lang="en" to="romeo@montague.lit/orchard"
-                         from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
-                    <acknowledged xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/>
-                </message>`);
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-            await u.waitUntil(() => view.model.isChatMarker.calls.count() === 3);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
-
-            stanza = u.toStanza(`
-                <message xml:lang="en" to="romeo@montague.lit/orchard"
-                         from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
-                    <body>'tis I!</body>
-                    <markable xmlns="urn:xmpp:chat-markers:0"/>
-                </message>`);
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => view.model.isChatMarker.calls.count() === 4);
-            expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
-            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
-            done();
-        }));
-
-        describe("when received", function () {
-
-            it("highlights all users mentioned via XEP-0372 references",
-                mock.initConverse(
-                    null, ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'tom');
-                const view = _converse.api.chatviews.get(muc_jid);
-                ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
-                    _converse.connection._dataRecv(test_utils.createRequest(
-                        $pres({
-                            'to': 'tom@montague.lit/resource',
-                            'from': `lounge@montague.lit/${nick}`
-                        })
-                        .c('x', {xmlns: Strophe.NS.MUC_USER})
-                        .c('item', {
-                            'affiliation': 'none',
-                            'jid': `${nick}@montague.lit/resource`,
-                            'role': 'participant'
-                        }))
-                    );
-                });
-                const msg = $msg({
-                        from: 'lounge@montague.lit/gibson',
-                        id: (new Date()).getTime(),
-                        to: 'romeo@montague.lit',
-                        type: 'groupchat'
-                    }).c('body').t('hello z3r0 tom mr.robot, how are you?').up()
-                        .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'6', 'end':'10', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up()
-                        .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'11', 'end':'14', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up()
-                        .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'15', 'end':'23', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree;
-                await view.model.onMessage(msg);
-                await new Promise((resolve, reject) => view.once('messageInserted', resolve));
-                const messages = view.el.querySelectorAll('.chat-msg__text');
-                expect(messages.length).toBe(1);
-                expect(messages[0].classList.length).toEqual(1);
-                expect(messages[0].innerHTML).toBe(
-                    'hello <span class="mention">z3r0</span> '+
-                    '<span class="mention mention--self badge badge-info">tom</span> '+
-                    '<span class="mention">mr.robot</span>, how are you?');
-                done();
-            }));
-        });
-
-        describe("in which someone is mentioned", function () {
-
-            it("gets parsed for mentions which get turned into references",
-                mock.initConverse(
-                    null, ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'tom');
-                const view = _converse.api.chatviews.get(muc_jid);
-                ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh', 'Link Mauve'].forEach((nick) => {
-                    _converse.connection._dataRecv(test_utils.createRequest(
-                        $pres({
-                            'to': 'tom@montague.lit/resource',
-                            'from': `lounge@montague.lit/${nick}`
-                        })
-                        .c('x', {xmlns: Strophe.NS.MUC_USER})
-                        .c('item', {
-                            'affiliation': 'none',
-                            'jid': `${nick.replace(/\s/g, '-')}@montague.lit/resource`,
-                            'role': 'participant'
-                        })));
-                });
-
-                // Run a few unit tests for the parseTextForReferences method
-                let [text, references] = view.model.parseTextForReferences('hello z3r0')
-                expect(references.length).toBe(0);
-                expect(text).toBe('hello z3r0');
-
-                [text, references] = view.model.parseTextForReferences('hello @z3r0')
-                expect(references.length).toBe(1);
-                expect(text).toBe('hello z3r0');
-                expect(JSON.stringify(references))
-                    .toBe('[{"begin":6,"end":10,"value":"z3r0","type":"mention","uri":"xmpp:z3r0@montague.lit"}]');
-
-                [text, references] = view.model.parseTextForReferences('hello @some1 @z3r0 @gibson @mr.robot, how are you?')
-                expect(text).toBe('hello @some1 z3r0 gibson mr.robot, how are you?');
-                expect(JSON.stringify(references))
-                    .toBe('[{"begin":13,"end":17,"value":"z3r0","type":"mention","uri":"xmpp:z3r0@montague.lit"},'+
-                            '{"begin":18,"end":24,"value":"gibson","type":"mention","uri":"xmpp:gibson@montague.lit"},'+
-                            '{"begin":25,"end":33,"value":"mr.robot","type":"mention","uri":"xmpp:mr.robot@montague.lit"}]');
-
-                [text, references] = view.model.parseTextForReferences('yo @gib')
-                expect(text).toBe('yo @gib');
-                expect(references.length).toBe(0);
-
-                [text, references] = view.model.parseTextForReferences('yo @gibsonian')
-                expect(text).toBe('yo @gibsonian');
-                expect(references.length).toBe(0);
-
-                [text, references] = view.model.parseTextForReferences('@gibson')
-                expect(text).toBe('gibson');
-                expect(references.length).toBe(1);
-                expect(JSON.stringify(references))
-                    .toBe('[{"begin":0,"end":6,"value":"gibson","type":"mention","uri":"xmpp:gibson@montague.lit"}]');
-
-                [text, references] = view.model.parseTextForReferences('hi @Link Mauve how are you?')
-                expect(text).toBe('hi Link Mauve how are you?');
-                expect(references.length).toBe(1);
-                expect(JSON.stringify(references))
-                    .toBe('[{"begin":3,"end":13,"value":"Link Mauve","type":"mention","uri":"xmpp:Link-Mauve@montague.lit"}]');
-
-                [text, references] = view.model.parseTextForReferences('https://example.org/@gibson')
-                expect(text).toBe('https://example.org/@gibson');
-                expect(references.length).toBe(0);
-                expect(JSON.stringify(references))
-                    .toBe('[]');
-
-                [text, references] = view.model.parseTextForReferences('mail@gibson.com')
-                expect(text).toBe('mail@gibson.com');
-                expect(references.length).toBe(0);
-                expect(JSON.stringify(references))
-                    .toBe('[]');
-
-                [text, references] = view.model.parseTextForReferences(
-                    'https://linkmauve.fr@Link Mauve/ https://linkmauve.fr/@github/is_back gibson@gibson.com gibson@Link Mauve.fr')
-                expect(text).toBe(
-                    'https://linkmauve.fr@Link Mauve/ https://linkmauve.fr/@github/is_back gibson@gibson.com gibson@Link Mauve.fr');
-                expect(references.length).toBe(0);
-                expect(JSON.stringify(references))
-                    .toBe('[]');
-                done();
-            }));
-
-            it("parses for mentions as indicated with an @ preceded by a space or at the start of the text",
-                mock.initConverse(
-                    null, ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'tom');
-                const view = _converse.api.chatviews.get(muc_jid);
-                ['NotAnAdress', 'darnuria'].forEach((nick) => {
-                    _converse.connection._dataRecv(test_utils.createRequest(
-                        $pres({
-                            'to': 'tom@montague.lit/resource',
-                            'from': `lounge@montague.lit/${nick}`
-                        })
-                        .c('x', {xmlns: Strophe.NS.MUC_USER})
-                        .c('item', {
-                            'affiliation': 'none',
-                            'jid': `${nick.replace(/\s/g, '-')}@montague.lit/resource`,
-                            'role': 'participant'
-                        })));
-                });
-
-                // Test that we don't match @nick in email adresses.
-                let [text, references] = view.model.parseTextForReferences('contact contact@NotAnAdress.eu');
-                expect(references.length).toBe(0);
-                expect(text).toBe('contact contact@NotAnAdress.eu');
-
-                // Test that we don't match @nick in url
-                [text, references] = view.model.parseTextForReferences('nice website https://darnuria.eu/@darnuria');
-                expect(references.length).toBe(0);
-                expect(text).toBe('nice website https://darnuria.eu/@darnuria');
-
-                done();
-            }));
-
-            it("can get corrected and given new references",
-                mock.initConverse(
-                    null, ['rosterGroupsFetched'], {},
-                    async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'tom');
-                const view = _converse.api.chatviews.get(muc_jid);
-                ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
-                    _converse.connection._dataRecv(test_utils.createRequest(
-                        $pres({
-                            'to': 'tom@montague.lit/resource',
-                            'from': `lounge@montague.lit/${nick}`
-                        })
-                        .c('x', {xmlns: Strophe.NS.MUC_USER})
-                        .c('item', {
-                            'affiliation': 'none',
-                            'jid': `${nick}@montague.lit/resource`,
-                            'role': 'participant'
-                        })));
-                });
-
-                const textarea = view.el.querySelector('textarea.chat-textarea');
-                textarea.value = 'hello @z3r0 @gibson @mr.robot, how are you?'
-                const enter_event = {
-                    'target': textarea,
-                    'preventDefault': function preventDefault () {},
-                    'stopPropagation': function stopPropagation () {},
-                    'keyCode': 13 // Enter
-                }
-                spyOn(_converse.connection, 'send');
-                view.onKeyDown(enter_event);
-                await new Promise((resolve, reject) => view.once('messageInserted', resolve));
-                const msg = _converse.connection.send.calls.all()[0].args[0];
-                expect(msg.toLocaleString())
-                    .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
-                            `to="lounge@montague.lit" type="groupchat" `+
-                            `xmlns="jabber:client">`+
-                                `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
-                                `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                                `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                                `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                                `<reference begin="18" end="26" type="mention" uri="xmpp:mr.robot@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                                `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                            `</message>`);
-
-                const first_msg = view.model.messages.findWhere({'message': 'hello z3r0 gibson mr.robot, how are you?'});
-                const action = view.el.querySelector('.chat-msg .chat-msg__action');
-                action.style.opacity = 1;
-                action.click();
-
-                expect(textarea.value).toBe('hello @z3r0 @gibson @mr.robot, how are you?');
-                expect(view.model.messages.at(0).get('correcting')).toBe(true);
-                expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
-                await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
-
-                textarea.value = 'hello @z3r0 @gibson @sw0rdf1sh, how are you?';
-                view.onKeyDown(enter_event);
-                await u.waitUntil(() => view.el.querySelector('.chat-msg__text').textContent ===
-                    'hello z3r0 gibson sw0rdf1sh, how are you?', 500);
-
-                const correction = _converse.connection.send.calls.all()[2].args[0];
-                expect(correction.toLocaleString())
-                    .toBe(`<message from="romeo@montague.lit/orchard" id="${correction.nodeTree.getAttribute("id")}" `+
-                            `to="lounge@montague.lit" type="groupchat" `+
-                            `xmlns="jabber:client">`+
-                                `<body>hello z3r0 gibson sw0rdf1sh, how are you?</body>`+
-                                `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                                `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                                `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                                `<reference begin="18" end="27" type="mention" uri="xmpp:sw0rdf1sh@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                                `<replace id="${msg.nodeTree.getAttribute("id")}" xmlns="urn:xmpp:message-correct:0"/>`+
-                                `<origin-id id="${correction.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                            `</message>`);
-                done();
-            }));
-
-            it("includes XEP-0372 references to that person",
-                mock.initConverse(
-                    null, ['rosterGroupsFetched'], {},
-                        async function (done, _converse) {
-
-                const muc_jid = 'lounge@montague.lit';
-                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-                const view = _converse.api.chatviews.get(muc_jid);
-                ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
-                    _converse.connection._dataRecv(test_utils.createRequest(
-                        $pres({
-                            'to': 'tom@montague.lit/resource',
-                            'from': `lounge@montague.lit/${nick}`
-                        })
-                        .c('x', {xmlns: Strophe.NS.MUC_USER})
-                        .c('item', {
-                            'affiliation': 'none',
-                            'jid': `${nick}@montague.lit/resource`,
-                            'role': 'participant'
-                        })));
-                });
-
-                spyOn(_converse.connection, 'send');
-                const textarea = view.el.querySelector('textarea.chat-textarea');
-                textarea.value = 'hello @z3r0 @gibson @mr.robot, how are you?'
-                const enter_event = {
-                    'target': textarea,
-                    'preventDefault': function preventDefault () {},
-                    'stopPropagation': function stopPropagation () {},
-                    'keyCode': 13 // Enter
-                }
-                view.onKeyDown(enter_event);
-
-                const msg = _converse.connection.send.calls.all()[0].args[0];
-                expect(msg.toLocaleString())
-                    .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
-                            `to="lounge@montague.lit" type="groupchat" `+
-                            `xmlns="jabber:client">`+
-                                `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
-                                `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                                `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                                `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                                `<reference begin="18" end="26" type="mention" uri="xmpp:mr.robot@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                                `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                            `</message>`);
-                done();
-            }));
-        });
-    });
 }));
 }));

+ 852 - 0
spec/muc_messages.js

@@ -0,0 +1,852 @@
+(function (root, factory) {
+    define([
+        "jasmine",
+        "mock",
+        "test-utils"
+        ], factory);
+} (this, function (jasmine, mock, test_utils) {
+    "use strict";
+    const { Promise, Strophe, $iq, $msg, $pres, sizzle, _ } = converse.env;
+    const u = converse.env.utils;
+
+    describe("A Groupchat Message", function () {
+
+        it("is specially marked when you are mentioned in it",
+            mock.initConverse(
+                null, ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.api.chatviews.get(muc_jid);
+            if (!view.el.querySelectorAll('.chat-area').length) { view.renderChatArea(); }
+            const message = 'romeo: Your attention is required';
+            const nick = mock.chatroom_names[0],
+                msg = $msg({
+                    from: 'lounge@montague.lit/'+nick,
+                    id: (new Date()).getTime(),
+                    to: 'romeo@montague.lit',
+                    type: 'groupchat'
+                }).c('body').t(message).tree();
+            await view.model.onMessage(msg);
+            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
+            expect(u.hasClass('mentioned', view.el.querySelector('.chat-msg'))).toBeTruthy();
+            done();
+        }));
+
+        it("can not be expected to have a unique id attribute",
+            mock.initConverse(
+                null, ['rosterGroupsFetched', 'chatBoxesFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.api.chatviews.get(muc_jid);
+            if (!view.el.querySelectorAll('.chat-area').length) { view.renderChatArea(); }
+            const id = u.getUniqueId();
+            let msg = $msg({
+                    from: 'lounge@montague.lit/some1',
+                    id: id,
+                    to: 'romeo@montague.lit',
+                    type: 'groupchat'
+                }).c('body').t('First message').tree();
+            await view.model.onMessage(msg);
+            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+
+            msg = $msg({
+                    from: 'lounge@montague.lit/some2',
+                    id: id,
+                    to: 'romeo@montague.lit',
+                    type: 'groupchat'
+                }).c('body').t('Another message').tree();
+            await view.model.onMessage(msg);
+            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
+            done();
+        }));
+
+        it("is ignored if it has the same stanza-id of an already received on",
+            mock.initConverse(
+                null, ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'room@muc.example.com';
+            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.api.chatviews.get(muc_jid);
+            spyOn(view.model, 'findDuplicateFromStanzaID').and.callThrough();
+            let stanza = u.toStanza(`
+                <message xmlns="jabber:client"
+                         from="room@muc.example.com/some1"
+                         to="${_converse.connection.jid}"
+                         type="groupchat">
+                    <body>Typical body text</body>
+                    <stanza-id xmlns="urn:xmpp:sid:0"
+                               id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
+                               by="room@muc.example.com"/>
+                </message>`);
+            _converse.connection._dataRecv(test_utils.createRequest(stanza));
+            await u.waitUntil(() => _converse.api.chats.get().length);
+            await u.waitUntil(() => view.model.messages.length === 1);
+            await u.waitUntil(() => view.model.findDuplicateFromStanzaID.calls.count() === 1);
+            let result = await view.model.findDuplicateFromStanzaID.calls.all()[0].returnValue;
+            expect(result).toBe(undefined);
+
+            stanza = u.toStanza(`
+                <message xmlns="jabber:client"
+                         from="room@muc.example.com/some1"
+                         to="${_converse.connection.jid}"
+                         type="groupchat">
+                    <body>Typical body text</body>
+                    <stanza-id xmlns="urn:xmpp:sid:0"
+                               id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
+                               by="room@muc.example.com"/>
+                </message>`);
+            spyOn(view.model, 'updateMessage');
+            _converse.connection._dataRecv(test_utils.createRequest(stanza));
+            await u.waitUntil(() => view.model.findDuplicateFromStanzaID.calls.count() === 2);
+            result = await view.model.findDuplicateFromStanzaID.calls.all()[1].returnValue;
+            expect(result instanceof _converse.Message).toBe(true);
+            expect(view.model.messages.length).toBe(1);
+            await u.waitUntil(() => view.model.updateMessage.calls.count());
+            done();
+        }));
+
+        it("keeps track of the sender's role and affiliation",
+            mock.initConverse(
+                null, ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.api.chatviews.get(muc_jid);
+            let msg = $msg({
+                from: 'lounge@montague.lit/romeo',
+                id: (new Date()).getTime(),
+                to: 'romeo@montague.lit',
+                type: 'groupchat'
+            }).c('body').t('I wrote this message!').tree();
+            await view.model.onMessage(msg);
+            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
+            expect(view.model.messages.last().occupant.get('affiliation')).toBe('owner');
+            expect(view.model.messages.last().occupant.get('role')).toBe('moderator');
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(sizzle('.chat-msg', view.el).pop().classList.value.trim()).toBe('message chat-msg groupchat moderator owner');
+
+            let presence = $pres({
+                    to:'romeo@montague.lit/orchard',
+                    from:'lounge@montague.lit/romeo',
+                    id: u.getUniqueId()
+            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+                .c('item').attrs({
+                    affiliation: 'member',
+                    jid: 'romeo@montague.lit/orchard',
+                    role: 'participant'
+                }).up()
+                .c('status').attrs({code:'110'}).up()
+                .c('status').attrs({code:'210'}).nodeTree;
+            _converse.connection._dataRecv(test_utils.createRequest(presence));
+
+            msg = $msg({
+                from: 'lounge@montague.lit/romeo',
+                id: (new Date()).getTime(),
+                to: 'romeo@montague.lit',
+                type: 'groupchat'
+            }).c('body').t('Another message!').tree();
+            await view.model.onMessage(msg);
+            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
+            expect(view.model.messages.last().occupant.get('affiliation')).toBe('member');
+            expect(view.model.messages.last().occupant.get('role')).toBe('participant');
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
+            expect(sizzle('.chat-msg', view.el).pop().classList.value.trim()).toBe('message chat-msg groupchat participant member');
+
+            presence = $pres({
+                    to:'romeo@montague.lit/orchard',
+                    from:'lounge@montague.lit/romeo',
+                    id: u.getUniqueId()
+            }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'})
+                .c('item').attrs({
+                    affiliation: 'owner',
+                    jid: 'romeo@montague.lit/orchard',
+                    role: 'moderator'
+                }).up()
+                .c('status').attrs({code:'110'}).up()
+                .c('status').attrs({code:'210'}).nodeTree;
+            _converse.connection._dataRecv(test_utils.createRequest(presence));
+            view.model.sendMessage('hello world');
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg').length === 3);
+
+            expect(view.model.messages.last().occupant.get('affiliation')).toBe('owner');
+            expect(view.model.messages.last().occupant.get('role')).toBe('moderator');
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(3);
+            expect(sizzle('.chat-msg', view.el).pop().classList.value.trim()).toBe('message chat-msg groupchat moderator owner');
+            done();
+        }));
+
+
+        it("keeps track whether you are the sender or not",
+            mock.initConverse(
+                null, ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.api.chatviews.get(muc_jid);
+            const msg = $msg({
+                    from: 'lounge@montague.lit/romeo',
+                    id: (new Date()).getTime(),
+                    to: 'romeo@montague.lit',
+                    type: 'groupchat'
+                }).c('body').t('I wrote this message!').tree();
+            await view.model.onMessage(msg);
+            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
+            expect(view.model.messages.last().get('sender')).toBe('me');
+            done();
+        }));
+
+        it("can be replaced with a correction",
+            mock.initConverse(
+                null, ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.api.chatviews.get(muc_jid);
+            const stanza = $pres({
+                    to: 'romeo@montague.lit/_converse.js-29092160',
+                    from: 'coven@chat.shakespeare.lit/newguy'
+                })
+                .c('x', {xmlns: Strophe.NS.MUC_USER})
+                .c('item', {
+                    'affiliation': 'none',
+                    'jid': 'newguy@montague.lit/_converse.js-290929789',
+                    'role': 'participant'
+                }).tree();
+            _converse.connection._dataRecv(test_utils.createRequest(stanza));
+            const msg_id = u.getUniqueId();
+            await view.model.onMessage($msg({
+                    'from': 'lounge@montague.lit/newguy',
+                    'to': _converse.connection.jid,
+                    'type': 'groupchat',
+                    'id': msg_id,
+                }).c('body').t('But soft, what light through yonder airlock breaks?').tree());
+            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(view.el.querySelector('.chat-msg__text').textContent)
+                .toBe('But soft, what light through yonder airlock breaks?');
+
+            await view.model.onMessage($msg({
+                    'from': 'lounge@montague.lit/newguy',
+                    'to': _converse.connection.jid,
+                    'type': 'groupchat',
+                    'id': u.getUniqueId(),
+                }).c('body').t('But soft, what light through yonder chimney breaks?').up()
+                    .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+            await u.waitUntil(() => view.el.querySelector('.chat-msg__text').textContent ===
+                'But soft, what light through yonder chimney breaks?', 500);
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
+
+            await view.model.onMessage($msg({
+                    'from': 'lounge@montague.lit/newguy',
+                    'to': _converse.connection.jid,
+                    'type': 'groupchat',
+                    'id': u.getUniqueId(),
+                }).c('body').t('But soft, what light through yonder window breaks?').up()
+                    .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+
+            await u.waitUntil(() => view.el.querySelector('.chat-msg__text').textContent ===
+                'But soft, what light through yonder window breaks?', 500);
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
+            view.el.querySelector('.chat-msg__content .fa-edit').click();
+            const modal = view.model.messages.at(0).message_versions_modal;
+            await u.waitUntil(() => u.isVisible(modal.el), 1000);
+            const older_msgs = modal.el.querySelectorAll('.older-msg');
+            expect(older_msgs.length).toBe(2);
+            expect(older_msgs[0].childNodes[1].textContent).toBe(': But soft, what light through yonder airlock breaks?');
+            expect(older_msgs[0].childNodes[0].nodeName).toBe('TIME');
+            expect(older_msgs[1].childNodes[0].nodeName).toBe('TIME');
+            expect(older_msgs[1].childNodes[1].textContent).toBe(': But soft, what light through yonder chimney breaks?');
+            done();
+        }));
+
+        it("can be sent as a correction by using the up arrow",
+            mock.initConverse(
+                null, ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.api.chatviews.get(muc_jid);
+            const textarea = view.el.querySelector('textarea.chat-textarea');
+            expect(textarea.value).toBe('');
+            view.onKeyDown({
+                target: textarea,
+                keyCode: 38 // Up arrow
+            });
+            expect(textarea.value).toBe('');
+
+            textarea.value = 'But soft, what light through yonder airlock breaks?';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13 // Enter
+            });
+            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(view.el.querySelector('.chat-msg__text').textContent)
+                .toBe('But soft, what light through yonder airlock breaks?');
+
+            const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
+            expect(textarea.value).toBe('');
+            view.onKeyDown({
+                target: textarea,
+                keyCode: 38 // Up arrow
+            });
+            await new Promise((resolve, reject) => view.model.messages.once('rendered', resolve));
+            expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
+            expect(view.model.messages.at(0).get('correcting')).toBe(true);
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(u.hasClass('correcting', view.el.querySelector('.chat-msg'))).toBe(true);
+
+            spyOn(_converse.connection, 'send');
+            textarea.value = 'But soft, what light through yonder window breaks?';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13 // Enter
+            });
+            expect(_converse.connection.send).toHaveBeenCalled();
+            await new Promise((resolve, reject) => view.model.messages.once('rendered', resolve));
+
+            const msg = _converse.connection.send.calls.all()[0].args[0];
+            expect(msg.toLocaleString())
+            .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
+                    `to="lounge@montague.lit" type="groupchat" `+
+                    `xmlns="jabber:client">`+
+                        `<body>But soft, what light through yonder window breaks?</body>`+
+                        `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                        `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
+                        `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+                `</message>`);
+
+            expect(view.model.messages.models.length).toBe(1);
+            const corrected_message = view.model.messages.at(0);
+            expect(corrected_message.get('msgid')).toBe(first_msg.get('msgid'));
+            expect(corrected_message.get('correcting')).toBe(false);
+
+            const older_versions = corrected_message.get('older_versions');
+            const keys = Object.keys(older_versions);
+            expect(keys.length).toBe(1);
+            expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?');
+
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(u.hasClass('correcting', view.el.querySelector('.chat-msg'))).toBe(false);
+
+            // Check that messages from other users are skipped
+            await view.model.onMessage($msg({
+                'from': muc_jid+'/someone-else',
+                'id': (new Date()).getTime(),
+                'to': 'romeo@montague.lit',
+                'type': 'groupchat'
+            }).c('body').t('Hello world').tree());
+            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
+
+            // Test that pressing the down arrow cancels message correction
+            expect(textarea.value).toBe('');
+            view.onKeyDown({
+                target: textarea,
+                keyCode: 38 // Up arrow
+            });
+            expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+            expect(view.model.messages.at(0).get('correcting')).toBe(true);
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
+            await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
+            expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+            view.onKeyDown({
+                target: textarea,
+                keyCode: 40 // Down arrow
+            });
+            expect(textarea.value).toBe('');
+            expect(view.model.messages.at(0).get('correcting')).toBe(false);
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
+            await u.waitUntil(() => !u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
+            done();
+        }));
+
+        it("will be shown as received upon MUC reflection",
+            mock.initConverse(
+                null, ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await test_utils.waitForRoster(_converse, 'current');
+            const muc_jid = 'lounge@montague.lit';
+            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.api.chatviews.get(muc_jid);
+            const textarea = view.el.querySelector('textarea.chat-textarea');
+            textarea.value = 'But soft, what light through yonder airlock breaks?';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13 // Enter
+            });
+            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
+            expect(view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length).toBe(0);
+
+            const msg_obj = view.model.messages.at(0);
+            const stanza = u.toStanza(`
+                <message xmlns="jabber:client"
+                         from="${msg_obj.get('from')}"
+                         to="${_converse.connection.jid}"
+                         type="groupchat">
+                    <msg_body>${msg_obj.get('message')}</msg_body>
+                    <stanza-id xmlns="urn:xmpp:sid:0"
+                               id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
+                               by="lounge@montague.lit"/>
+                    <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/>
+                </message>`);
+            await view.model.onMessage(stanza);
+            await u.waitUntil(() => view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500);
+            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+            expect(view.el.querySelectorAll('.chat-msg__body.chat-msg__body--received').length).toBe(1);
+            expect(view.model.messages.length).toBe(1);
+
+            const message = view.model.messages.at(0);
+            expect(message.get('stanza_id lounge@montague.lit')).toBe('5f3dbc5e-e1d3-4077-a492-693f3769c7ad');
+            expect(message.get('origin_id')).toBe(msg_obj.get('origin_id'));
+            done();
+        }));
+
+        it("gets updated with its stanza-id upon MUC reflection",
+            mock.initConverse(
+                null, ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            const muc_jid = 'room@muc.example.com';
+            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.api.chatviews.get(muc_jid);
+
+            view.model.sendMessage('hello world');
+            await u.waitUntil(() => _converse.api.chats.get().length);
+            await u.waitUntil(() => view.model.messages.length === 1);
+            const msg = view.model.messages.at(0);
+            expect(msg.get('stanza_id')).toBeUndefined();
+            expect(msg.get('origin_id')).toBe(msg.get('origin_id'));
+
+            const stanza = u.toStanza(`
+                <message xmlns="jabber:client"
+                         from="room@muc.example.com/romeo"
+                         to="${_converse.connection.jid}"
+                         type="groupchat">
+                    <body>Hello world</body>
+                    <stanza-id xmlns="urn:xmpp:sid:0"
+                               id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"
+                               by="room@muc.example.com"/>
+                    <origin-id xmlns="urn:xmpp:sid:0" id="${msg.get('origin_id')}"/>
+                </message>`);
+            spyOn(view.model, 'updateMessage').and.callThrough();
+            _converse.connection._dataRecv(test_utils.createRequest(stanza));
+            await u.waitUntil(() => view.model.updateMessage.calls.count() === 1);
+            expect(view.model.messages.length).toBe(1);
+            expect(view.model.messages.at(0).get('stanza_id room@muc.example.com')).toBe("5f3dbc5e-e1d3-4077-a492-693f3769c7ad");
+            expect(view.model.messages.at(0).get('origin_id')).toBe(msg.get('origin_id'));
+            done();
+        }));
+
+        it("can cause a delivery receipt to be returned",
+            mock.initConverse(
+                null, ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await test_utils.waitForRoster(_converse, 'current');
+            const muc_jid = 'lounge@montague.lit';
+            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.api.chatviews.get(muc_jid);
+            const textarea = view.el.querySelector('textarea.chat-textarea');
+            textarea.value = 'But soft, what light through yonder airlock breaks?';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13 // Enter
+            });
+            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+
+            const msg_obj = view.model.messages.at(0);
+            const stanza = u.toStanza(`
+                <message xml:lang="en" to="romeo@montague.lit/orchard"
+                         from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
+                    <received xmlns="urn:xmpp:receipts" id="${msg_obj.get('msgid')}"/>
+                    <origin-id xmlns="urn:xmpp:sid:0" id="CE08D448-5ED8-4B6A-BB5B-07ED9DFE4FF0"/>
+                </message>`);
+            spyOn(_converse.api, "trigger").and.callThrough();
+            spyOn(view.model, "isReceipt").and.callThrough();
+            _converse.connection._dataRecv(test_utils.createRequest(stanza));
+            await u.waitUntil(() => view.model.isReceipt.calls.count() === 1);
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+            expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+            done();
+        }));
+
+        it("can cause a chat marker to be returned",
+            mock.initConverse(
+                null, ['rosterGroupsFetched'], {},
+                async function (done, _converse) {
+
+            await test_utils.waitForRoster(_converse, 'current');
+            const muc_jid = 'lounge@montague.lit';
+            await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.api.chatviews.get(muc_jid);
+            const textarea = view.el.querySelector('textarea.chat-textarea');
+            textarea.value = 'But soft, what light through yonder airlock breaks?';
+            view.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13 // Enter
+            });
+            await new Promise((resolve, reject) => view.once('messageInserted', resolve));
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(view.el.querySelector('.chat-msg .chat-msg__body').textContent.trim())
+                .toBe("But soft, what light through yonder airlock breaks?");
+
+            const msg_obj = view.model.messages.at(0);
+            let stanza = u.toStanza(`
+                <message xml:lang="en" to="romeo@montague.lit/orchard"
+                         from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
+                    <received xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/>
+                </message>`);
+            spyOn(view.model, "isChatMarker").and.callThrough();
+            _converse.connection._dataRecv(test_utils.createRequest(stanza));
+            await u.waitUntil(() => view.model.isChatMarker.calls.count() === 1);
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+
+            stanza = u.toStanza(`
+                <message xml:lang="en" to="romeo@montague.lit/orchard"
+                         from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
+                    <displayed xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/>
+                </message>`);
+            _converse.connection._dataRecv(test_utils.createRequest(stanza));
+            await u.waitUntil(() => view.model.isChatMarker.calls.count() === 2);
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+
+            stanza = u.toStanza(`
+                <message xml:lang="en" to="romeo@montague.lit/orchard"
+                         from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
+                    <acknowledged xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/>
+                </message>`);
+            _converse.connection._dataRecv(test_utils.createRequest(stanza));
+
+            await u.waitUntil(() => view.model.isChatMarker.calls.count() === 3);
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+
+            stanza = u.toStanza(`
+                <message xml:lang="en" to="romeo@montague.lit/orchard"
+                         from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client">
+                    <body>'tis I!</body>
+                    <markable xmlns="urn:xmpp:chat-markers:0"/>
+                </message>`);
+            _converse.connection._dataRecv(test_utils.createRequest(stanza));
+            await u.waitUntil(() => view.model.isChatMarker.calls.count() === 4);
+            expect(view.el.querySelectorAll('.chat-msg').length).toBe(2);
+            expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(0);
+            done();
+        }));
+
+        describe("when received", function () {
+
+            it("highlights all users mentioned via XEP-0372 references",
+                mock.initConverse(
+                    null, ['rosterGroupsFetched'], {},
+                    async function (done, _converse) {
+
+                const muc_jid = 'lounge@montague.lit';
+                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'tom');
+                const view = _converse.api.chatviews.get(muc_jid);
+                ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
+                    _converse.connection._dataRecv(test_utils.createRequest(
+                        $pres({
+                            'to': 'tom@montague.lit/resource',
+                            'from': `lounge@montague.lit/${nick}`
+                        })
+                        .c('x', {xmlns: Strophe.NS.MUC_USER})
+                        .c('item', {
+                            'affiliation': 'none',
+                            'jid': `${nick}@montague.lit/resource`,
+                            'role': 'participant'
+                        }))
+                    );
+                });
+                const msg = $msg({
+                        from: 'lounge@montague.lit/gibson',
+                        id: (new Date()).getTime(),
+                        to: 'romeo@montague.lit',
+                        type: 'groupchat'
+                    }).c('body').t('hello z3r0 tom mr.robot, how are you?').up()
+                        .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'6', 'end':'10', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up()
+                        .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'11', 'end':'14', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up()
+                        .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'15', 'end':'23', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree;
+                await view.model.onMessage(msg);
+                await new Promise((resolve, reject) => view.once('messageInserted', resolve));
+                const messages = view.el.querySelectorAll('.chat-msg__text');
+                expect(messages.length).toBe(1);
+                expect(messages[0].classList.length).toEqual(1);
+                expect(messages[0].innerHTML).toBe(
+                    'hello <span class="mention">z3r0</span> '+
+                    '<span class="mention mention--self badge badge-info">tom</span> '+
+                    '<span class="mention">mr.robot</span>, how are you?');
+                done();
+            }));
+        });
+
+        describe("in which someone is mentioned", function () {
+
+            it("gets parsed for mentions which get turned into references",
+                mock.initConverse(
+                    null, ['rosterGroupsFetched'], {},
+                    async function (done, _converse) {
+
+                const muc_jid = 'lounge@montague.lit';
+                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'tom');
+                const view = _converse.api.chatviews.get(muc_jid);
+                ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh', 'Link Mauve'].forEach((nick) => {
+                    _converse.connection._dataRecv(test_utils.createRequest(
+                        $pres({
+                            'to': 'tom@montague.lit/resource',
+                            'from': `lounge@montague.lit/${nick}`
+                        })
+                        .c('x', {xmlns: Strophe.NS.MUC_USER})
+                        .c('item', {
+                            'affiliation': 'none',
+                            'jid': `${nick.replace(/\s/g, '-')}@montague.lit/resource`,
+                            'role': 'participant'
+                        })));
+                });
+
+                // Run a few unit tests for the parseTextForReferences method
+                let [text, references] = view.model.parseTextForReferences('hello z3r0')
+                expect(references.length).toBe(0);
+                expect(text).toBe('hello z3r0');
+
+                [text, references] = view.model.parseTextForReferences('hello @z3r0')
+                expect(references.length).toBe(1);
+                expect(text).toBe('hello z3r0');
+                expect(JSON.stringify(references))
+                    .toBe('[{"begin":6,"end":10,"value":"z3r0","type":"mention","uri":"xmpp:z3r0@montague.lit"}]');
+
+                [text, references] = view.model.parseTextForReferences('hello @some1 @z3r0 @gibson @mr.robot, how are you?')
+                expect(text).toBe('hello @some1 z3r0 gibson mr.robot, how are you?');
+                expect(JSON.stringify(references))
+                    .toBe('[{"begin":13,"end":17,"value":"z3r0","type":"mention","uri":"xmpp:z3r0@montague.lit"},'+
+                            '{"begin":18,"end":24,"value":"gibson","type":"mention","uri":"xmpp:gibson@montague.lit"},'+
+                            '{"begin":25,"end":33,"value":"mr.robot","type":"mention","uri":"xmpp:mr.robot@montague.lit"}]');
+
+                [text, references] = view.model.parseTextForReferences('yo @gib')
+                expect(text).toBe('yo @gib');
+                expect(references.length).toBe(0);
+
+                [text, references] = view.model.parseTextForReferences('yo @gibsonian')
+                expect(text).toBe('yo @gibsonian');
+                expect(references.length).toBe(0);
+
+                [text, references] = view.model.parseTextForReferences('@gibson')
+                expect(text).toBe('gibson');
+                expect(references.length).toBe(1);
+                expect(JSON.stringify(references))
+                    .toBe('[{"begin":0,"end":6,"value":"gibson","type":"mention","uri":"xmpp:gibson@montague.lit"}]');
+
+                [text, references] = view.model.parseTextForReferences('hi @Link Mauve how are you?')
+                expect(text).toBe('hi Link Mauve how are you?');
+                expect(references.length).toBe(1);
+                expect(JSON.stringify(references))
+                    .toBe('[{"begin":3,"end":13,"value":"Link Mauve","type":"mention","uri":"xmpp:Link-Mauve@montague.lit"}]');
+
+                [text, references] = view.model.parseTextForReferences('https://example.org/@gibson')
+                expect(text).toBe('https://example.org/@gibson');
+                expect(references.length).toBe(0);
+                expect(JSON.stringify(references))
+                    .toBe('[]');
+
+                [text, references] = view.model.parseTextForReferences('mail@gibson.com')
+                expect(text).toBe('mail@gibson.com');
+                expect(references.length).toBe(0);
+                expect(JSON.stringify(references))
+                    .toBe('[]');
+
+                [text, references] = view.model.parseTextForReferences(
+                    'https://linkmauve.fr@Link Mauve/ https://linkmauve.fr/@github/is_back gibson@gibson.com gibson@Link Mauve.fr')
+                expect(text).toBe(
+                    'https://linkmauve.fr@Link Mauve/ https://linkmauve.fr/@github/is_back gibson@gibson.com gibson@Link Mauve.fr');
+                expect(references.length).toBe(0);
+                expect(JSON.stringify(references))
+                    .toBe('[]');
+                done();
+            }));
+
+            it("parses for mentions as indicated with an @ preceded by a space or at the start of the text",
+                mock.initConverse(
+                    null, ['rosterGroupsFetched'], {},
+                    async function (done, _converse) {
+
+                const muc_jid = 'lounge@montague.lit';
+                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'tom');
+                const view = _converse.api.chatviews.get(muc_jid);
+                ['NotAnAdress', 'darnuria'].forEach((nick) => {
+                    _converse.connection._dataRecv(test_utils.createRequest(
+                        $pres({
+                            'to': 'tom@montague.lit/resource',
+                            'from': `lounge@montague.lit/${nick}`
+                        })
+                        .c('x', {xmlns: Strophe.NS.MUC_USER})
+                        .c('item', {
+                            'affiliation': 'none',
+                            'jid': `${nick.replace(/\s/g, '-')}@montague.lit/resource`,
+                            'role': 'participant'
+                        })));
+                });
+
+                // Test that we don't match @nick in email adresses.
+                let [text, references] = view.model.parseTextForReferences('contact contact@NotAnAdress.eu');
+                expect(references.length).toBe(0);
+                expect(text).toBe('contact contact@NotAnAdress.eu');
+
+                // Test that we don't match @nick in url
+                [text, references] = view.model.parseTextForReferences('nice website https://darnuria.eu/@darnuria');
+                expect(references.length).toBe(0);
+                expect(text).toBe('nice website https://darnuria.eu/@darnuria');
+
+                done();
+            }));
+
+            it("can get corrected and given new references",
+                mock.initConverse(
+                    null, ['rosterGroupsFetched'], {},
+                    async function (done, _converse) {
+
+                const muc_jid = 'lounge@montague.lit';
+                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'tom');
+                const view = _converse.api.chatviews.get(muc_jid);
+                ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
+                    _converse.connection._dataRecv(test_utils.createRequest(
+                        $pres({
+                            'to': 'tom@montague.lit/resource',
+                            'from': `lounge@montague.lit/${nick}`
+                        })
+                        .c('x', {xmlns: Strophe.NS.MUC_USER})
+                        .c('item', {
+                            'affiliation': 'none',
+                            'jid': `${nick}@montague.lit/resource`,
+                            'role': 'participant'
+                        })));
+                });
+
+                const textarea = view.el.querySelector('textarea.chat-textarea');
+                textarea.value = 'hello @z3r0 @gibson @mr.robot, how are you?'
+                const enter_event = {
+                    'target': textarea,
+                    'preventDefault': function preventDefault () {},
+                    'stopPropagation': function stopPropagation () {},
+                    'keyCode': 13 // Enter
+                }
+                spyOn(_converse.connection, 'send');
+                view.onKeyDown(enter_event);
+                await new Promise((resolve, reject) => view.once('messageInserted', resolve));
+                const msg = _converse.connection.send.calls.all()[0].args[0];
+                expect(msg.toLocaleString())
+                    .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
+                            `to="lounge@montague.lit" type="groupchat" `+
+                            `xmlns="jabber:client">`+
+                                `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
+                                `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                                `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                                `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                                `<reference begin="18" end="26" type="mention" uri="xmpp:mr.robot@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                                `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+                            `</message>`);
+
+                const first_msg = view.model.messages.findWhere({'message': 'hello z3r0 gibson mr.robot, how are you?'});
+                const action = view.el.querySelector('.chat-msg .chat-msg__action');
+                action.style.opacity = 1;
+                action.click();
+
+                expect(textarea.value).toBe('hello @z3r0 @gibson @mr.robot, how are you?');
+                expect(view.model.messages.at(0).get('correcting')).toBe(true);
+                expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
+                await u.waitUntil(() => u.hasClass('correcting', view.el.querySelector('.chat-msg')), 500);
+
+                textarea.value = 'hello @z3r0 @gibson @sw0rdf1sh, how are you?';
+                view.onKeyDown(enter_event);
+                await u.waitUntil(() => view.el.querySelector('.chat-msg__text').textContent ===
+                    'hello z3r0 gibson sw0rdf1sh, how are you?', 500);
+
+                const correction = _converse.connection.send.calls.all()[2].args[0];
+                expect(correction.toLocaleString())
+                    .toBe(`<message from="romeo@montague.lit/orchard" id="${correction.nodeTree.getAttribute("id")}" `+
+                            `to="lounge@montague.lit" type="groupchat" `+
+                            `xmlns="jabber:client">`+
+                                `<body>hello z3r0 gibson sw0rdf1sh, how are you?</body>`+
+                                `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                                `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                                `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                                `<reference begin="18" end="27" type="mention" uri="xmpp:sw0rdf1sh@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                                `<replace id="${msg.nodeTree.getAttribute("id")}" xmlns="urn:xmpp:message-correct:0"/>`+
+                                `<origin-id id="${correction.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+                            `</message>`);
+                done();
+            }));
+
+            it("includes XEP-0372 references to that person",
+                mock.initConverse(
+                    null, ['rosterGroupsFetched'], {},
+                        async function (done, _converse) {
+
+                const muc_jid = 'lounge@montague.lit';
+                await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+                const view = _converse.api.chatviews.get(muc_jid);
+                ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
+                    _converse.connection._dataRecv(test_utils.createRequest(
+                        $pres({
+                            'to': 'tom@montague.lit/resource',
+                            'from': `lounge@montague.lit/${nick}`
+                        })
+                        .c('x', {xmlns: Strophe.NS.MUC_USER})
+                        .c('item', {
+                            'affiliation': 'none',
+                            'jid': `${nick}@montague.lit/resource`,
+                            'role': 'participant'
+                        })));
+                });
+
+                spyOn(_converse.connection, 'send');
+                const textarea = view.el.querySelector('textarea.chat-textarea');
+                textarea.value = 'hello @z3r0 @gibson @mr.robot, how are you?'
+                const enter_event = {
+                    'target': textarea,
+                    'preventDefault': function preventDefault () {},
+                    'stopPropagation': function stopPropagation () {},
+                    'keyCode': 13 // Enter
+                }
+                view.onKeyDown(enter_event);
+
+                const msg = _converse.connection.send.calls.all()[0].args[0];
+                expect(msg.toLocaleString())
+                    .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
+                            `to="lounge@montague.lit" type="groupchat" `+
+                            `xmlns="jabber:client">`+
+                                `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
+                                `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                                `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                                `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                                `<reference begin="18" end="26" type="mention" uri="xmpp:mr.robot@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                                `<origin-id id="${msg.nodeTree.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+                            `</message>`);
+                done();
+            }));
+        });
+    });
+}));

+ 1 - 0
tests/runner.js

@@ -54,6 +54,7 @@ var specs = [
     "spec/chatbox",
     "spec/chatbox",
     "spec/user-details-modal",
     "spec/user-details-modal",
     "spec/messages",
     "spec/messages",
+    "spec/muc_messages",
     "spec/muc",
     "spec/muc",
     "spec/modtools",
     "spec/modtools",
     "spec/room_registration",
     "spec/room_registration",