2
0
Эх сурвалжийг харах

Handle and render chat state notifications separately from messages

JC Brand 5 жил өмнө
parent
commit
b5d57f0ef8

+ 11 - 0
sass/_chatbox.scss

@@ -230,10 +230,21 @@
                 width: 100%
             }
         }
+
         .chat-content-sendbutton {
             height: calc(100% - (var(--chat-textarea-height) + var(--send-button-height) + 2 * var(--send-button-margin)));
         }
 
+        .chat-state-notifications {
+            white-space: pre;
+            background-color: var(--chat-content-background-color);
+            color: var(--subdued-color);
+            font-size: 90%;
+            font-style: italic;
+            line-height: var(--line-height-small);
+            padding: 0 1em 0.3em;
+        }
+
         .dropdown { /* status dropdown styles */
             background-color: var(--light-background-color);
             dd {

+ 60 - 68
spec/chatbox.js

@@ -686,31 +686,37 @@
                                 id: u.getUniqueId()
                             }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
 
-                        spyOn(_converse.api, "trigger").and.callThrough();
                         _converse.connection._dataRecv(test_utils.createRequest(msg));
-                        await u.waitUntil(() => _converse.api.trigger.calls.count());
-                        expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
                         const view = _converse.chatboxviews.get(sender_jid);
-                        expect(view).toBeDefined();
+                        let csn = mock.cur_names[1] + ' is typing';
+                        await u.waitUntil( () => view.el.querySelector('.chat-state-notifications').innerText === csn);
+                        expect(view.model.messages.length).toEqual(0);
 
-                        const event = await u.waitUntil(() => view.el.querySelector('.chat-state-notification'));
-                        expect(event.textContent).toEqual(mock.cur_names[1] + ' is typing');
+                        // <paused> state
+                        msg = $msg({
+                                from: sender_jid,
+                                to: _converse.connection.jid,
+                                type: 'chat',
+                                id: u.getUniqueId()
+                            }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+                        _converse.connection._dataRecv(test_utils.createRequest(msg));
+                        csn = mock.cur_names[1] + ' has stopped typing';
+                        await u.waitUntil( () => view.el.querySelector('.chat-state-notifications').innerText === csn);
 
-                        // Check that it doesn't appear twice
                         msg = $msg({
                                 from: sender_jid,
                                 to: _converse.connection.jid,
                                 type: 'chat',
                                 id: u.getUniqueId()
-                            }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+                            }).c('body').t('hello world').tree();
                         await _converse.handleMessageStanza(msg);
-                        const events = view.el.querySelectorAll('.chat-state-notification');
-                        expect(events.length).toBe(1);
-                        expect(events[0].textContent).toEqual(mock.cur_names[1] + ' is typing');
+                        const msg_el = await u.waitUntil(() => view.content.querySelector('.chat-msg'));
+                        await u.waitUntil( () => view.el.querySelector('.chat-state-notifications').innerText === '');
+                        expect(msg_el.querySelector('.chat-msg__text').textContent).toBe('hello world');
                         done();
                     }));
 
-                    it("can be a composing carbon message that this user sent from a different client",
+                    it("is ignored if it's a composing carbon message sent by this user from a different client",
                         mock.initConverse(
                             ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                             async function (done, _converse) {
@@ -721,6 +727,9 @@
                         // Send a message from a different resource
                         const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
                         const view = await test_utils.openChatBoxFor(_converse, recipient_jid);
+
+                        spyOn(u, 'shouldCreateMessage').and.callThrough();
+
                         const msg = $msg({
                                 'from': _converse.bare_jid,
                                 'id': u.getUniqueId(),
@@ -735,20 +744,12 @@
                                     'to': recipient_jid,
                                     'type': 'chat'
                             }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        await _converse.handleMessageStanza(msg);
-                        await u.waitUntil(() => view.model.messages.length);
-                        // Check that the chatbox and its view now exist
-                        const chatbox = _converse.chatboxes.get(recipient_jid);
-                        const chatboxview = _converse.chatboxviews.get(recipient_jid);
-                        // Check that the message was received and check the message parameters
-                        expect(chatbox.messages.length).toEqual(1);
-                        const msg_obj = chatbox.messages.models[0];
-                        expect(msg_obj.get('sender')).toEqual('me');
-                        expect(msg_obj.get('is_delayed')).toEqual(false);
-                        const chat_content = chatboxview.el.querySelector('.chat-content');
-                        const el = await u.waitUntil(() => chat_content.querySelector('.chat-info.chat-state-notification'));
-                        const status_text = el.textContent;
-                        expect(status_text).toBe('Typing from another device');
+                        _converse.connection._dataRecv(test_utils.createRequest(msg));
+
+                        await u.waitUntil(() => u.shouldCreateMessage.calls.count());
+                        expect(view.model.messages.length).toEqual(0);
+                        const el = view.el.querySelector('.chat-state-notifications');
+                        expect(el.textContent).toBe('');
                         done();
                     }));
                 });
@@ -829,15 +830,15 @@
                                 type: 'chat',
                                 id: u.getUniqueId()
                             }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        await _converse.handleMessageStanza(msg);
-                        expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
-                        await u.waitUntil(() => view.model.vcard.get('fullname') === mock.cur_names[1])
-                        const event = await u.waitUntil(() => view.el.querySelector('.chat-state-notification'));
-                        expect(event.textContent).toEqual(mock.cur_names[1] + ' has stopped typing');
+
+                        _converse.connection._dataRecv(test_utils.createRequest(msg));
+                        const csn = mock.cur_names[1] +  ' has stopped typing';
+                        await u.waitUntil( () => view.el.querySelector('.chat-state-notifications').innerText === csn);
+                        expect(view.model.messages.length).toEqual(0);
                         done();
                     }));
 
-                    it("can be a paused carbon message that this user sent from a different client",
+                    it("will not be shown if it's a paused carbon message that this user sent from a different client",
                         mock.initConverse(
                             ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                             async function (done, _converse) {
@@ -847,6 +848,7 @@
                         await test_utils.waitForRoster(_converse, 'current');
                         // Send a message from a different resource
                         const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+                        spyOn(u, 'shouldCreateMessage').and.callThrough();
                         const view = await test_utils.openChatBoxFor(_converse, recipient_jid);
                         const msg = $msg({
                                 'from': _converse.bare_jid,
@@ -862,18 +864,12 @@
                                     'to': recipient_jid,
                                     'type': 'chat'
                             }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        await _converse.handleMessageStanza(msg);
-                        await u.waitUntil(() => view.model.messages.length);
-                        // Check that the chatbox and its view now exist
-                        const chatbox = _converse.chatboxes.get(recipient_jid);
-                        const chatboxview = _converse.chatboxviews.get(recipient_jid);
-                        // Check that the message was received and check the message parameters
-                        expect(chatbox.messages.length).toEqual(1);
-                        const msg_obj = chatbox.messages.models[0];
-                        expect(msg_obj.get('sender')).toEqual('me');
-                        expect(msg_obj.get('is_delayed')).toEqual(false);
-                        const el = await u.waitUntil(() => chatboxview.el.querySelector('.chat-info.chat-state-notification'));
-                        expect(el.textContent).toBe('Stopped typing on the other device');
+                        _converse.connection._dataRecv(test_utils.createRequest(msg));
+                        await u.waitUntil(() => u.shouldCreateMessage.calls.count());
+                        expect(view.model.messages.length).toEqual(0);
+                        const el = view.el.querySelector('.chat-state-notifications');
+                        expect(el.textContent).toBe('');
+                        done();
                         done();
                     }));
                 });
@@ -997,7 +993,6 @@
                         await test_utils.openControlBox(_converse);
                         const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
                         // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
-                        spyOn(_converse.api, "trigger").and.callThrough();
                         await test_utils.openChatBoxFor(_converse, sender_jid);
                         const view = _converse.chatboxviews.get(sender_jid);
                         expect(view.el.querySelectorAll('.chat-event').length).toBe(0);
@@ -1011,20 +1006,20 @@
                                 'type': 'chat'})
                             .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
                             .tree();
-                        await _converse.handleMessageStanza(msg);
-                        await u.waitUntil(() => view.model.messages.length);
-                        await u.waitUntil(() => view.el.querySelector('.chat-state-notification'));
-                        expect(view.el.querySelectorAll('.chat-state-notification').length).toBe(1);
+                        _converse.connection._dataRecv(test_utils.createRequest(msg));
+                        const csntext = await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent);
+                        expect(csntext).toEqual(mock.cur_names[1] + ' is typing');
+                        expect(view.model.messages.length).toBe(0);
+
                         msg = $msg({
                                 from: sender_jid,
                                 to: _converse.connection.jid,
                                 type: 'chat',
                                 id: u.getUniqueId()
                             }).c('inactive', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        await _converse.handleMessageStanza(msg);
-                        await u.waitUntil(() => (view.model.messages.length > 1));
-                        expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
-                        await u.waitUntil(() => view.el.querySelectorAll('.chat-state-notification').length === 0);
+                        _converse.connection._dataRecv(test_utils.createRequest(msg));
+
+                        await u.waitUntil(() => !view.el.querySelector('.chat-state-notifications').textContent);
                         done();
                     }));
                 });
@@ -1038,28 +1033,27 @@
 
                         await test_utils.waitForRoster(_converse, 'current', 3);
                         await test_utils.openControlBox(_converse);
-
-                        spyOn(_converse.api, "trigger").and.callThrough();
                         const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                        // <paused> state
+                        await test_utils.openChatBoxFor(_converse, sender_jid);
+
                         const msg = $msg({
                                 from: sender_jid,
                                 to: _converse.connection.jid,
                                 type: 'chat',
                                 id: u.getUniqueId()
                             }).c('body').c('gone', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        await _converse.handleMessageStanza(msg);
-                        expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+                        _converse.connection._dataRecv(test_utils.createRequest(msg));
+
                         const view = _converse.chatboxviews.get(sender_jid);
-                        await u.waitUntil(() => view.model.vcard.get('fullname') === mock.cur_names[1]);
-                        const event = await u.waitUntil(() => view.el.querySelector('.chat-state-notification'));
-                        expect(event.textContent).toEqual(mock.cur_names[1] + ' has gone away');
+                        const csntext = await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent);
+                        expect(csntext).toEqual(mock.cur_names[1] + ' has gone away');
                         done();
                     }));
                 });
 
                 describe("On receiving a message correction", function () {
-                    it("will be updated",
+
+                    it("will be removed",
                         mock.initConverse(
                             ['rosterGroupsFetched'], {},
                             async function (done, _converse) {
@@ -1096,10 +1090,10 @@
                             type: 'chat',
                             id: u.getUniqueId()
                         }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+                        _converse.connection._dataRecv(test_utils.createRequest(msg));
 
-                        await _converse.handleMessageStanza(msg);
-                        const event = await u.waitUntil(() => view.el.querySelector('.chat-state-notification'));
-                        expect(event.textContent).toEqual(mock.cur_names[1] + ' is typing');
+                        const csntext = await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent);
+                        expect(csntext).toEqual(mock.cur_names[1] + ' is typing');
 
                         // Edited message
                         const edited = $msg({
@@ -1113,9 +1107,7 @@
                             .c('replace', {'xmlns': Strophe.NS.MESSAGE_CORRECT, 'id': original_id }).tree();
 
                         await _converse.handleMessageStanza(edited);
-
-                        const events = view.el.querySelectorAll('.chat-state-notification');
-                        expect(events.length).toBe(0);
+                        await u.waitUntil(() => !view.el.querySelector('.chat-state-notifications').textContent);
                         done();
                     }));
                 });

+ 1 - 14
spec/messages.js

@@ -1108,21 +1108,8 @@
                 .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
             await new Promise(resolve => view.once('messageInserted', resolve));
 
-            jasmine.clock().tick(1000);
-            // Insert <composing> message, to also check that
-            // text messages are inserted correctly with
-            // temporary chat events in the chat contents.
-            _converse.handleMessageStanza($msg({
-                    'id': 'aeb219',
-                    'to': _converse.bare_jid,
-                    'xmlns': 'jabber:client',
-                    'from': sender_jid,
-                    'type': 'chat'})
-                .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
-                .tree());
-            await new Promise(resolve => view.once('messageInserted', resolve));
-
             jasmine.clock().tick(1*ONE_MINUTE_LATER);
+
             _converse.handleMessageStanza($msg({
                     'from': sender_jid,
                     'to': _converse.connection.jid,

+ 45 - 123
spec/muc.js

@@ -5063,7 +5063,11 @@
                         async function (done, _converse) {
 
                     const muc_jid = 'coven@chat.shakespeare.lit';
-                    await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'some1');
+                    const members = [
+                        {'affiliation': 'member', 'nick': 'majortom', 'jid': 'majortom@example.org'},
+                        {'affiliation': 'admin', 'nick': 'groundcontrol', 'jid': 'groundcontrol@example.org'}
+                    ];
+                    await test_utils.openAndEnterChatRoom(_converse, muc_jid, 'some1', [], members);
                     const view = _converse.api.chatviews.get(muc_jid);
                     const chat_content = view.el.querySelector('.chat-content');
 
@@ -5102,6 +5106,13 @@
 
                     // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
 
+                    const timeout_functions = [];
+                    spyOn(window, 'setTimeout').and.callFake(f => {
+                        if (f.toString() === "() => this.removeCSNFor(actor, state)") {
+                            timeout_functions.push(f)
+                        }
+                    });
+
                     // <composing> state
                     let msg = $msg({
                             from: muc_jid+'/newguy',
@@ -5109,63 +5120,49 @@
                             to: 'romeo@montague.lit',
                             type: 'groupchat'
                         }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+                    _converse.connection._dataRecv(test_utils.createRequest(msg));
 
-                    await view.model.queueMessage(msg);
-                    await u.waitUntil(() => view.el.querySelectorAll('.chat-state-notification').length);
+                    const csntext = await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent);
+                    expect(csntext.trim()).toEqual('newguy is typing');
+                    expect(timeout_functions.length).toBe(1);
 
                     // Check that the notification appears inside the chatbox in the DOM
-                    let events = view.el.querySelectorAll('.chat-event');
+                    const events = view.el.querySelectorAll('.chat-event');
                     expect(events.length).toBe(3);
                     expect(events[0].textContent.trim()).toEqual('some1 has entered the groupchat');
                     expect(events[1].textContent.trim()).toEqual('newguy has entered the groupchat');
                     expect(events[2].textContent.trim()).toEqual('nomorenicks has entered the groupchat');
+                    expect(view.el.querySelector('.chat-state-notifications').textContent.trim()).toEqual('newguy is typing');
 
-                    let notifications = view.el.querySelectorAll('.chat-state-notification');
-                    expect(notifications.length).toBe(1);
-                    expect(notifications[0].textContent.trim()).toEqual('newguy is typing');
-
-                    const timeout_functions = [];
-                    spyOn(window, 'setTimeout').and.callFake(f => timeout_functions.push(f));
-
-                    // Check that it doesn't appear twice
+                    // <composing> state for a different occupant
                     msg = $msg({
-                            from: muc_jid+'/newguy',
+                            from: muc_jid+'/nomorenicks',
                             id: u.getUniqueId(),
                             to: 'romeo@montague.lit',
                             type: 'groupchat'
                         }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
                     await view.model.queueMessage(msg);
-
-                    events = view.el.querySelectorAll('.chat-event');
-                    expect(events.length).toBe(3);
-                    expect(events[0].textContent.trim()).toEqual('some1 has entered the groupchat');
-                    expect(events[1].textContent.trim()).toEqual('newguy has entered the groupchat');
-                    expect(events[2].textContent.trim()).toEqual('nomorenicks has entered the groupchat');
-
-                    notifications = view.el.querySelectorAll('.chat-state-notification');
-                    expect(notifications.length).toBe(1);
-                    expect(notifications[0].textContent.trim()).toEqual('newguy is typing');
-                    expect(timeout_functions.length).toBe(1);
+                    await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent.trim() === 'newguy and nomorenicks are typing');
 
                     // <composing> state for a different occupant
                     msg = $msg({
-                            from: muc_jid+'/nomorenicks',
+                            from: muc_jid+'/majortom',
                             id: u.getUniqueId(),
                             to: 'romeo@montague.lit',
                             type: 'groupchat'
                         }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
                     await view.model.queueMessage(msg);
-                    events = view.el.querySelectorAll('.chat-event');
-                    expect(events.length).toBe(3);
-                    expect(events[0].textContent.trim()).toEqual('some1 has entered the groupchat');
-                    expect(events[1].textContent.trim()).toEqual('newguy has entered the groupchat');
-                    expect(events[2].textContent.trim()).toEqual('nomorenicks has entered the groupchat');
+                    await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent.trim() === 'newguy, nomorenicks and majortom are typing');
 
-                    await u.waitUntil(() => (view.el.querySelectorAll('.chat-state-notification').length === 2));
-                    notifications = view.el.querySelectorAll('.chat-state-notification');
-                    expect(notifications.length).toBe(2);
-                    expect(notifications[0].textContent.trim()).toEqual('nomorenicks is typing');
-                    expect(notifications[1].textContent.trim()).toEqual('newguy is typing');
+                    // <composing> state for a different occupant
+                    msg = $msg({
+                            from: muc_jid+'/groundcontrol',
+                            id: u.getUniqueId(),
+                            to: 'romeo@montague.lit',
+                            type: 'groupchat'
+                        }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+                    await view.model.queueMessage(msg);
+                    await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent.trim() === 'newguy, nomorenicks and others are typing');
 
                     // Check that new messages appear under the chat state notifications
                     msg = $msg({
@@ -5178,32 +5175,13 @@
                     await new Promise(resolve => view.once('messageInserted', resolve));
 
                     const messages = view.el.querySelectorAll('.message');
-                    expect(messages.length).toBe(7);
+                    expect(messages.length).toBe(5);
                     expect(view.el.querySelectorAll('.chat-msg').length).toBe(1);
                     expect(view.el.querySelector('.chat-msg .chat-msg__text').textContent.trim()).toBe('hello world');
 
                     // Test that the composing notifications get removed via timeout.
                     timeout_functions[0]();
-                    events = view.el.querySelectorAll('.chat-event');
-                    expect(events.length).toBe(3);
-                    expect(events[0].textContent.trim()).toEqual('some1 has entered the groupchat');
-                    expect(events[1].textContent.trim()).toEqual('newguy has entered the groupchat');
-                    expect(events[2].textContent.trim()).toEqual('nomorenicks has entered the groupchat');
-
-                    notifications = view.el.querySelectorAll('.chat-state-notification');
-                    expect(notifications.length).toBe(1);
-                    expect(notifications[0].textContent.trim()).toEqual('nomorenicks is typing');
-
-                    timeout_functions.filter(f => f.name === 'bound safeDestroy').pop()();
-
-                    events = view.el.querySelectorAll('.chat-event');
-                    expect(events.length).toBe(3);
-                    expect(events[0].textContent.trim()).toEqual('some1 has entered the groupchat');
-                    expect(events[1].textContent.trim()).toEqual('newguy has entered the groupchat');
-                    expect(events[2].textContent.trim()).toEqual('nomorenicks has entered the groupchat');
-
-                    notifications = view.el.querySelectorAll('.chat-state-notification');
-                    expect(notifications.length).toBe(0);
+                    await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent.trim() === 'nomorenicks, majortom and groundcontrol are typing');
                     done();
                 }));
             });
@@ -5221,13 +5199,13 @@
                     const chat_content = view.el.querySelector('.chat-content');
 
                     /* <presence to="romeo@montague.lit/_converse.js-29092160"
-                        *           from="coven@chat.shakespeare.lit/some1">
-                        *      <x xmlns="http://jabber.org/protocol/muc#user">
-                        *          <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/>
-                        *          <status code="110"/>
-                        *      </x>
-                        *  </presence></body>
-                        */
+                     *           from="coven@chat.shakespeare.lit/some1">
+                     *      <x xmlns="http://jabber.org/protocol/muc#user">
+                     *          <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/>
+                     *          <status code="110"/>
+                     *      </x>
+                     *  </presence></body>
+                     */
                     let presence = $pres({
                             to: 'romeo@montague.lit/_converse.js-29092160',
                             from: 'coven@chat.shakespeare.lit/some1'
@@ -5282,37 +5260,8 @@
                             type: 'groupchat'
                         }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
                     await view.model.queueMessage(msg);
-
-                    // Check that the notification appears inside the chatbox in the DOM
-                    var events = view.el.querySelectorAll('.chat-event');
-                    expect(events.length).toBe(3);
-                    expect(events[0].textContent.trim()).toEqual('some1 has entered the groupchat');
-                    expect(events[1].textContent.trim()).toEqual('newguy has entered the groupchat');
-                    expect(events[2].textContent.trim()).toEqual('nomorenicks has entered the groupchat');
-
-                    await u.waitUntil(() => view.el.querySelectorAll('.chat-state-notification').length);
-                    let notifications = view.el.querySelectorAll('.chat-state-notification');
-                    expect(notifications.length).toBe(1);
-                    expect(notifications[0].textContent.trim()).toEqual('newguy is typing');
-
-                    // Check that it doesn't appear twice
-                    msg = $msg({
-                            from: muc_jid+'/newguy',
-                            id: u.getUniqueId(),
-                            to: 'romeo@montague.lit',
-                            type: 'groupchat'
-                        }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                    await view.model.queueMessage(msg);
-
-                    events = view.el.querySelectorAll('.chat-event');
-                    expect(events.length).toBe(3);
-                    expect(events[0].textContent.trim()).toEqual('some1 has entered the groupchat');
-                    expect(events[1].textContent.trim()).toEqual('newguy has entered the groupchat');
-                    expect(events[2].textContent.trim()).toEqual('nomorenicks has entered the groupchat');
-
-                    notifications = view.el.querySelectorAll('.chat-state-notification');
-                    expect(notifications.length).toBe(1);
-                    expect(notifications[0].textContent.trim()).toEqual('newguy is typing');
+                    await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent);
+                    expect(view.el.querySelector('.chat-state-notifications').textContent.trim()).toBe('newguy is typing');
 
                     // <composing> state for a different occupant
                     msg = $msg({
@@ -5322,20 +5271,8 @@
                             type: 'groupchat'
                         }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
                     await view.model.queueMessage(msg);
-                    events = view.el.querySelectorAll('.chat-event');
-                    expect(events.length).toBe(3);
-                    expect(events[0].textContent.trim()).toEqual('some1 has entered the groupchat');
-                    expect(events[1].textContent.trim()).toEqual('newguy has entered the groupchat');
-                    expect(events[2].textContent.trim()).toEqual('nomorenicks has entered the groupchat');
 
-                    await u.waitUntil(() => view.el.querySelectorAll('.chat-state-notification').length ===  2);
-                    notifications = view.el.querySelectorAll('.chat-state-notification');
-                    // We check for the messages' text without assuming order,
-                    // because it can be variable since getLastMessageDate
-                    // ignore CSN messages.
-                    let text = _.map(notifications, 'textContent').join(' ');
-                    expect(text.includes('newguy is typing')).toBe(true);
-                    expect(text.includes('nomorenicks is typing')).toBe(true);
+                    await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent.trim()  == 'newguy and nomorenicks are typing');
 
                     // <paused> state from occupant who typed first
                     msg = $msg({
@@ -5345,22 +5282,7 @@
                             type: 'groupchat'
                         }).c('body').c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
                     await view.model.queueMessage(msg);
-                    events = view.el.querySelectorAll('.chat-event');
-                    expect(events.length).toBe(3);
-                    expect(events[0].textContent.trim()).toEqual('some1 has entered the groupchat');
-                    expect(events[1].textContent.trim()).toEqual('newguy has entered the groupchat');
-                    expect(events[2].textContent.trim()).toEqual('nomorenicks has entered the groupchat');
-
-                    await u.waitUntil(() => {
-                        return _.map(
-                            view.el.querySelectorAll('.chat-state-notification'), 'textContent')
-                                .join(' ').includes('stopped typing')
-                    });
-                    notifications = view.el.querySelectorAll('.chat-state-notification');
-                    expect(notifications.length).toBe(2);
-                    text = _.map(notifications, 'textContent').join(' ');
-                    expect(text.includes('newguy has stopped typing')).toBe(true);
-                    expect(text.includes('nomorenicks is typing')).toBe(true);
+                    await u.waitUntil(() => view.el.querySelector('.chat-state-notifications').textContent.trim()  == 'nomorenicks is typing\n newguy has stopped typing');
                     done();
                 }));
             });

+ 17 - 23
src/converse-chatview.js

@@ -197,13 +197,14 @@ converse.plugins.add('converse-chatview', {
                 this.initDebounced();
 
                 this.listenTo(this.model.messages, 'add', this.onMessageAdded);
-                this.listenTo(this.model.messages, 'change:edited', this.onMessageEdited);
                 this.listenTo(this.model.messages, 'rendered', this.scrollDown);
                 this.model.messages.on('reset', () => {
                     this.content.innerHTML = '';
                     this.removeAll();
                 });
 
+                this.listenTo(this.model.csn, 'change', this.renderChatStateNotification);
+
                 this.listenTo(this.model, 'change:status', this.onStatusMessageChanged);
                 this.listenTo(this.model, 'destroy', this.remove);
                 this.listenTo(this.model, 'show', this.show);
@@ -248,11 +249,25 @@ converse.plugins.add('converse-chatview', {
                 );
                 render(result, this.el);
                 this.content = this.el.querySelector('.chat-content');
+                this.csn = this.el.querySelector('.chat-state-notifications');
+                this.renderChatStateNotification();
                 this.renderMessageForm();
                 this.renderHeading();
                 return this;
             },
 
+            renderChatStateNotification () {
+                if (this.model.csn.get('chat_state') === _converse.COMPOSING) {
+                    this.csn.innerText = __('%1$s is typing', this.model.getDisplayName());
+                } else if (this.model.csn.get('chat_state') === _converse.PAUSED) {
+                    this.csn.innerText = __('%1$s has stopped typing', this.model.getDisplayName());
+                } else if (this.model.csn.get('chat_state') === _converse.GONE) {
+                    this.csn.innerText = __('%1$s has gone away', this.model.getDisplayName());
+                } else {
+                    this.csn.innerText = '';
+                }
+            },
+
             renderToolbar () {
                 if (!_converse.show_toolbar) {
                     return this;
@@ -729,7 +744,6 @@ converse.plugins.add('converse-chatview', {
                 await message.initialized;
                 const view = this.add(message.get('id'), new _converse.MessageView({'model': message}));
                 await view.render();
-                this.clearChatStateForSender(message.get('from'));
                 this.insertMessage(view);
                 this.insertDayIndicator(view.el);
                 this.setScrollPosition(view.el);
@@ -741,7 +755,7 @@ converse.plugins.add('converse-chatview', {
                         // when the user writes a message as opposed to when a
                         // message is received.
                         this.model.set('scrolled', false);
-                    } else if (this.model.get('scrolled', true) && !u.isOnlyChatStateNotification(message)) {
+                    } else if (this.model.get('scrolled', true)) {
                         this.showNewMessagesIndicator();
                     }
                 }
@@ -784,16 +798,6 @@ converse.plugins.add('converse-chatview', {
                 });
             },
 
-            /**
-             * Handler that gets called when a message object has been edited via LMC.
-             * @private
-             * @method _converse.ChatBoxView#onMessageEdited
-             * @param { object } message - The updated message object.
-             */
-            onMessageEdited (message) {
-                this.clearChatStateForSender(message.get('from'));
-            },
-
             parseMessageForCommands (text) {
                 const match = text.replace(/^\s*/, "").match(/^\/(.*)\s*$/);
                 if (match) {
@@ -1086,16 +1090,6 @@ converse.plugins.add('converse-chatview', {
                 return this;
             },
 
-            /**
-             * Remove chat state notifications for a given sender JID.
-             * @private
-             * @method _converse.ChatBoxView#clearChatStateForSender
-             * @param {string} sender - The sender of the chat state
-             */
-            clearChatStateForSender (sender) {
-                sizzle(`.chat-state-notification[data-csn="${sender}"]`, this.content).forEach(u.removeElement);
-            },
-
             /**
              * Insert a particular string value into the textarea of this chat box.
              * @private

+ 1 - 36
src/converse-message-view.js

@@ -12,7 +12,6 @@ import { debounce } from 'lodash'
 import { render } from "lit-html";
 import filesize from "filesize";
 import log from "@converse/headless/log";
-import tpl_csn from "templates/csn.html";
 import tpl_file_progress from "templates/file_progress.html";
 import tpl_info from "templates/info.html";
 import tpl_message from "templates/message.html";
@@ -119,9 +118,7 @@ converse.plugins.add('converse-message-view', {
 
             async render () {
                 const is_followup = u.hasClass('chat-msg--followup', this.el);
-                if (this.model.isOnlyChatStateNotification()) {
-                    this.renderChatStateNotification()
-                } else if (this.model.get('file') && !this.model.get('oob_url')) {
+                if (this.model.get('file') && !this.model.get('oob_url')) {
                     if (!this.model.file) {
                         log.error("Attempted to render a file upload message with no file data");
                         return this.el;
@@ -327,38 +324,6 @@ converse.plugins.add('converse-message-view', {
                 return this.replaceElement(msg);
             },
 
-            renderChatStateNotification () {
-                let text;
-                const from = this.model.get('from');
-                const name = this.model.getDisplayName();
-
-                if (this.model.get('chat_state') === _converse.COMPOSING) {
-                    if (this.model.get('sender') === 'me') {
-                        text = __('Typing from another device');
-                    } else {
-                        text = __('%1$s is typing', name);
-                    }
-                } else if (this.model.get('chat_state') === _converse.PAUSED) {
-                    if (this.model.get('sender') === 'me') {
-                        text = __('Stopped typing on the other device');
-                    } else {
-                        text = __('%1$s has stopped typing', name);
-                    }
-                } else if (this.model.get('chat_state') === _converse.GONE) {
-                    text = __('%1$s has gone away', name);
-                } else {
-                    return;
-                }
-                const isodate = (new Date()).toISOString();
-                this.replaceElement(
-                      u.stringToElement(
-                        tpl_csn({
-                            'message': text,
-                            'from': from,
-                            'isodate': isodate
-                        })));
-            },
-
             renderFileUploadProgresBar () {
                 const msg = u.stringToElement(tpl_file_progress(
                     Object.assign(this.model.toJSON(), {

+ 50 - 21
src/converse-muc-views.js

@@ -15,7 +15,6 @@ import { __ } from '@converse/headless/i18n';
 import converse from "@converse/headless/converse-core";
 import log from "@converse/headless/log";
 import tpl_add_chatroom_modal from "templates/add_chatroom_modal.js";
-import tpl_chatarea from "templates/chatarea.html";
 import tpl_chatroom from "templates/chatroom.js";
 import tpl_chatroom_bottom_panel from "templates/chatroom_bottom_panel.html";
 import tpl_chatroom_destroyed from "templates/chatroom_destroyed.html";
@@ -708,8 +707,10 @@ converse.plugins.add('converse-muc-views', {
                     this.removeAll();
                 });
 
-                this.listenTo(this.model, 'change', this.renderHeading);
+                this.listenTo(this.model.csn, 'change', this.renderChatStateNotifications);
                 this.listenTo(this.model.session, 'change:connection_status', this.onConnectionStatusChanged);
+
+                this.listenTo(this.model, 'change', this.renderHeading);
                 this.listenTo(this.model, 'change:hidden_occupants', this.updateOccupantsToggle);
                 this.listenTo(this.model, 'change:subject', this.setChatRoomSubject);
                 this.listenTo(this.model, 'configurationNeeded', this.getAndRenderConfigurationForm);
@@ -745,10 +746,14 @@ converse.plugins.add('converse-muc-views', {
 
             render () {
                 this.el.setAttribute('id', this.model.get('box_id'));
-                render(tpl_chatroom(), this.el);
+                render(tpl_chatroom({
+                    'muc_show_logs_before_join': _converse.muc_show_logs_before_join,
+                    'show_send_button': _converse.show_send_button
+                }), this.el);
                 this.renderHeading();
-                this.renderChatArea();
                 this.renderBottomPanel();
+                this.content = this.el.querySelector('.chat-content');
+                this.csn = this.el.querySelector('.chat-state-notifications');
                 if (!_converse.muc_show_logs_before_join) {
                     this.model.session.get('connection_status') !== converse.ROOMSTATUS.ENTERED && this.showSpinner();
                 }
@@ -758,6 +763,47 @@ converse.plugins.add('converse-muc-views', {
                 return this;
             },
 
+            renderChatStateNotifications () {
+                const actors_per_state = this.model.csn.toJSON();
+                const message = converse.CHAT_STATES.reduce((result, state) => {
+                    const existing_actors = actors_per_state[state];
+                    if (!existing_actors) {
+                        return result;
+                    }
+                    const actors = existing_actors
+                        .map(a => this.model.getOccupant(a))
+                        .filter(a => a)
+                        .map(a => a.getDisplayName());
+
+                    if (actors.length === 1) {
+                        if (state === 'composing') {
+                            return `${result} ${__('%1$s is typing', actors[0])}\n`;
+                        } else if (state === 'paused') {
+                            return `${result} ${__('%1$s has stopped typing', actors[0])}\n`;
+                        } else if (state === _converse.GONE) {
+                            return `${result} ${__('%1$s has gone away', actors[0])}\n`;
+                        }
+                    } else if (actors.length > 1) {
+                        let actors_str;
+                        if (actors.length > 3) {
+                            actors_str = `${Array.from(actors).slice(0, 2).join(', ')} and others`;
+                        } else {
+                            const last_actor = actors.pop();
+                            actors_str = __('%1$s and %2$s', actors.join(', '), last_actor);
+                        }
+                        if (state === 'composing') {
+                            return `${result} ${__('%1$s are typing', actors_str)}\n`;
+                        } else if (state === 'paused') {
+                            return `${result} ${__('%1$s have stopped typing', actors_str)}\n`;
+                        } else if (state === _converse.GONE) {
+                            return `${result} ${__('%1$s have gone away', actors_str)}\n`;
+                        }
+                    }
+                    return result;
+                }, '');
+                this.csn.innerHTML = message;
+            },
+
             /**
              * Renders the MUC heading if any relevant attributes have changed.
              * @private
@@ -780,23 +826,6 @@ converse.plugins.add('converse-muc-views', {
                 }
             },
 
-            renderChatArea () {
-                // Render the UI container in which groupchat messages will appear.
-                if (this.el.querySelector('.chat-area') === null) {
-                    const container_el = this.el.querySelector('.chatroom-body');
-                    container_el.insertAdjacentHTML(
-                        'beforeend',
-                        tpl_chatarea({
-                            __,
-                            'muc_show_logs_before_join': _converse.muc_show_logs_before_join,
-                            'show_send_button': _converse.show_send_button
-                        })
-                    );
-                    this.content = this.el.querySelector('.chat-content');
-                }
-                return this;
-            },
-
             createSidebarView () {
                 this.model.occupants.chatroomview = this;
                 this.sidebar_view = new _converse.MUCSidebar({'model': this.model.occupants});

+ 17 - 6
src/headless/converse-chat.js

@@ -150,12 +150,8 @@ converse.plugins.add('converse-chat', {
                 }
             },
 
-            isOnlyChatStateNotification () {
-                return u.isOnlyChatStateNotification(this);
-            },
-
             isEphemeral () {
-                return this.get('is_ephemeral') || u.isOnlyChatStateNotification(this);
+                return this.get('is_ephemeral');
             },
 
             getDisplayName () {
@@ -325,6 +321,7 @@ converse.plugins.add('converse-chat', {
                 }
                 this.set({'box_id': `box-${btoa(jid)}`});
                 this.initMessages();
+                this.initCSN();
 
                 if (this.get('type') === _converse.PRIVATE_CHAT_TYPE) {
                     this.presence = _converse.presences.findWhere({'jid': jid}) || _converse.presences.create({'jid': jid});
@@ -357,6 +354,10 @@ converse.plugins.add('converse-chat', {
                 });
             },
 
+            initCSN () {
+                this.csn = new Model();
+            },
+
             afterMessagesFetched () {
                 /**
                  * Triggered whenever a `_converse.ChatBox` instance has fetched its messages from
@@ -409,8 +410,13 @@ converse.plugins.add('converse-chat', {
                         return;
                     }
                     this.setEditable(attrs, attrs.time, stanza);
+
+                    if (attrs['chat_state'] && attrs.sender === 'them') {
+                        this.csn.set('chat_state', attrs.chat_state);
+                    }
                     if (u.shouldCreateMessage(attrs)) {
                         const msg = this.handleCorrection(attrs) || await this.createMessage(attrs);
+                        this.csn.set({'chat_state': null});
                         this.incrementUnreadMsgCounter(msg);
                     }
                 }
@@ -945,12 +951,18 @@ converse.plugins.add('converse-chat', {
                 }
             },
 
+            /**
+             * @async
+             * @private
+             * @method _converse.ChatBox#createMessage
+             */
             createMessage (attrs, options) {
                 return this.messages.create(attrs, Object.assign({'wait': true, 'promise':true}, options));
             },
 
             /**
              * Responsible for sending off a text message inside an ongoing chat conversation.
+             * @private
              * @method _converse.ChatBox#sendMessage
              * @memberOf _converse.ChatBox
              * @param { String } text - The chat message text
@@ -1073,7 +1085,6 @@ converse.plugins.add('converse-chat', {
             },
 
             maybeShow () {
-                // Returns the chatbox
                 return this.trigger("show");
             },
 

+ 3 - 0
src/headless/converse-core.js

@@ -1691,6 +1691,9 @@ window.converse = window.converse || {};
  * @namespace converse
  */
 Object.assign(window.converse, {
+
+    CHAT_STATES: ['active', 'composing', 'gone', 'inactive', 'paused'],
+
     keycodes: {
         TAB: 9,
         ENTER: 13,

+ 34 - 1
src/headless/converse-muc.js

@@ -354,6 +354,7 @@ converse.plugins.add('converse-muc', {
                 this.debouncedRejoin = debounce(this.rejoin, 250);
                 this.set('box_id', `box-${btoa(this.get('jid'))}`);
                 this.initMessages();
+                this.initCSN();
                 this.initOccupants();
                 this.initDiscoModels(); // sendChatState depends on this.features
                 this.registerHandlers();
@@ -1818,6 +1819,36 @@ converse.plugins.add('converse-muc', {
                 }
             },
 
+            removeCSNFor (actor, state) {
+                const actors_per_state = this.csn.toJSON();
+                const existing_actors = Array.from(actors_per_state[state]) || [];
+                if (existing_actors.includes(actor)) {
+                    const idx = existing_actors.indexOf(actor);
+                    existing_actors.splice(idx, 1);
+                    this.csn.set(state, Array.from(existing_actors));
+                }
+            },
+
+            updateCSN (attrs) {
+                const actor = attrs.nick;
+                const state = attrs.chat_state;
+                const actors_per_state = this.csn.toJSON();
+                const existing_actors = actors_per_state[state] || [];
+                if (existing_actors.includes(actor)) {
+                    return;
+                }
+                const new_actors_per_state = converse.CHAT_STATES.reduce((out, s) => {
+                    if (s === state) {
+                        out[s] =  [...existing_actors, actor];
+                    } else {
+                        out[s] = (actors_per_state[s] || []).filter(a => a !== actor);
+                    }
+                    return out;
+                }, {});
+                this.csn.set(new_actors_per_state);
+                window.setTimeout(() => this.removeCSNFor(actor, state), 10000);
+            },
+
             /**
              * Handler for all MUC messages sent to this groupchat. This method
              * shouldn't be called directly, instead {@link _converse.ChatRoom#queueMessage}
@@ -1865,7 +1896,9 @@ converse.plugins.add('converse-muc', {
                 }
                 this.setEditable(attrs, attrs.time);
 
-                if (u.shouldCreateGroupchatMessage(attrs)) {
+                if (attrs['chat_state']) {
+                    this.updateCSN(attrs);
+                } else if (u.shouldCreateGroupchatMessage(attrs)) {
                     const msg = this.handleCorrection(attrs) || await this.createMessage(attrs);
                     this.incrementUnreadMsgCounter(msg);
                 }

+ 1 - 2
src/headless/utils/core.js

@@ -124,8 +124,7 @@ u.isNewMessage = function (message) {
 };
 
 u.shouldCreateMessage = function (attrs) {
-    return attrs['chat_state'] ||
-        attrs['retracted'] || // Retraction received *before* the message
+    return attrs['retracted'] || // Retraction received *before* the message
         !u.isEmptyMessage(attrs);
 }
 

+ 0 - 8
src/templates/chatarea.html

@@ -1,8 +0,0 @@
-<div class="chat-area col">
-    <div class="chat-content {[ if (o.show_send_button) { ]}chat-content-sendbutton{[ } ]}" aria-live="polite">
-        {[ if (o.muc_show_logs_before_join) { ]}
-            <div class="empty-history-feedback"><span>{{{o.__('No message history available.')}}}</span></div>
-        {[ } ]}
-    </div>
-    <div class="bottom-panel"></div>
-</div>

+ 2 - 3
src/templates/chatbox.js

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

+ 11 - 1
src/templates/chatroom.js

@@ -1,10 +1,20 @@
 import { html } from "lit-html";
+import { __ } from '@converse/headless/i18n';
 
+const i18n_no_history = __('No message history available.');
 
-export default () => html`
+
+export default (o) => html`
     <div class="flyout box-flyout">
         <div class="chat-head chat-head-chatroom row no-gutters"></div>
         <div class="chat-body chatroom-body row no-gutters">
+            <div class="chat-area col">
+                <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
+                    ${ o.muc_show_logs_before_join ? html`<div class="empty-history-feedback"><span>${ i18n_no_history }</span></div>`  : '' }
+                </div>
+                <div class="chat-state-notifications"></div>
+                <div class="bottom-panel"></div>
+            </div>
             <div class="disconnect-container hidden"></div>
         </div>
     </div>

+ 0 - 3
src/templates/csn.html

@@ -1,3 +0,0 @@
-<div class="message chat-info chat-state-notification"
-     data-isodate="{{{o.isodate}}}"
-     data-csn="{{{o.from}}}">{{{o.message}}}</div>