Browse Source

Refactoring

- Move headless one-on-one chat functionality into converse-chat
- Split converse-headline into converse-headlines and converse-headlines-views
- Add api in `_converse.api.chatboxes` for creating chatboxes
- Add `_converse.api.controlbox.get` method
JC Brand 5 years ago
parent
commit
879e165ae5

+ 3 - 0
CHANGES.md

@@ -14,6 +14,7 @@
   instances. Still working out a wire protocol for compatibility with other clients.
   To add custom emojis, edit the `emojis.json` file.
 - Refactor some presence and status handling code from `converse-core` into `@converse/headless/converse-status`.
+- New API [\_converse.api.headlines](https://conversejs.org/docs/html/api/-_converse.api.headlines.html#.get)
 
 ### Breaking changes
 
@@ -34,10 +35,12 @@
   * `_converse.api.rooms.create`
   * `_converse.api.roomviews.close`
 
+- `_converse.api.chats.get()` now only returns one-on-one chats, not the control box or headline notifications.
 - The `show_only_online_users` setting has been removed.
 - The order of certain events have now changed: `statusInitialized` is now triggered after `initialized` and `connected` and `reconnected`.
 - `_converse.api.alert.show` is now `_converse.api.show` and instead of taking
   an integer for the `type`, "info", "warn" or "error" should be passed in.
+- The `converse-headline` plugin has been split up into `converse-headlines` and `converse-headlines-view`.
 
 ## 5.0.4 (2019-10-08)
 - New config option [allow_message_corrections](https://conversejs.org/docs/html/configuration.html#allow-message-corrections)

+ 1 - 1
spec/bookmarks.js

@@ -168,7 +168,7 @@
                 'name':  'The Play',
                 'nick': ' Othello'
             });
-            await u.waitUntil(() => _converse.api.rooms.get().length);
+            await new Promise(resolve => _converse.api.listen.once('chatBoxInitialized', resolve));
             expect(_.isUndefined(_converse.chatboxviews.get(jid))).toBeFalsy();
 
             // Check that we don't auto-join if muc_respect_autojoin is false

+ 48 - 77
spec/chatbox.js

@@ -38,7 +38,7 @@
                         type: 'chat',
                         id: (new Date()).getTime()
                     }).c('body').t('hello world').tree();
-                await _converse.chatboxes.onMessage(msg);
+                await _converse.handleMessageStanza(msg);
                 await u.waitUntil(() => view.content.querySelectorAll('.chat-msg').length);
                 expect(view.content.lastElementChild.textContent.trim().indexOf('hello world')).not.toBe(-1);
                 done();
@@ -61,7 +61,7 @@
                     }).c('body').t(message).up()
                     .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
 
-                await _converse.chatboxes.onMessage(msg);
+                await _converse.handleMessageStanza(msg);
                 const view = _converse.chatboxviews.get(sender_jid);
                 await new Promise(resolve => view.once('messageInserted', resolve));
                 expect(view.el.querySelectorAll('.chat-msg--action').length).toBe(1);
@@ -135,7 +135,7 @@
 
                 const message_promise = new Promise(resolve => _converse.api.listen.on('message', resolve));
                 _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                await u.waitUntil(() => _converse.api.chats.get().length === 2);
+                await new Promise(resolve => _converse.api.listen.once('chatBoxInitialized', resolve));
                 await u.waitUntil(() => message_promise);
                 expect(_converse.chatboxviews.keys().length).toBe(2);
                 done();
@@ -190,7 +190,7 @@
                     el.click();
                 }
                 await u.waitUntil(() => _converse.chatboxes.length == 16);
-                expect(_converse.chatboxviews.trimChats.calls.count()).toBe(16);
+                expect(_converse.chatboxviews.trimChats.calls.count()).toBe(17);
 
                 _converse.api.chatviews.get().forEach(v => spyOn(v, 'onMinimized').and.callThrough());
                 for (i=0; i<online_contacts.length; i++) {
@@ -212,7 +212,7 @@
 
                 expect(trimmedview.restore).toHaveBeenCalled();
                 expect(chatbox.maximize).toHaveBeenCalled();
-                expect(_converse.chatboxviews.trimChats.calls.count()).toBe(17);
+                expect(_converse.chatboxviews.trimChats.calls.count()).toBe(18);
                 done();
             }));
 
@@ -520,58 +520,28 @@
 
             describe("A Chat Status Notification", function () {
 
-                it("is ignored when it's a carbon copy of one of my own",
-                    mock.initConverse(
-                        ['rosterGroupsFetched'], {},
-                        async function (done, _converse) {
-
-                    await test_utils.waitForRoster(_converse, 'current');
-                    await test_utils.openControlBox(_converse);
-
-                    const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                    await test_utils.openChatBoxFor(_converse, sender_jid);
-                    let stanza = u.toStanza(
-                        `<message from="${sender_jid}"
-                                 type="chat"
-                                 to="romeo@montague.lit/orchard">
-                            <composing xmlns="http://jabber.org/protocol/chatstates"/>
-                            <no-store xmlns="urn:xmpp:hints"/>
-                            <no-permanent-store xmlns="urn:xmpp:hints"/>
-                        </message>`);
-                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
-
-                    stanza = u.toStanza(
-                        `<message from="${sender_jid}"
-                                 type="chat"
-                                 to="romeo@montague.lit/orchard">
-                            <paused xmlns="http://jabber.org/protocol/chatstates"/>
-                            <no-store xmlns="urn:xmpp:hints"/>
-                            <no-permanent-store xmlns="urn:xmpp:hints"/>
-                        </message>`);
-                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
-                    done();
-                }));
-
                 it("does not open a new chatbox",
                     mock.initConverse(
-                        ['rosterGroupsFetched'], {},
+                        ['rosterGroupsFetched', 'emojisInitialized'], {},
                         async function (done, _converse) {
 
                     await test_utils.waitForRoster(_converse, 'current');
                     await test_utils.openControlBox(_converse);
 
-                    spyOn(_converse.api, "trigger").and.callThrough();
                     const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
                     // <composing> state
-                    const msg = $msg({
+                    const stanza = $msg({
                             'from': sender_jid,
                             'to': _converse.connection.jid,
                             'type': 'chat',
                             'id': (new Date()).getTime()
                         }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                    await _converse.chatboxes.onMessage(msg);
+
+                    spyOn(_converse.api, "trigger").and.callThrough();
+                    _converse.connection._dataRecv(test_utils.createRequest(stanza));
+                    await u.waitUntil(() => _converse.api.trigger.calls.count());
                     expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
-                    expect(_converse.api.chats.get().length).toBe(1);
+                    expect(_converse.chatboxviews.keys().length).toBe(1);
                     done();
                 }));
 
@@ -705,7 +675,6 @@
                         await test_utils.openControlBox(_converse);
 
                         // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
-                        spyOn(_converse.api, "trigger").and.callThrough();
                         const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
                         await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length);
                         await test_utils.openChatBoxFor(_converse, sender_jid);
@@ -716,10 +685,13 @@
                                 to: _converse.connection.jid,
                                 type: 'chat',
                                 id: (new Date()).getTime()
-                            }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        await _converse.chatboxes.onMessage(msg);
+                            }).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));
-                        var view = _converse.chatboxviews.get(sender_jid);
+                        const view = _converse.chatboxviews.get(sender_jid);
                         expect(view).toBeDefined();
 
                         const event = await u.waitUntil(() => view.el.querySelector('.chat-state-notification'));
@@ -732,7 +704,7 @@
                                 type: 'chat',
                                 id: (new Date()).getTime()
                             }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        await _converse.chatboxes.onMessage(msg);
+                        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');
@@ -765,7 +737,7 @@
                                     'to': recipient_jid,
                                     'type': 'chat'
                             }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        await _converse.chatboxes.onMessage(msg);
+                        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);
@@ -859,7 +831,7 @@
                                 type: 'chat',
                                 id: (new Date()).getTime()
                             }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        await _converse.chatboxes.onMessage(msg);
+                        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'));
@@ -893,7 +865,7 @@
                                     'to': recipient_jid,
                                     'type': 'chat'
                             }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        await _converse.chatboxes.onMessage(msg);
+                        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);
@@ -1042,7 +1014,8 @@
                                 'type': 'chat'})
                             .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
                             .tree();
-                        await _converse.chatboxes.onMessage(msg);
+                        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);
                         msg = $msg({
@@ -1050,8 +1023,8 @@
                                 to: _converse.connection.jid,
                                 type: 'chat',
                                 id: (new Date()).getTime()
-                            }).c('body').c('inactive', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        await _converse.chatboxes.onMessage(msg);
+                            }).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);
@@ -1078,7 +1051,7 @@
                                 type: 'chat',
                                 id: (new Date()).getTime()
                             }).c('body').c('gone', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                        await _converse.chatboxes.onMessage(msg);
+                        await _converse.handleMessageStanza(msg);
                         expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
                         const view = _converse.chatboxviews.get(sender_jid);
                         await u.waitUntil(() => view.model.vcard.get('fullname') === mock.cur_names[1]);
@@ -1165,7 +1138,7 @@
                 spyOn(_converse, 'incrementMsgCounter').and.callThrough();
                 spyOn(_converse, 'clearMsgCounter').and.callThrough();
 
-                await _converse.chatboxes.onMessage(msg);
+                await _converse.handleMessageStanza(msg);
                 await new Promise(resolve => view.once('messageInserted', resolve));
                 expect(_converse.incrementMsgCounter).toHaveBeenCalled();
                 expect(_converse.clearMsgCounter).not.toHaveBeenCalled();
@@ -1210,7 +1183,7 @@
                         id: (new Date()).getTime()
                     }).c('body').t(message).up()
                       .c('active', {'xmlns': Strophe.NS.CHATSTATES}).tree();
-                await _converse.chatboxes.onMessage(msg);
+                await _converse.handleMessageStanza(msg);
                 expect(_converse.incrementMsgCounter).not.toHaveBeenCalled();
                 expect(document.title).toBe('Converse Tests');
                 done();
@@ -1240,14 +1213,13 @@
 
                 // leave converse-chat page
                 _converse.windowState = 'hidden';
-                _converse.chatboxes.onMessage(msgFactory());
-                await u.waitUntil(() => _converse.api.chats.get().length === 2)
+                await _converse.handleMessageStanza(msgFactory());
                 let view = _converse.chatboxviews.get(sender_jid);
                 expect(document.title).toBe('Messages (1) Converse Tests');
 
                 // come back to converse-chat page
                 _converse.saveWindowState(null, 'focus');
-                expect(u.isVisible(view.el)).toBeTruthy();
+                await u.waitUntil(() => u.isVisible(view.el));
                 expect(document.title).toBe('Converse Tests');
 
                 // close chatbox and leave converse-chat page again
@@ -1255,8 +1227,7 @@
                 _converse.windowState = 'hidden';
 
                 // check that msg_counter is incremented from zero again
-                _converse.chatboxes.onMessage(msgFactory());
-                await u.waitUntil(() => _converse.api.chats.get().length === 2)
+                await _converse.handleMessageStanza(msgFactory());
                 view = _converse.chatboxviews.get(sender_jid);
                 expect(u.isVisible(view.el)).toBeTruthy();
                 expect(document.title).toBe('Messages (1) Converse Tests');
@@ -1277,7 +1248,7 @@
 
                 const view = await test_utils.openChatBoxFor(_converse, sender_jid)
                 view.model.save('scrolled', true);
-                await _converse.chatboxes.onMessage(msg);
+                await _converse.handleMessageStanza(msg);
                 await u.waitUntil(() => view.model.messages.length);
                 expect(view.model.get('num_unread')).toBe(1);
                 done();
@@ -1295,7 +1266,7 @@
 
                 await test_utils.openChatBoxFor(_converse, sender_jid);
                 const chatbox = _converse.chatboxes.get(sender_jid);
-                await _converse.chatboxes.onMessage(msg);
+                await _converse.handleMessageStanza(msg);
                 expect(chatbox.get('num_unread')).toBe(0);
                 done();
             }));
@@ -1313,7 +1284,7 @@
                 await test_utils.openChatBoxFor(_converse, sender_jid);
                 const chatbox = _converse.chatboxes.get(sender_jid);
                 _converse.windowState = 'hidden';
-                _converse.chatboxes.onMessage(msgFactory());
+                _converse.handleMessageStanza(msgFactory());
                 await u.waitUntil(() => chatbox.messages.length);
                 expect(chatbox.get('num_unread')).toBe(1);
                 done();
@@ -1331,7 +1302,7 @@
                 const chatbox = _converse.chatboxes.get(sender_jid);
                 chatbox.save('scrolled', true);
                 _converse.windowState = 'hidden';
-                _converse.chatboxes.onMessage(msgFactory());
+                _converse.handleMessageStanza(msgFactory());
                 await u.waitUntil(() => chatbox.messages.length);
                 expect(chatbox.get('num_unread')).toBe(1);
                 done();
@@ -1348,7 +1319,7 @@
                 await test_utils.openChatBoxFor(_converse, sender_jid);
                 const chatbox = _converse.chatboxes.get(sender_jid);
                 _converse.windowState = 'hidden';
-                _converse.chatboxes.onMessage(msgFactory());
+                _converse.handleMessageStanza(msgFactory());
                 await u.waitUntil(() => chatbox.messages.length);
                 expect(chatbox.get('num_unread')).toBe(1);
                 _converse.saveWindowState(null, 'focus');
@@ -1368,7 +1339,7 @@
                 const chatbox = _converse.chatboxes.get(sender_jid);
                 chatbox.save('scrolled', true);
                 _converse.windowState = 'hidden';
-                _converse.chatboxes.onMessage(msgFactory());
+                _converse.handleMessageStanza(msgFactory());
                 await u.waitUntil(() => chatbox.messages.length);
                 expect(chatbox.get('num_unread')).toBe(1);
                 _converse.saveWindowState(null, 'focus');
@@ -1392,13 +1363,13 @@
                 const chatbox = _converse.chatboxes.get(sender_jid);
                 chatbox.save('scrolled', true);
                 msg = test_utils.createChatMessage(_converse, sender_jid, 'This message will be unread');
-                await _converse.chatboxes.onMessage(msg);
+                await _converse.handleMessageStanza(msg);
                 await u.waitUntil(() => chatbox.messages.length);
                 const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator';
                 indicator_el = sizzle(selector, _converse.rosterview.el).pop();
                 expect(indicator_el.textContent).toBe('1');
                 msg = test_utils.createChatMessage(_converse, sender_jid, 'This message will be unread too');
-                await _converse.chatboxes.onMessage(msg);
+                await _converse.handleMessageStanza(msg);
                 await u.waitUntil(() => chatbox.messages.length > 1);
                 indicator_el = sizzle(selector, _converse.rosterview.el).pop();
                 expect(indicator_el.textContent).toBe('2');
@@ -1421,14 +1392,14 @@
                 chatboxview.minimize();
 
                 msg = test_utils.createChatMessage(_converse, sender_jid, 'This message will be unread');
-                await _converse.chatboxes.onMessage(msg);
+                await _converse.handleMessageStanza(msg);
                 await u.waitUntil(() => chatbox.messages.length);
                 const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator';
                 indicator_el = sizzle(selector, _converse.rosterview.el).pop();
                 expect(indicator_el.textContent).toBe('1');
 
                 msg = test_utils.createChatMessage(_converse, sender_jid, 'This message will be unread too');
-                await _converse.chatboxes.onMessage(msg);
+                await _converse.handleMessageStanza(msg);
                 await u.waitUntil(() => chatbox.messages.length === 2);
                 indicator_el = sizzle(selector, _converse.rosterview.el).pop();
                 expect(indicator_el.textContent).toBe('2');
@@ -1450,10 +1421,10 @@
                 const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator';
                 const select_msgs_indicator = () => sizzle(selector, _converse.rosterview.el).pop();
                 view.minimize();
-                _converse.chatboxes.onMessage(msgFactory());
+                _converse.handleMessageStanza(msgFactory());
                 await u.waitUntil(() => chatbox.messages.length);
                 expect(select_msgs_indicator().textContent).toBe('1');
-                _converse.chatboxes.onMessage(msgFactory());
+                _converse.handleMessageStanza(msgFactory());
                 await u.waitUntil(() => chatbox.messages.length > 1);
                 expect(select_msgs_indicator().textContent).toBe('2');
                 view.model.maximize();
@@ -1476,7 +1447,7 @@
                 const selector = `a.open-chat:contains("${chatbox.get('nickname')}") .msgs-indicator`;
                 const select_msgs_indicator = () => sizzle(selector, _converse.rosterview.el).pop();
                 chatbox.save('scrolled', true);
-                _converse.chatboxes.onMessage(msgFactory());
+                _converse.handleMessageStanza(msgFactory());
                 const view = _converse.chatboxviews.get(sender_jid);
                 await u.waitUntil(() => view.model.messages.length);
                 expect(select_msgs_indicator().textContent).toBe('1');
@@ -1502,7 +1473,7 @@
                 const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator';
                 const select_msgs_indicator = () => sizzle(selector, _converse.rosterview.el).pop();
                 chatbox.save('scrolled', true);
-                _converse.chatboxes.onMessage(msgFactory());
+                _converse.handleMessageStanza(msgFactory());
                 await u.waitUntil(() => view.model.messages.length);
                 expect(select_msgs_indicator().textContent).toBe('1');
                 await test_utils.openChatBoxFor(_converse, sender_jid);
@@ -1530,7 +1501,7 @@
                 };
                 const chatbox = _converse.chatboxes.get(sender_jid);
                 chatbox.save('scrolled', true);
-                _converse.chatboxes.onMessage(msgFactory());
+                _converse.handleMessageStanza(msgFactory());
                 await u.waitUntil(() => chatbox.messages.length);
                 const chatboxview = _converse.chatboxviews.get(sender_jid);
                 chatboxview.minimize();
@@ -1558,7 +1529,7 @@
                     return minimizedChatBoxView.el.querySelector('.message-count');
                 };
                 view.minimize();
-                _converse.chatboxes.onMessage(msgFactory());
+                _converse.handleMessageStanza(msgFactory());
                 await u.waitUntil(() => view.model.messages.length);
                 const unread_count = selectUnreadMsgCount();
                 expect(u.isVisible(unread_count)).toBeTruthy();

+ 2 - 2
spec/controlbox.js

@@ -88,7 +88,7 @@
                         id: (new Date()).getTime()
                     }).c('body').t('hello').up()
                     .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-                _converse.chatboxes.onMessage(msg);
+                _converse.handleMessageStanza(msg);
                 await u.waitUntil(() => _converse.rosterview.el.querySelectorAll(".msgs-indicator").length);
                 spyOn(chatview.model, 'incrementUnreadMsgCounter').and.callThrough();
                 expect(_converse.chatboxviews.el.querySelector('.restore-chat .message-count').textContent).toBe('1');
@@ -101,7 +101,7 @@
                         id: (new Date()).getTime()
                     }).c('body').t('hello again').up()
                     .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-                _converse.chatboxes.onMessage(msg);
+                _converse.handleMessageStanza(msg);
                 await u.waitUntil(() => chatview.model.incrementUnreadMsgCounter.calls.count());
                 expect(_converse.chatboxviews.el.querySelector('.restore-chat .message-count').textContent).toBe('2');
                 expect(_converse.rosterview.el.querySelector('.msgs-indicator').textContent).toBe('2');

+ 2 - 2
spec/converse.js

@@ -269,7 +269,7 @@
 
                 // Test on chat that's not open
                 chat = await _converse.api.chats.get(jid);
-                expect(typeof chat === 'undefined').toBeTruthy();
+                expect(chat === null).toBeTruthy();
                 expect(_converse.chatboxes.length).toBe(1);
 
                 // Test for one JID
@@ -281,7 +281,7 @@
                 await u.waitUntil(() => u.isVisible(view.el));
                 // Test for multiple JIDs
                 test_utils.openChatBoxFor(_converse, jid2);
-                await u.waitUntil(() => _converse.chatboxes.length == 2);
+                await u.waitUntil(() => _converse.chatboxes.length == 3);
                 const list = await _converse.api.chats.get([jid, jid2]);
                 expect(Array.isArray(list)).toBeTruthy();
                 expect(list[0].get('box_id')).toBe(`box-${btoa(jid)}`);

+ 2 - 2
spec/emojis.js

@@ -163,7 +163,7 @@
 
                 await test_utils.waitForRoster(_converse, 'current');
                 const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                _converse.chatboxes.onMessage($msg({
+                _converse.handleMessageStanza($msg({
                         'from': sender_jid,
                         'to': _converse.connection.jid,
                         'type': 'chat',
@@ -177,7 +177,7 @@
                 let message = chat_content.querySelector('.chat-msg__text');
                 expect(u.hasClass('chat-msg__text--larger', message)).toBe(true);
 
-                _converse.chatboxes.onMessage($msg({
+                _converse.handleMessageStanza($msg({
                         'from': sender_jid,
                         'to': _converse.connection.jid,
                         'type': 'chat',

+ 2 - 2
spec/headline.js

@@ -14,7 +14,7 @@
 
         it("will not open nor display non-headline messages",
             mock.initConverse(
-                ['rosterGroupsFetched', 'chatBoxesFetched'], {}, async function (done, _converse) {
+                ['rosterGroupsFetched', 'chatBoxesFetched'], {}, function (done, _converse) {
 
             /* XMPP spam message:
              *
@@ -36,9 +36,9 @@
                 .c('nick', {'xmlns': "http://jabber.org/protocol/nick"}).t("-wwdmz").up()
                 .c('body').t('SORRY FOR THIS ADVERT');
             _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => _converse.api.chats.get().length);
             expect(u.isHeadlineMessage.called).toBeTruthy();
             expect(u.isHeadlineMessage.returned(false)).toBeTruthy();
+            expect(_converse.api.headlines.get().length === 0);
             u.isHeadlineMessage.restore();
             done();
         }));

+ 1 - 1
spec/mam.js

@@ -205,7 +205,7 @@
 
         describe("An archived message", function () {
 
-            describe("when recieved", function () {
+            describe("when received", function () {
 
                 it("is discarded if it doesn't come from the right sender",
                     mock.initConverse(

+ 61 - 74
spec/messages.js

@@ -21,7 +21,8 @@
             const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
             const forwarded_contact_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
             await test_utils.openChatBoxFor(_converse, contact_jid);
-            expect(_converse.api.chats.get().length).toBe(2);
+            let models = await _converse.api.chats.get();
+            expect(models.length).toBe(1);
             const received_stanza = u.toStanza(`
                 <message to='${_converse.jid}' from='${contact_jid}' type='chat' id='${_converse.connection.getUniqueId()}'>
                     <body>A most courteous exposition!</body>
@@ -51,7 +52,8 @@
                             'Forwarded messages not part of an encapsulating protocol are not supported</text>'+
                     '</error>'+
                 '</message>');
-            expect(_converse.api.chats.get().length).toBe(2);
+            models = await _converse.api.chats.get();
+            expect(models.length).toBe(1);
             done();
         }));
 
@@ -148,7 +150,7 @@
             await u.waitUntil(() => (u.hasClass('correcting', view.el.querySelector('.chat-msg')) === false), 500);
 
             // Test that messages from other users don't have the pencil icon
-            _converse.chatboxes.onMessage(
+            _converse.handleMessageStanza(
                 $msg({
                     'from': contact_jid,
                     'to': _converse.connection.jid,
@@ -352,7 +354,6 @@
 
             const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
             await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length)
-            spyOn(_converse.chatboxes, 'getChatBox').and.callThrough();
             _converse.filter_by_resource = true;
 
             let msg = $msg({
@@ -364,8 +365,7 @@
                 .c('body').t("message").up()
                 .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T13:08:25Z'})
                 .tree();
-            await _converse.chatboxes.onMessage(msg);
-            await u.waitUntil(() => _converse.api.chats.get().length);
+            await _converse.handleMessageStanza(msg);
             const view = _converse.api.chatviews.get(sender_jid);
 
             msg = $msg({
@@ -377,7 +377,7 @@
                 .c('body').t("Older message").up()
                 .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2017-12-31T22:08:25Z'})
                 .tree();
-            await _converse.chatboxes.onMessage(msg);
+            await _converse.handleMessageStanza(msg);
             await new Promise(resolve => view.once('messageInserted', resolve));
 
             msg = $msg({
@@ -389,7 +389,7 @@
                 .c('body').t("Inbetween message").up()
                 .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'})
                 .tree();
-            await _converse.chatboxes.onMessage(msg);
+            await _converse.handleMessageStanza(msg);
             await new Promise(resolve => view.once('messageInserted', resolve));
 
             msg = $msg({
@@ -401,7 +401,7 @@
                 .c('body').t("another inbetween message").up()
                 .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'})
                 .tree();
-            await _converse.chatboxes.onMessage(msg);
+            await _converse.handleMessageStanza(msg);
             await new Promise(resolve => view.once('messageInserted', resolve));
 
             msg = $msg({
@@ -413,7 +413,7 @@
                 .c('body').t("An earlier message on the next day").up()
                 .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T12:18:23Z'})
                 .tree();
-            await _converse.chatboxes.onMessage(msg);
+            await _converse.handleMessageStanza(msg);
             await new Promise(resolve => view.once('messageInserted', resolve));
 
             msg = $msg({
@@ -425,7 +425,7 @@
                 .c('body').t("newer message from the next day").up()
                 .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T22:28:23Z'})
                 .tree();
-            await _converse.chatboxes.onMessage(msg);
+            await _converse.handleMessageStanza(msg);
             await new Promise(resolve => view.once('messageInserted', resolve));
 
             // Insert <composing> message, to also check that
@@ -439,7 +439,7 @@
                     'type': 'chat'})
                 .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
                 .tree();
-            await _converse.chatboxes.onMessage(msg);
+            await _converse.handleMessageStanza(msg);
 
             msg = $msg({
                     'id': _converse.connection.getUniqueId(),
@@ -450,7 +450,7 @@
                 .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
                 .c('body').t("latest message")
                 .tree();
-            await _converse.chatboxes.onMessage(msg);
+            await _converse.handleMessageStanza(msg);
             await new Promise(resolve => view.once('messageInserted', resolve));
 
             const chat_content = view.el.querySelector('.chat-content');
@@ -519,7 +519,7 @@
             // Ideally we wouldn't have to filter out headline
             // messages, but Prosody gives them the wrong 'type' :(
             sinon.spy(_converse, 'log');
-            sinon.spy(_converse.chatboxes, 'getChatBox');
+            sinon.spy(_converse.api.chatboxes, 'get');
             sinon.spy(u, 'isHeadlineMessage');
             const msg = $msg({
                     from: 'montague.lit',
@@ -527,17 +527,17 @@
                     type: 'chat',
                     id: (new Date()).getTime()
                 }).c('body').t("This headline message will not be shown").tree();
-            await _converse.chatboxes.onMessage(msg);
+            await _converse.handleMessageStanza(msg);
             expect(_converse.log.calledWith(
                 "onMessage: Ignoring incoming headline message from JID: montague.lit",
                 Strophe.LogLevel.INFO
             )).toBeTruthy();
             expect(u.isHeadlineMessage.called).toBeTruthy();
             expect(u.isHeadlineMessage.returned(true)).toBeTruthy();
-            expect(_converse.chatboxes.getChatBox.called).toBeFalsy();
+            expect(_converse.api.chatboxes.get.called).toBeFalsy();
             // Remove sinon spies
             _converse.log.restore();
-            _converse.chatboxes.getChatBox.restore();
+            _converse.api.chatboxes.get.restore();
             u.isHeadlineMessage.restore();
             done();
         }));
@@ -570,8 +570,7 @@
                         'type': 'chat'
                 }).c('body').t(msgtext).tree();
 
-            await _converse.chatboxes.onMessage(msg);
-            await u.waitUntil(() => (_converse.api.chats.get().length > 1))
+            await _converse.handleMessageStanza(msg);
             const chatbox = _converse.chatboxes.get(sender_jid);
             const view = _converse.chatboxviews.get(sender_jid);
 
@@ -622,7 +621,7 @@
                         'type': 'chat'
                 }).c('body').t(msgtext).tree();
 
-            await _converse.chatboxes.onMessage(msg);
+            await _converse.handleMessageStanza(msg);
             // Check that the chatbox and its view now exist
             const chatbox = await _converse.api.chats.get(recipient_jid);
             const view = _converse.api.chatviews.get(recipient_jid);
@@ -677,15 +676,15 @@
                         'to': _converse.connection.jid,
                         'type': 'chat'
                 }).c('body').t(msgtext).tree();
-            await _converse.chatboxes.onMessage(msg);
+            await _converse.handleMessageStanza(msg);
 
             // Check that chatbox for impersonated user is not created.
             let chatbox = await _converse.api.chats.get(impersonated_jid);
-            expect(chatbox).not.toBeDefined();
+            expect(chatbox).toBe(null);
 
             // Check that the chatbox for the malicous user is not created
             chatbox = await _converse.api.chats.get(sender_jid);
-            expect(chatbox).not.toBeDefined();
+            expect(chatbox).toBe(null);
             done();
         }));
 
@@ -719,7 +718,7 @@
                 id: (new Date()).getTime()
             }).c('body').t(message).up()
             .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-            await _converse.chatboxes.onMessage(msg);
+            await _converse.handleMessageStanza(msg);
 
             await u.waitUntil(() => chatview.model.messages.length);
             expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
@@ -730,7 +729,7 @@
             expect(trimmedview.model.get('minimized')).toBeTruthy();
             expect(u.isVisible(count)).toBeTruthy();
             expect(count.textContent).toBe('1');
-            _converse.chatboxes.onMessage(
+            _converse.handleMessageStanza(
                 $msg({
                     from: mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
                     to: _converse.connection.jid,
@@ -779,7 +778,7 @@
             }).c('body').t(message).up()
             .c('delay', { xmlns:'urn:xmpp:delay', from: 'montague.lit', stamp: one_day_ago.toISOString() })
             .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-            await _converse.chatboxes.onMessage(msg);
+            await _converse.handleMessageStanza(msg);
             await new Promise(resolve => view.once('messageInserted', resolve));
 
             expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
@@ -812,7 +811,7 @@
                 id: new Date().getTime()
             }).c('body').t(message).up()
             .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-            await _converse.chatboxes.onMessage(msg);
+            await _converse.handleMessageStanza(msg);
             await new Promise(resolve => view.once('messageInserted', resolve));
 
             expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
@@ -1077,7 +1076,7 @@
             jasmine.clock().install();
             jasmine.clock().mockDate(base_time);
 
-            _converse.chatboxes.onMessage($msg({
+            _converse.handleMessageStanza($msg({
                     'from': sender_jid,
                     'to': _converse.connection.jid,
                     'type': 'chat',
@@ -1089,7 +1088,7 @@
             await new Promise(resolve => view.once('messageInserted', resolve));
 
             jasmine.clock().tick(3*ONE_MINUTE_LATER);
-            _converse.chatboxes.onMessage($msg({
+            _converse.handleMessageStanza($msg({
                     'from': sender_jid,
                     'to': _converse.connection.jid,
                     'type': 'chat',
@@ -1099,7 +1098,7 @@
             await new Promise(resolve => view.once('messageInserted', resolve));
 
             jasmine.clock().tick(11*ONE_MINUTE_LATER);
-            _converse.chatboxes.onMessage($msg({
+            _converse.handleMessageStanza($msg({
                     'from': sender_jid,
                     'to': _converse.connection.jid,
                     'type': 'chat',
@@ -1112,7 +1111,7 @@
             // Insert <composing> message, to also check that
             // text messages are inserted correctly with
             // temporary chat events in the chat contents.
-            _converse.chatboxes.onMessage($msg({
+            _converse.handleMessageStanza($msg({
                     'id': 'aeb219',
                     'to': _converse.bare_jid,
                     'xmlns': 'jabber:client',
@@ -1123,7 +1122,7 @@
             await new Promise(resolve => view.once('messageInserted', resolve));
 
             jasmine.clock().tick(1*ONE_MINUTE_LATER);
-            _converse.chatboxes.onMessage($msg({
+            _converse.handleMessageStanza($msg({
                     'from': sender_jid,
                     'to': _converse.connection.jid,
                     'type': 'chat',
@@ -1154,7 +1153,7 @@
                 "Another message within 10 minutes, but from a different person");
 
             // Let's add a delayed, inbetween message
-            _converse.chatboxes.onMessage(
+            _converse.handleMessageStanza(
                 $msg({
                     'xmlns': 'jabber:client',
                     'id': _converse.connection.getUniqueId(),
@@ -1184,7 +1183,7 @@
                 "Another message 1 minute and 1 second since the previous one");
             expect(u.hasClass('chat-msg--followup', chat_content.querySelector('.message:nth-child(7)'))).toBe(false);
 
-            _converse.chatboxes.onMessage(
+            _converse.handleMessageStanza(
                 $msg({
                     'xmlns': 'jabber:client',
                     'id': _converse.connection.getUniqueId(),
@@ -1242,7 +1241,7 @@
                     'id': msg_id,
                 }).c('body').t('Message!').up()
                 .c('request', {'xmlns': Strophe.NS.RECEIPTS}).tree();
-            await _converse.chatboxes.onMessage(msg);
+            await _converse.handleMessageStanza(msg);
             const sent_messages = sent_stanzas.map(s => _.isElement(s) ? s : s.nodeTree).filter(s => s.nodeName === 'message');
             expect(sent_messages.length).toBe(1);
             const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, sent_messages[0]).pop();
@@ -1274,8 +1273,7 @@
                         'id': msg_id
                 }).c('body').t('Message!').up()
                 .c('request', {'xmlns': Strophe.NS.RECEIPTS}).tree();
-            await _converse.chatboxes.onMessage(msg);
-            await u.waitUntil(() => _converse.api.chats.get().length);
+            await _converse.handleMessageStanza(msg);
             expect(view.model.sendReceiptStanza).not.toHaveBeenCalled();
             done();
         }));
@@ -1298,7 +1296,6 @@
                     preventDefault: function preventDefault () {},
                     keyCode: 13 // Enter
                 });
-                await u.waitUntil(() => _converse.api.chats.get().length);
                 const chatbox = _converse.chatboxes.get(contact_jid);
                 expect(chatbox).toBeDefined();
                 await new Promise(resolve => view.once('messageInserted', resolve));
@@ -1314,7 +1311,7 @@
                 expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(1);
 
                 // Also handle receipts with type 'chat'. See #1353
-                spyOn(_converse.chatboxes, 'onMessage').and.callThrough();
+                spyOn(_converse, 'handleMessageStanza').and.callThrough();
                 textarea.value = 'Another message';
                 view.onKeyDown({
                     target: textarea,
@@ -1334,7 +1331,7 @@
                 _converse.connection._dataRecv(test_utils.createRequest(msg));
                 await new Promise(resolve => view.model.messages.once('rendered', resolve));
                 expect(view.el.querySelectorAll('.chat-msg__receipt').length).toBe(2);
-                expect(_converse.chatboxes.onMessage.calls.count()).toBe(1);
+                expect(_converse.handleMessageStanza.calls.count()).toBe(1);
                 done();
             }));
 
@@ -1396,7 +1393,7 @@
                 const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
                 // We don't already have an open chatbox for this user
                 expect(_converse.chatboxes.get(sender_jid)).not.toBeDefined();
-                _converse.chatboxes.onMessage(
+                await _converse.handleMessageStanza(
                     $msg({
                         'from': sender_jid,
                         'to': _converse.connection.jid,
@@ -1405,8 +1402,7 @@
                     }).c('body').t(message).up()
                     .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
                 );
-                await u.waitUntil(() => (_converse.api.chats.get().length === 2));
-                const chatbox = _converse.chatboxes.get(sender_jid);
+                const chatbox = await _converse.chatboxes.get(sender_jid);
                 expect(chatbox).toBeDefined();
                 const view = _converse.api.chatviews.get(sender_jid);
                 expect(view).toBeDefined();
@@ -1421,7 +1417,8 @@
                 expect(msg_obj.get('is_delayed')).toEqual(false);
                 // Now check that the message appears inside the chatbox in the DOM
                 const chat_content = view.el.querySelector('.chat-content');
-                expect(chat_content.querySelector('.chat-msg .chat-msg__text').textContent).toEqual(message);
+                const mel = await u.waitUntil(() => chat_content.querySelector('.chat-msg .chat-msg__text'));
+                expect(mel.textContent).toEqual(message);
                 expect(chat_content.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
                 await u.waitUntil(() => chatbox.vcard.get('fullname') === mock.cur_names[0]);
                 expect(chat_content.querySelector('span.chat-msg__author').textContent.trim()).toBe('Mercutio');
@@ -1437,7 +1434,7 @@
                 await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length, 300);
                 const message = '\n\n        This is a received message         \n\n';
                 const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-                _converse.chatboxes.onMessage(
+                await _converse.handleMessageStanza(
                     $msg({
                         'from': sender_jid,
                         'to': _converse.connection.jid,
@@ -1446,13 +1443,13 @@
                     }).c('body').t(message).up()
                     .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
                 );
-                await u.waitUntil(() => (_converse.api.chats.get().length === 2));
                 const view = _converse.api.chatviews.get(sender_jid);
                 expect(view.model.messages.length).toEqual(1);
                 const msg_obj = view.model.messages.at(0);
                 expect(msg_obj.get('message')).toEqual(message.trim());
                 const chat_content = view.el.querySelector('.chat-content');
-                expect(chat_content.querySelector('.chat-msg .chat-msg__text').textContent).toEqual(message.trim());
+                const mel = await u.waitUntil(() => chat_content.querySelector('.chat-msg .chat-msg__text'));
+                expect(mel.textContent).toEqual(message.trim());
                 done();
             }));
 
@@ -1467,7 +1464,7 @@
                 const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
                 const msg_id = u.getUniqueId();
                 const view = await test_utils.openChatBoxFor(_converse, sender_jid);
-                _converse.chatboxes.onMessage($msg({
+                _converse.handleMessageStanza($msg({
                         'from': sender_jid,
                         'to': _converse.connection.jid,
                         'type': 'chat',
@@ -1478,7 +1475,7 @@
                 expect(view.el.querySelector('.chat-msg__text').textContent)
                     .toBe('But soft, what light through yonder airlock breaks?');
 
-                _converse.chatboxes.onMessage($msg({
+                _converse.handleMessageStanza($msg({
                         'from': sender_jid,
                         'to': _converse.connection.jid,
                         'type': 'chat',
@@ -1493,7 +1490,7 @@
                 expect(view.el.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
                 expect(view.model.messages.models.length).toBe(1);
 
-                _converse.chatboxes.onMessage($msg({
+                _converse.handleMessageStanza($msg({
                         'from': sender_jid,
                         'to': _converse.connection.jid,
                         'type': 'chat',
@@ -1550,8 +1547,7 @@
                     // We don't already have an open chatbox for this user
                     expect(_converse.chatboxes.get(sender_jid)).not.toBeDefined();
 
-                    await _converse.chatboxes.onMessage(msg);
-                    await u.waitUntil(() => _converse.api.chats.get().length);
+                    await _converse.handleMessageStanza(msg);
                     expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
 
                     // Check that the chatbox and its view now exist
@@ -1600,15 +1596,15 @@
                     expect(_converse.chatboxes.get(sender_jid)).not.toBeDefined();
 
                     let chatbox = await _converse.api.chats.get(sender_jid);
-                    expect(chatbox).not.toBeDefined();
+                    expect(chatbox).toBe(null);
                     // onMessage is a handler for received XMPP messages
-                    await _converse.chatboxes.onMessage(msg);
+                    await _converse.handleMessageStanza(msg);
                     let view = _converse.chatboxviews.get(sender_jid);
                     expect(view).not.toBeDefined();
 
                     // onMessage is a handler for received XMPP messages
                     _converse.allow_non_roster_messaging = true;
-                    await _converse.chatboxes.onMessage(msg);
+                    await _converse.handleMessageStanza(msg);
                     view = _converse.chatboxviews.get(sender_jid);
                     await new Promise(resolve => view.once('messageInserted', resolve));
                     expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
@@ -1807,7 +1803,7 @@
                 // Create enough messages so that there's a scrollbar.
                 const promises = [];
                 for (let i=0; i<20; i++) {
-                    _converse.chatboxes.onMessage($msg({
+                    _converse.handleMessageStanza($msg({
                             from: sender_jid,
                             to: _converse.connection.jid,
                             type: 'chat',
@@ -1826,7 +1822,7 @@
                 view.model.set('scrolled', true);
 
                 const message = 'This message is received while the chat area is scrolled up';
-                _converse.chatboxes.onMessage($msg({
+                _converse.handleMessageStanza($msg({
                         from: sender_jid,
                         to: _converse.connection.jid,
                         type: 'chat',
@@ -1858,7 +1854,7 @@
                 await u.waitUntil(() => _converse.rosterview.el.querySelectorAll('.roster-group').length)
                 // Send a message from a different resource
                 spyOn(_converse, 'log');
-                spyOn(_converse.chatboxes, 'getChatBox').and.callThrough();
+                spyOn(_converse.api.chatboxes, 'create').and.callThrough();
                 _converse.filter_by_resource = true;
                 const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
                 let msg = $msg({
@@ -1868,12 +1864,12 @@
                         id: (new Date()).getTime()
                     }).c('body').t("This message will not be shown").up()
                     .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-                await _converse.chatboxes.onMessage(msg);
-                await u.waitUntil(() => _converse.api.chats.get().length);
+                await _converse.handleMessageStanza(msg);
+
                 expect(_converse.log).toHaveBeenCalledWith(
                         "onMessage: Ignoring incoming message intended for a different resource: romeo@montague.lit/some-other-resource",
                         Strophe.LogLevel.INFO);
-                expect(_converse.chatboxes.getChatBox).not.toHaveBeenCalled();
+                expect(_converse.api.chatboxes.create).not.toHaveBeenCalled();
                 _converse.filter_by_resource = false;
 
                 const message = "This message sent to a different resource will be shown";
@@ -1884,11 +1880,11 @@
                         id: '134234623462346'
                     }).c('body').t(message).up()
                         .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-                await _converse.chatboxes.onMessage(msg);
+                await _converse.handleMessageStanza(msg);
                 await u.waitUntil(() => _converse.chatboxviews.keys().length > 1, 1000);
                 const view = _converse.chatboxviews.get(sender_jid);
                 await u.waitUntil(() => view.model.messages.length);
-                expect(_converse.chatboxes.getChatBox).toHaveBeenCalled();
+                expect(_converse.api.chatboxes.create).toHaveBeenCalled();
                 const last_message = await u.waitUntil(() => sizzle('.chat-content:last .chat-msg__text', view.el).pop());
                 const msg_txt = last_message.textContent;
                 expect(msg_txt).toEqual(message);
@@ -2121,20 +2117,11 @@
 
             const sent_stanzas = [];
             spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s));
-            _converse.connection._dataRecv(test_utils.createRequest(stanza));
-            await u.waitUntil(() => _converse.api.chats.get().length == 2);
+            await _converse.handleMessageStanza(stanza);
             const sent_messages = sent_stanzas
                 .map(s => _.isElement(s) ? s : s.nodeTree)
                 .filter(e => e.nodeName === 'message');
-
-            // Only one message is sent out, and it's not a chat marker
-            expect(sent_messages.length).toBe(1);
-            expect(Strophe.serialize(sent_messages[0])).toBe(
-                `<message id="${sent_messages[0].getAttribute('id')}" to="someone@montague.lit" type="chat" xmlns="jabber:client">`+
-                    `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                    `<no-store xmlns="urn:xmpp:hints"/>`+
-                    `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
-                `</message>`);
+            expect(sent_messages.length).toBe(0);
             done();
         }));
 

+ 5 - 5
spec/minchats.js

@@ -95,7 +95,7 @@
                         id: (new Date()).getTime()
                     }).c('body').t('This message is sent to a minimized chatbox').up()
                     .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-                    _converse.chatboxes.onMessage(msg);
+                    _converse.handleMessageStanza(msg);
                 }
                 return u.waitUntil(() => chatview.model.messages.length);
             }).then(() => {
@@ -103,7 +103,7 @@
                 expect(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count').textContent).toBe((3).toString());
                 // Chat state notifications don't increment the unread messages counter
                 // <composing> state
-                _converse.chatboxes.onMessage($msg({
+                _converse.handleMessageStanza($msg({
                     from: contact_jid,
                     to: _converse.connection.jid,
                     type: 'chat',
@@ -112,7 +112,7 @@
                 expect(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count').textContent).toBe((i).toString());
 
                 // <paused> state
-                _converse.chatboxes.onMessage($msg({
+                _converse.handleMessageStanza($msg({
                     from: contact_jid,
                     to: _converse.connection.jid,
                     type: 'chat',
@@ -121,7 +121,7 @@
                 expect(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count').textContent).toBe((i).toString());
 
                 // <gone> state
-                _converse.chatboxes.onMessage($msg({
+                _converse.handleMessageStanza($msg({
                     from: contact_jid,
                     to: _converse.connection.jid,
                     type: 'chat',
@@ -130,7 +130,7 @@
                 expect(_converse.minimized_chats.toggleview.el.querySelector('.unread-message-count').textContent).toBe((i).toString());
 
                 // <inactive> state
-                _converse.chatboxes.onMessage($msg({
+                _converse.handleMessageStanza($msg({
                     from: contact_jid,
                     to: _converse.connection.jid,
                     type: 'chat',

+ 1 - 1
spec/muc.js

@@ -89,7 +89,7 @@
                 // Non-existing room
                 muc_jid = 'chillout2@montague.lit';
                 room = await _converse.api.rooms.get(muc_jid);
-                expect(typeof room === 'undefined').toBeTruthy();
+                expect(room).toBe(null);
                 done();
             }));
 

+ 0 - 2
spec/muc_messages.js

@@ -120,7 +120,6 @@
                                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;
@@ -572,7 +571,6 @@
             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();

+ 1 - 1
spec/notification.js

@@ -30,7 +30,7 @@
                                 id: (new Date()).getTime()
                             }).c('body').t(message).up()
                             .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
-                        await _converse.chatboxes.onMessage(msg); // This will emit 'message'
+                        await _converse.handleMessageStanza(msg); // This will emit 'message'
                         await u.waitUntil(() => _converse.api.chatviews.get(sender_jid));
                         expect(_converse.areDesktopNotificationsEnabled).toHaveBeenCalled();
                         expect(_converse.showMessageNotification).toHaveBeenCalled();

+ 0 - 1
spec/register.js

@@ -303,7 +303,6 @@
             spyOn(registerview, 'onRegistrationFields').and.callThrough();
             spyOn(registerview, 'renderRegistrationForm').and.callThrough();
             registerview.delegateEvents();  // We need to rebind all events otherwise our spy won't be called
-            spyOn(_converse.connection, 'connect').and.callThrough();
 
             registerview.el.querySelector('input[name=domain]').value = 'conversejs.org';
             registerview.el.querySelector('input[type=submit]').click();

+ 1 - 1
spec/roomslist.js

@@ -263,7 +263,7 @@
             expect(window.confirm).toHaveBeenCalledWith(
                 'Are you sure you want to leave the groupchat lounge@conference.shakespeare.lit?');
 
-            await u.waitUntil(() => !_converse.api.rooms.get().length);
+            await new Promise(resolve => _converse.api.listen.once('chatBoxClosed', resolve));
             room_els = _converse.rooms_list_view.el.querySelectorAll(".open-room");
             expect(room_els.length).toBe(0);
             expect(_converse.chatboxes.length).toBe(1);

+ 5 - 5
spec/spoilers.js

@@ -34,8 +34,8 @@
                       'xmlns': 'urn:xmpp:spoiler:0',
                     }).t(spoiler_hint)
                 .tree();
-            await _converse.chatboxes.onMessage(msg);
-            await u.waitUntil(() => _converse.api.chats.get().length === 2);
+            _converse.connection._dataRecv(test_utils.createRequest(msg));
+            await new Promise(resolve => _converse.api.listen.once('chatBoxInitialized', resolve));
             const view = _converse.chatboxviews.get(sender_jid);
             await new Promise(resolve => view.once('messageInserted', resolve));
             await u.waitUntil(() => view.model.vcard.get('fullname') === 'Mercutio')
@@ -69,10 +69,10 @@
                   .c('spoiler', {
                       'xmlns': 'urn:xmpp:spoiler:0',
                     }).tree();
-            await _converse.chatboxes.onMessage(msg);
-            await u.waitUntil(() => _converse.api.chats.get().length === 2);
+            _converse.connection._dataRecv(test_utils.createRequest(msg));
+            await new Promise(resolve => _converse.api.listen.once('chatBoxInitialized', resolve));
             const view = _converse.chatboxviews.get(sender_jid);
-            await new Promise(resolve => view.once('messageInserted', resolve));
+            await u.waitUntil(() => u.isVisible(view.el));
             await u.waitUntil(() => view.model.vcard.get('fullname') === 'Mercutio')
             expect(_.includes(view.el.querySelector('.chat-msg__author').textContent, 'Mercutio')).toBeTruthy();
             const message_content = view.el.querySelector('.chat-msg__text');

+ 22 - 19
src/converse-chatview.js

@@ -47,6 +47,7 @@ converse.plugins.add('converse-chatview', {
      */
     dependencies: [
         "converse-chatboxviews",
+        "converse-chat",
         "converse-disco",
         "converse-message-view",
         "converse-modal"
@@ -72,17 +73,6 @@ converse.plugins.add('converse-chatview', {
             },
         });
 
-        function onWindowStateChanged (data) {
-            if (_converse.chatboxviews) {
-                _converse.chatboxviews.forEach(view => {
-                    if (view.model.get('id') !== 'controlbox') {
-                        view.onWindowStateChanged(data.state);
-                    }
-                });
-            }
-        }
-        _converse.api.listen.on('windowStateChanged', onWindowStateChanged);
-
 
         _converse.ChatBoxHeading = _converse.ViewWithAvatar.extend({
             initialize () {
@@ -92,6 +82,9 @@ converse.plugins.add('converse-chatview', {
                 if (this.model.vcard) {
                     this.listenTo(this.model.vcard, 'change', this.debouncedRender);
                 }
+                if (this.model.contact) {
+                    this.listenTo(this.model.contact, 'destroy', this.debouncedRender);
+                }
                 if (this.model.rosterContactAdded) {
                     this.model.rosterContactAdded.then(() => {
                         this.listenTo(this.model.contact, 'change:nickname', this.debouncedRender);
@@ -101,8 +94,8 @@ converse.plugins.add('converse-chatview', {
             },
 
             render () {
-                const vcard = get(this.model, 'vcard'),
-                      vcard_json = vcard ? vcard.toJSON() : {};
+                const vcard = get(this.model, 'vcard');
+                const vcard_json = vcard ? vcard.toJSON() : {};
                 this.el.innerHTML = tpl_chatbox_head(
                     Object.assign(
                         vcard_json,
@@ -409,10 +402,6 @@ converse.plugins.add('converse-chatview', {
                 this.heading = new _converse.ChatBoxHeading({'model': this.model});
                 this.heading.render();
                 this.heading.chatview = this;
-
-                if (this.model.contact !== undefined) {
-                    this.listenTo(this.model.contact, 'destroy', this.heading.render);
-                }
                 const flyout = this.el.querySelector('.flyout');
                 flyout.insertBefore(this.heading.el, flyout.querySelector('.chat-body'));
                 return this;
@@ -1299,15 +1288,29 @@ converse.plugins.add('converse-chatview', {
 
         _converse.api.listen.on('chatBoxViewsInitialized', () => {
             const views = _converse.chatboxviews;
-            _converse.chatboxes.on('add', item => {
+            _converse.chatboxes.on('add', async item => {
                 if (!views.get(item.get('id')) && item.get('type') === _converse.PRIVATE_CHAT_TYPE) {
+                    await item.initialized;
                     views.add(item.get('id'), new _converse.ChatBoxView({model: item}));
                 }
             });
         });
 
-        // Advertise that we support XEP-0382 Message Spoilers
+
+        /************************ BEGIN Event Handlers ************************/
+        function onWindowStateChanged (data) {
+            if (_converse.chatboxviews) {
+                _converse.chatboxviews.forEach(view => {
+                    if (view.model.get('id') !== 'controlbox') {
+                        view.onWindowStateChanged(data.state);
+                    }
+                });
+            }
+        }
+        _converse.api.listen.on('windowStateChanged', onWindowStateChanged);
         _converse.api.listen.on('connected', () => _converse.api.disco.own.features.add(Strophe.NS.SPOILER));
+        /************************ END Event Handlers ************************/
+
 
         /************************ BEGIN API ************************/
         Object.assign(_converse.api, {

+ 18 - 9
src/converse-controlbox.js

@@ -69,7 +69,7 @@ converse.plugins.add('converse-controlbox', {
      *
      * NB: These plugins need to have already been loaded via require.js.
      */
-    dependencies: ["converse-modal", "converse-chatboxes", "converse-rosterview", "converse-chatview"],
+    dependencies: ["converse-modal", "converse-chatboxes", "converse-chat", "converse-rosterview", "converse-chatview"],
 
     enabled (_converse) {
         return !_converse.singleton;
@@ -626,16 +626,25 @@ converse.plugins.add('converse-controlbox', {
              * @namespace _converse.api.controlbox
              * @memberOf _converse.api
              */
-            'controlbox': {
+            controlbox: {
                 /**
-                 * Retrieves the controlbox view.
-                 *
+                 * Opens the controlbox
+                 * @method _converse.api.controlbox.open
+                 * @returns { Promise<_converse.ControlBox> }
+                 */
+                async open () {
+                    await _converse.api.waitUntil('chatBoxesFetched');
+                    const model = await _converse.api.chatboxes.get('controlbox') ||
+                      _converse.api.chatboxes.create('controlbox', {}, _converse.Controlbox);
+                    model.trigger('show');
+                    return model;
+                },
+
+                /**
+                 * Returns the controlbox view.
                  * @method _converse.api.controlbox.get
-                 *
-                 * @example
-                 * const view = _converse.api.controlbox.get();
-                 *
-                 * @returns {Backbone.View} View representing the controlbox
+                 * @returns { Backbone.View } View representing the controlbox
+                 * @example const view = _converse.api.controlbox.get();
                  */
                 get () {
                     return _converse.chatboxviews.get('controlbox');

+ 89 - 0
src/converse-headlines-view.js

@@ -0,0 +1,89 @@
+// Converse.js (A browser based XMPP chat client)
+// https://conversejs.org
+//
+// Copyright (c) 2019, Jan-Carel Brand <jc@opkode.com>
+// Licensed under the Mozilla Public License (MPLv2)
+/**
+ * @module converse-headline
+ */
+import "converse-chatview";
+import converse from "@converse/headless/converse-core";
+import tpl_chatbox from "templates/chatbox.html";
+
+
+converse.plugins.add('converse-headlines-view', {
+    /* Plugin dependencies are other plugins which might be
+     * overridden or relied upon, and therefore need to be loaded before
+     * this plugin.
+     *
+     * If the setting "strict_plugin_dependencies" is set to true,
+     * an error will be raised if the plugin is not found. By default it's
+     * false, which means these plugins are only loaded opportunistically.
+     *
+     * NB: These plugins need to have already been loaded via require.js.
+     */
+    dependencies: ["converse-headlines", "converse-chatview"],
+
+
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this;
+
+
+        _converse.HeadlinesBoxView = _converse.ChatBoxView.extend({
+            className: 'chatbox headlines',
+
+            events: {
+                'click .close-chatbox-button': 'close',
+                'click .toggle-chatbox-button': 'minimize',
+                'keypress textarea.chat-textarea': 'onKeyDown'
+            },
+
+            initialize () {
+                this.initDebounced();
+
+                this.model.disable_mam = true; // Don't do MAM queries for this box
+                this.listenTo(this.model.messages, 'add', this.onMessageAdded);
+                this.listenTo(this.model, 'show', this.show);
+                this.listenTo(this.model, 'destroy', this.hide);
+                this.listenTo(this.model, 'change:minimized', this.onMinimizedChanged);
+
+                this.render().insertHeading()
+                this.updateAfterMessagesFetched();
+                this.insertIntoDOM().hide();
+                _converse.api.trigger('chatBoxInitialized', this);
+            },
+
+            render () {
+                this.el.setAttribute('id', this.model.get('box_id'))
+                this.el.innerHTML = tpl_chatbox(
+                    Object.assign(this.model.toJSON(), {
+                            info_close: '',
+                            label_personal_message: '',
+                            show_send_button: false,
+                            show_toolbar: false,
+                            unread_msgs: ''
+                        }
+                    ));
+                this.content = this.el.querySelector('.chat-content');
+                return this;
+            },
+
+            // Override to avoid the methods in converse-chatview.js
+            'renderMessageForm': function renderMessageForm () {},
+            'afterShown': function afterShown () {}
+        });
+
+
+        _converse.api.listen.on('chatBoxViewsInitialized', () => {
+            const views = _converse.chatboxviews;
+            _converse.chatboxes.on('add', item => {
+                if (!views.get(item.get('id')) && item.get('type') === _converse.HEADLINES_TYPE) {
+                    views.add(item.get('id'), new _converse.HeadlinesBoxView({model: item}));
+                }
+            });
+        });
+    }
+});

+ 3 - 2
src/converse-minimize.js

@@ -112,8 +112,9 @@ converse.plugins.add('converse-minimize', {
 
         ChatBoxHeading: {
             render () {
-                const { _converse } = this.__super__,
-                    { __ } = _converse;
+                const { _converse } = this.__super__;
+                const { __ } = _converse;
+
                 this.__super__.render.apply(this, arguments);
                 const new_html = tpl_chatbox_minimize({
                     'info_minimize': __('Minimize this chat box')

+ 1 - 1
src/converse-roomslist.js

@@ -91,7 +91,7 @@ converse.plugins.add('converse-roomslist', {
 
             toHTML () {
                 return tpl_rooms_list({
-                    'rooms': _converse.api.rooms.get(),
+                    'rooms': this.model.filter(m => m.get('type') === _converse.CHATROOMS_TYPE),
                     'allow_bookmarks': _converse.allow_bookmarks && _converse.bookmarks,
                     'collapsed': this.list_model.get('toggle-state') !== _converse.OPENED,
                     'desc_rooms': __('Click to toggle the list of open groupchats'),

+ 2 - 1
src/converse.js

@@ -17,10 +17,10 @@ import "converse-controlbox";      // The control box
 import "converse-dragresize";      // Allows chat boxes to be resized by dragging them
 import "converse-emoji-views";
 import "converse-fullscreen";
-import "converse-headline";        // Support for headline messages
 import "converse-mam-views";
 import "converse-minimize";        // Allows chat boxes to be minimized
 import "converse-muc-views";       // Views related to MUC
+import "converse-headlines-view";
 import "converse-notification";    // HTML5 Notifications
 import "converse-omemo";
 import "converse-profile";
@@ -51,6 +51,7 @@ const WHITELISTED_PLUGINS = [
     'converse-minimize',
     'converse-modal',
     'converse-muc-views',
+    'converse-headlines-view',
     'converse-notification',
     'converse-omemo',
     'converse-profile',

+ 1440 - 0
src/headless/converse-chat.js

@@ -0,0 +1,1440 @@
+import { get, isObject, isString, propertyOf } from "lodash";
+import converse from "./converse-core";
+import filesize from "filesize";
+
+const { $msg, Backbone, Strophe, dayjs, sizzle, utils } = converse.env;
+const u = converse.env.utils;
+
+
+converse.plugins.add('converse-chat', {
+    /* Optional dependencies are other plugins which might be
+     * overridden or relied upon, and therefore need to be loaded before
+     * this plugin. They are called "optional" because they might not be
+     * available, in which case any overrides applicable to them will be
+     * ignored.
+     *
+     * It's possible however to make optional dependencies non-optional.
+     * If the setting "strict_plugin_dependencies" is set to true,
+     * an error will be raised if the plugin is not found.
+     *
+     * NB: These plugins need to have already been loaded via require.js.
+     */
+    dependencies: ["converse-chatboxes", "converse-disco"],
+
+    initialize () {
+        /* The initialize function gets called as soon as the plugin is
+         * loaded by converse.js's plugin machinery.
+         */
+        const { _converse } = this;
+        const { __ } = _converse;
+
+        // Configuration values for this plugin
+        // ====================================
+        // Refer to docs/source/configuration.rst for explanations of these
+        // configuration settings.
+        _converse.api.settings.update({
+            'auto_join_private_chats': [],
+            'clear_messages_on_reconnection': false,
+            'filter_by_resource': false,
+            'allow_message_corrections': 'all',
+            'send_chat_state_notifications': true
+        });
+
+
+        const ModelWithContact = Backbone.Model.extend({
+
+            initialize () {
+                this.rosterContactAdded = u.getResolveablePromise();
+            },
+
+            async setRosterContact (jid) {
+                const contact = await _converse.api.contacts.get(jid);
+                if (contact) {
+                    this.contact = contact;
+                    this.set('nickname', contact.get('nickname'));
+                    this.rosterContactAdded.resolve();
+                }
+            }
+        });
+
+
+        /**
+         * Represents a non-MUC message. These can be either `chat` messages or
+         * `headline` messages.
+         * @class
+         * @namespace _converse.Message
+         * @memberOf _converse
+         * @example const msg = new _converse.Message({'message': 'hello world!'});
+         */
+        _converse.Message = ModelWithContact.extend({
+
+            defaults () {
+                return {
+                    'msgid': u.getUniqueId(),
+                    'time': (new Date()).toISOString(),
+                    'ephemeral': false
+                };
+            },
+
+            async initialize () {
+                this.initialized = u.getResolveablePromise();
+
+                if (this.get('type') === 'chat') {
+                    ModelWithContact.prototype.initialize.apply(this, arguments);
+                    this.setRosterContact(Strophe.getBareJidFromJid(this.get('from')));
+                }
+
+                if (this.get('file')) {
+                    this.on('change:put', this.uploadFile, this);
+                }
+                if (this.isEphemeral()) {
+                    window.setTimeout(this.safeDestroy.bind(this), 10000);
+                }
+                await _converse.api.trigger('messageInitialized', this, {'Synchronous': true});
+                this.initialized.resolve();
+            },
+
+            safeDestroy () {
+                try {
+                    this.destroy()
+                } catch (e) {
+                    _converse.log(e, Strophe.LogLevel.ERROR);
+                }
+            },
+
+            isOnlyChatStateNotification () {
+                return u.isOnlyChatStateNotification(this);
+            },
+
+            isEphemeral () {
+                return this.isOnlyChatStateNotification() || this.get('ephemeral');
+            },
+
+            getDisplayName () {
+                if (this.get('type') === 'groupchat') {
+                    return this.get('nick');
+                } else if (this.contact) {
+                    return this.contact.getDisplayName();
+                } else if (this.vcard) {
+                    return this.vcard.getDisplayName();
+                } else {
+                    return this.get('from');
+                }
+            },
+
+            getMessageText () {
+                if (this.get('is_encrypted')) {
+                    return this.get('plaintext') ||
+                           (_converse.debug ? __('Unencryptable OMEMO message') : null);
+                }
+                return this.get('message');
+            },
+
+            isMeCommand () {
+                const text = this.getMessageText();
+                if (!text) {
+                    return false;
+                }
+                return text.startsWith('/me ');
+            },
+
+            sendSlotRequestStanza () {
+                /* Send out an IQ stanza to request a file upload slot.
+                 *
+                 * https://xmpp.org/extensions/xep-0363.html#request
+                 */
+                if (!this.file) {
+                    return Promise.reject(new Error("file is undefined"));
+                }
+                const iq = converse.env.$iq({
+                    'from': _converse.jid,
+                    'to': this.get('slot_request_url'),
+                    'type': 'get'
+                }).c('request', {
+                    'xmlns': Strophe.NS.HTTPUPLOAD,
+                    'filename': this.file.name,
+                    'size': this.file.size,
+                    'content-type': this.file.type
+                })
+                return _converse.api.sendIQ(iq);
+            },
+
+            async getRequestSlotURL () {
+                let stanza;
+                try {
+                    stanza = await this.sendSlotRequestStanza();
+                } catch (e) {
+                    _converse.log(e, Strophe.LogLevel.ERROR);
+                    return this.save({
+                        'type': 'error',
+                        'message': __("Sorry, could not determine upload URL."),
+                        'ephemeral': true
+                    });
+                }
+                const slot = stanza.querySelector('slot');
+                if (slot) {
+                    this.save({
+                        'get':  slot.querySelector('get').getAttribute('url'),
+                        'put': slot.querySelector('put').getAttribute('url'),
+                    });
+                } else {
+                    return this.save({
+                        'type': 'error',
+                        'message': __("Sorry, could not determine file upload URL."),
+                        'ephemeral': true
+                    });
+                }
+            },
+
+            uploadFile () {
+                const xhr = new XMLHttpRequest();
+                xhr.onreadystatechange = () => {
+                    if (xhr.readyState === XMLHttpRequest.DONE) {
+                        _converse.log("Status: " + xhr.status, Strophe.LogLevel.INFO);
+                        if (xhr.status === 200 || xhr.status === 201) {
+                            this.save({
+                                'upload': _converse.SUCCESS,
+                                'oob_url': this.get('get'),
+                                'message': this.get('get')
+                            });
+                        } else {
+                            xhr.onerror();
+                        }
+                    }
+                };
+
+                xhr.upload.addEventListener("progress", (evt) => {
+                    if (evt.lengthComputable) {
+                        this.set('progress', evt.loaded / evt.total);
+                    }
+                }, false);
+
+                xhr.onerror = () => {
+                    let message;
+                    if (xhr.responseText) {
+                        message = __('Sorry, could not succesfully upload your file. Your server’s response: "%1$s"', xhr.responseText)
+                    } else {
+                        message = __('Sorry, could not succesfully upload your file.');
+                    }
+                    this.save({
+                        'type': 'error',
+                        'upload': _converse.FAILURE,
+                        'message': message,
+                        'ephemeral': true
+                    });
+                };
+                xhr.open('PUT', this.get('put'), true);
+                xhr.setRequestHeader("Content-type", this.file.type);
+                xhr.send(this.file);
+            }
+        });
+
+
+        _converse.Messages = _converse.Collection.extend({
+            model: _converse.Message,
+            comparator: 'time'
+        });
+
+
+        /**
+         * Represents an open/ongoing chat conversation.
+         *
+         * @class
+         * @namespace _converse.ChatBox
+         * @memberOf _converse
+         */
+        _converse.ChatBox = ModelWithContact.extend({
+            messagesCollection: _converse.Messages,
+
+            defaults () {
+                return {
+                    'bookmarked': false,
+                    'chat_state': undefined,
+                    'hidden': ['mobile', 'fullscreen'].includes(_converse.view_mode),
+                    'message_type': 'chat',
+                    'nickname': undefined,
+                    'num_unread': 0,
+                    'time_sent': (new Date(0)).toISOString(),
+                    'time_opened': this.get('time_opened') || (new Date()).getTime(),
+                    'type': _converse.PRIVATE_CHAT_TYPE,
+                    'url': ''
+                }
+            },
+
+            async initialize () {
+                this.initialized = u.getResolveablePromise();
+                ModelWithContact.prototype.initialize.apply(this, arguments);
+
+                const jid = this.get('jid');
+                if (!jid) {
+                    // XXX: The `validate` method will prevent this model
+                    // from being persisted if there's no jid, but that gets
+                    // called after model instantiation, so we have to deal
+                    // with invalid models here also.
+                    // This happens when the controlbox is in browser storage,
+                    // but we're in embedded mode.
+                    return;
+                }
+                this.set({'box_id': `box-${btoa(jid)}`});
+
+                if (_converse.vcards) {
+                    this.vcard = _converse.vcards.findWhere({'jid': jid}) || _converse.vcards.create({'jid': jid});
+                }
+                if (this.get('type') === _converse.PRIVATE_CHAT_TYPE) {
+                    this.presence = _converse.presences.findWhere({'jid': jid}) || _converse.presences.create({'jid': jid});
+                    await this.setRosterContact(jid);
+                }
+                this.on('change:chat_state', this.sendChatState, this);
+                this.initMessages();
+                await this.fetchMessages();
+                this.initialized.resolve();
+            },
+
+            getMessagesCacheKey () {
+                return `converse.messages-${this.get('jid')}-${_converse.bare_jid}`;
+            },
+
+            initMessages () {
+                this.messages = new this.messagesCollection();
+                this.messages.chatbox = this;
+                this.messages.browserStorage = _converse.createStore(this.getMessagesCacheKey());
+                this.listenTo(this.messages, 'change:upload', message => {
+                    if (message.get('upload') === _converse.SUCCESS) {
+                        _converse.api.send(this.createMessageStanza(message));
+                    }
+                });
+            },
+
+            afterMessagesFetched () {
+                /**
+                 * Triggered whenever a `_converse.ChatBox` instance has fetched its messages from
+                 * `sessionStorage` but **NOT** from the server.
+                 * @event _converse#afterMessagesFetched
+                 * @type {_converse.ChatBox | _converse.ChatRoom}
+                 * @example _converse.api.listen.on('afterMessagesFetched', view => { ... });
+                 */
+                _converse.api.trigger('afterMessagesFetched', this);
+            },
+
+            fetchMessages () {
+                if (this.messages.fetched) {
+                    _converse.log(`Not re-fetching messages for ${this.get('jid')}`, Strophe.LogLevel.INFO);
+                    return;
+                }
+                this.messages.fetched = u.getResolveablePromise();
+                const resolve = this.messages.fetched.resolve;
+                this.messages.fetch({
+                    'add': true,
+                    'success': () => { this.afterMessagesFetched(); resolve() },
+                    'error': () => { this.afterMessagesFetched(); resolve() }
+                });
+                return this.messages.fetched;
+            },
+
+            async onMessage (stanza, original_stanza, from_jid) {
+                const message = await this.getDuplicateMessage(stanza);
+                if (message) {
+                    this.updateMessage(message, original_stanza);
+                } else {
+                    if (
+                        !this.handleReceipt (stanza, from_jid) &&
+                        !this.handleChatMarker(stanza, from_jid)
+                    ) {
+                        const attrs = await this.getMessageAttributesFromStanza(stanza, original_stanza);
+                        this.setEditable(attrs, attrs.time, stanza);
+                        if (attrs['chat_state'] || !u.isEmptyMessage(attrs)) {
+                            const msg = this.correctMessage(attrs) || this.messages.create(attrs);
+                            this.incrementUnreadMsgCounter(msg);
+                        }
+                    }
+                }
+            },
+
+            async clearMessages () {
+                try {
+                    await Promise.all(this.messages.models.map(m => m.destroy()));
+                    this.messages.reset();
+                } catch (e) {
+                    this.messages.trigger('reset');
+                    _converse.log(e, Strophe.LogLevel.ERROR);
+                } finally {
+                    delete this.messages.fetched;
+                }
+            },
+
+            async close () {
+                try {
+                    await new Promise((success, reject) => {
+                        return this.destroy({success, 'error': (m, e) => reject(e)})
+                    });
+                } catch (e) {
+                    _converse.log(e, Strophe.LogLevel.ERROR);
+                } finally {
+                    if (_converse.clear_messages_on_reconnection) {
+                        await this.clearMessages();
+                    }
+                }
+            },
+
+            announceReconnection () {
+                /**
+                 * Triggered whenever a `_converse.ChatBox` instance has reconnected after an outage
+                 * @event _converse#onChatReconnected
+                 * @type {_converse.ChatBox | _converse.ChatRoom}
+                 * @example _converse.api.listen.on('onChatReconnected', chatbox => { ... });
+                 */
+                _converse.api.trigger('chatReconnected', this);
+            },
+
+            onReconnection () {
+                if (_converse.clear_messages_on_reconnection) {
+                    this.clearMessages();
+                }
+                this.announceReconnection();
+            },
+
+            validate (attrs) {
+                if (!attrs.jid) {
+                    return 'Ignored ChatBox without JID';
+                }
+                const room_jids = _converse.auto_join_rooms.map(s => isObject(s) ? s.jid : s);
+                const auto_join = _converse.auto_join_private_chats.concat(room_jids);
+                if (_converse.singleton && !auto_join.includes(attrs.jid) && !_converse.auto_join_on_invite) {
+                    const msg = `${attrs.jid} is not allowed because singleton is true and it's not being auto_joined`;
+                    _converse.log(msg, Strophe.LogLevel.WARN);
+                    return msg;
+                }
+            },
+
+            getDisplayName () {
+                if (this.contact) {
+                    return this.contact.getDisplayName();
+                } else if (this.vcard) {
+                    return this.vcard.getDisplayName();
+                } else {
+                    return this.get('jid');
+                }
+            },
+
+            createMessageFromError (error) {
+                if (error instanceof _converse.TimeoutError) {
+                    const msg = this.messages.create({'type': 'error', 'message': error.message, 'retry': true});
+                    msg.error = error;
+                }
+            },
+
+            getOldestMessage () {
+                for (let i=0; i<this.messages.length; i++) {
+                    const message = this.messages.at(i);
+                    if (message.get('type') === this.get('message_type')) {
+                        return message;
+                    }
+                }
+            },
+
+
+            getMostRecentMessage () {
+                for (let i=this.messages.length-1; i>=0; i--) {
+                    const message = this.messages.at(i);
+                    if (message.get('type') === this.get('message_type')) {
+                        return message;
+                    }
+                }
+            },
+
+            getUpdatedMessageAttributes (message, stanza) {  // eslint-disable-line no-unused-vars
+                // Overridden in converse-muc and converse-mam
+                return {};
+            },
+
+            updateMessage (message, stanza) {
+                // Overridden in converse-muc and converse-mam
+                const attrs = this.getUpdatedMessageAttributes(message, stanza);
+                if (attrs) {
+                    message.save(attrs);
+                }
+            },
+
+            /**
+             * Mutator for setting the chat state of this chat session.
+             * Handles clearing of any chat state notification timeouts and
+             * setting new ones if necessary.
+             * Timeouts are set when the  state being set is COMPOSING or PAUSED.
+             * After the timeout, COMPOSING will become PAUSED and PAUSED will become INACTIVE.
+             * See XEP-0085 Chat State Notifications.
+             * @private
+             * @method _converse.ChatBox#setChatState
+             * @param { string } state - The chat state (consts ACTIVE, COMPOSING, PAUSED, INACTIVE, GONE)
+             */
+            setChatState (state, options) {
+                if (this.chat_state_timeout !== undefined) {
+                    window.clearTimeout(this.chat_state_timeout);
+                    delete this.chat_state_timeout;
+                }
+                if (state === _converse.COMPOSING) {
+                    this.chat_state_timeout = window.setTimeout(
+                        this.setChatState.bind(this),
+                        _converse.TIMEOUTS.PAUSED,
+                        _converse.PAUSED
+                    );
+                } else if (state === _converse.PAUSED) {
+                    this.chat_state_timeout = window.setTimeout(
+                        this.setChatState.bind(this),
+                        _converse.TIMEOUTS.INACTIVE,
+                        _converse.INACTIVE
+                    );
+                }
+                this.set('chat_state', state, options);
+                return this;
+            },
+
+            /**
+             * @private
+             * @method _converse.ChatBox#shouldShowErrorMessage
+             * @returns {boolean}
+             */
+            shouldShowErrorMessage (stanza) {
+                const id = stanza.getAttribute('id');
+                if (id) {
+                    const msgs = this.messages.where({'msgid': id});
+                    const referenced_msgs = msgs.filter(m => m.get('type') !== 'error');
+                    if (!referenced_msgs.length && stanza.querySelector('body') === null) {
+                        // If the error refers to a message not included in our store,
+                        // and it doesn't have a <body> tag, we assume that this was a
+                        // CSI message (which we don't store).
+                        // See https://github.com/conversejs/converse.js/issues/1317
+                        return;
+                    }
+                    const dupes = msgs.filter(m => m.get('type') === 'error');
+                    if (dupes.length) {
+                        return;
+                    }
+                }
+                // Gets overridden in ChatRoom
+                return true;
+            },
+
+            /**
+             * If the passed in `message` stanza contains an
+             * [XEP-0308](https://xmpp.org/extensions/xep-0308.html#usecase)
+             * `<replace>` element, return its `id` attribute.
+             * @private
+             * @method _converse.ChatBox#getReplaceId
+             * @param { XMLElement } stanza
+             */
+            getReplaceId (stanza) {
+                const el = sizzle(`replace[xmlns="${Strophe.NS.MESSAGE_CORRECT}"]`, stanza).pop();
+                if (el) {
+                    return el.getAttribute('id');
+                }
+            },
+
+            /**
+             * Determine whether the passed in message attributes represent a
+             * message which corrects a previously received message, or an
+             * older message which has already been corrected.
+             * In both cases, update the corrected message accordingly.
+             * @private
+             * @method _converse.ChatBox#correctMessage
+             * @param { object } attrs - Attributes representing a received
+             *     message, as returned by
+             *     {@link _converse.ChatBox.getMessageAttributesFromStanza}
+             */
+            correctMessage (attrs) {
+                if (!attrs.replaced_id || !attrs.from) {
+                    return;
+                }
+                const message = this.messages.findWhere({'msgid': attrs.replaced_id, 'from': attrs.from});
+                if (!message) {
+                    return;
+                }
+                const older_versions = message.get('older_versions') || {};
+                if ((attrs.time < message.get('time')) && message.get('edited')) {
+                    // This is an older message which has been corrected afterwards
+                    older_versions[attrs.time] = attrs['message'];
+                    message.save({'older_versions': older_versions});
+                } else {
+                    // This is a correction of an earlier message we already received
+                    older_versions[message.get('time')] = message.get('message');
+                    attrs = Object.assign(attrs, {'older_versions': older_versions});
+                    delete attrs['id']; // Delete id, otherwise a new cache entry gets created
+                    message.save(attrs);
+                }
+                return message;
+            },
+
+            async getDuplicateMessage (stanza) {
+                return this.findDuplicateFromOriginID(stanza) ||
+                    await this.findDuplicateFromStanzaID(stanza) ||
+                    this.findDuplicateFromMessage(stanza);
+            },
+
+            findDuplicateFromOriginID  (stanza) {
+                const origin_id = sizzle(`origin-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop();
+                if (!origin_id) {
+                    return null;
+                }
+                return this.messages.findWhere({
+                    'origin_id': origin_id.getAttribute('id'),
+                    'from': stanza.getAttribute('from')
+                });
+            },
+
+            async findDuplicateFromStanzaID (stanza) {
+                const stanza_id = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop();
+                if (!stanza_id) {
+                    return false;
+                }
+                const by_jid = stanza_id.getAttribute('by');
+                if (!(await _converse.api.disco.supports(Strophe.NS.SID, by_jid))) {
+                    return false;
+                }
+                const query = {};
+                query[`stanza_id ${by_jid}`] = stanza_id.getAttribute('id');
+                return this.messages.findWhere(query);
+            },
+
+            findDuplicateFromMessage (stanza) {
+                const text = this.getMessageBody(stanza) || undefined;
+                if (!text) { return false; }
+                const id = stanza.getAttribute('id');
+                if (!id) { return false; }
+                return this.messages.findWhere({
+                    'message': text,
+                    'from': stanza.getAttribute('from'),
+                    'msgid': id
+                });
+            },
+
+            sendMarker(to_jid, id, type) {
+                const stanza = $msg({
+                    'from': _converse.connection.jid,
+                    'id': u.getUniqueId(),
+                    'to': to_jid,
+                    'type': 'chat',
+                }).c(type, {'xmlns': Strophe.NS.MARKERS, 'id': id});
+                _converse.api.send(stanza);
+            },
+
+            handleChatMarker (stanza, from_jid) {
+                const to_bare_jid = Strophe.getBareJidFromJid(stanza.getAttribute('to'));
+                if (to_bare_jid !== _converse.bare_jid) {
+                    return false;
+                }
+                const markers = sizzle(`[xmlns="${Strophe.NS.MARKERS}"]`, stanza);
+                if (markers.length === 0) {
+                    return false;
+                } else if (markers.length > 1) {
+                    _converse.log(
+                        'handleChatMarker: Ignoring incoming stanza with multiple message markers',
+                        Strophe.LogLevel.ERROR
+                    );
+                    _converse.log(stanza, Strophe.LogLevel.ERROR);
+                    return false;
+                } else {
+                    const marker = markers.pop();
+                    if (marker.nodeName === 'markable') {
+                        if (this.contact && !u.isMAMMessage(stanza) && !u.isCarbonMessage(stanza)) {
+                            this.sendMarker(from_jid, stanza.getAttribute('id'), 'received');
+                        }
+                        return false;
+                    } else {
+                        const msgid = marker && marker.getAttribute('id'),
+                            message = msgid && this.messages.findWhere({msgid}),
+                            field_name = `marker_${marker.nodeName}`;
+
+                        if (message && !message.get(field_name)) {
+                            message.save({field_name: (new Date()).toISOString()});
+                        }
+                        return true;
+                    }
+                }
+            },
+
+            sendReceiptStanza (to_jid, id) {
+                const receipt_stanza = $msg({
+                    'from': _converse.connection.jid,
+                    'id': u.getUniqueId(),
+                    'to': to_jid,
+                    'type': 'chat',
+                }).c('received', {'xmlns': Strophe.NS.RECEIPTS, 'id': id}).up()
+                .c('store', {'xmlns': Strophe.NS.HINTS}).up();
+                _converse.api.send(receipt_stanza);
+            },
+
+            handleReceipt (stanza, from_jid) {
+                const is_me = Strophe.getBareJidFromJid(from_jid) === _converse.bare_jid;
+                const requests_receipt = sizzle(`request[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop() !== undefined;
+                if (requests_receipt && !is_me && !u.isCarbonMessage(stanza)) {
+                    this.sendReceiptStanza(from_jid, stanza.getAttribute('id'));
+                }
+                const to_bare_jid = Strophe.getBareJidFromJid(stanza.getAttribute('to'));
+                if (to_bare_jid === _converse.bare_jid) {
+                    const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, stanza).pop();
+                    if (receipt) {
+                        const msgid = receipt && receipt.getAttribute('id'),
+                            message = msgid && this.messages.findWhere({msgid});
+                        if (message && !message.get('received')) {
+                            message.save({'received': (new Date()).toISOString()});
+                        }
+                        return true;
+                    }
+                }
+                return false;
+            },
+
+            /**
+             * Given a {@link _converse.Message} return the XML stanza that represents it.
+             * @private
+             * @method _converse.ChatBox#createMessageStanza
+             * @param { _converse.Message } message - The message object
+             */
+            createMessageStanza (message) {
+                const stanza = $msg({
+                        'from': _converse.connection.jid,
+                        'to': this.get('jid'),
+                        'type': this.get('message_type'),
+                        'id': message.get('edited') && u.getUniqueId() || message.get('msgid'),
+                    }).c('body').t(message.get('message')).up()
+                      .c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).root();
+
+                if (message.get('type') === 'chat') {
+                    stanza.c('request', {'xmlns': Strophe.NS.RECEIPTS}).root();
+                }
+                if (message.get('is_spoiler')) {
+                    if (message.get('spoiler_hint')) {
+                        stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}, message.get('spoiler_hint')).root();
+                    } else {
+                        stanza.c('spoiler', {'xmlns': Strophe.NS.SPOILER}).root();
+                    }
+                }
+                (message.get('references') || []).forEach(reference => {
+                    const attrs = {
+                        'xmlns': Strophe.NS.REFERENCE,
+                        'begin': reference.begin,
+                        'end': reference.end,
+                        'type': reference.type,
+                    }
+                    if (reference.uri) {
+                        attrs.uri = reference.uri;
+                    }
+                    stanza.c('reference', attrs).root();
+                });
+
+                if (message.get('oob_url')) {
+                    stanza.c('x', {'xmlns': Strophe.NS.OUTOFBAND}).c('url').t(message.get('oob_url')).root();
+                }
+                if (message.get('edited')) {
+                    stanza.c('replace', {
+                        'xmlns': Strophe.NS.MESSAGE_CORRECT,
+                        'id': message.get('msgid')
+                    }).root();
+                }
+                if (message.get('origin_id')) {
+                    stanza.c('origin-id', {'xmlns': Strophe.NS.SID, 'id': message.get('origin_id')}).root();
+                }
+                return stanza;
+            },
+
+            getOutgoingMessageAttributes (text, spoiler_hint) {
+                const is_spoiler = this.get('composing_spoiler');
+                const origin_id = u.getUniqueId();
+                return {
+                    'id': origin_id,
+                    'jid': this.get('jid'),
+                    'nickname': this.get('nickname'),
+                    'msgid': origin_id,
+                    'origin_id': origin_id,
+                    'fullname': _converse.xmppstatus.get('fullname'),
+                    'from': _converse.bare_jid,
+                    'is_single_emoji': text ? u.isSingleEmoji(text) : false,
+                    'sender': 'me',
+                    'time': (new Date()).toISOString(),
+                    'message': text ? u.httpToGeoUri(u.shortnameToUnicode(text), _converse) : undefined,
+                    'is_spoiler': is_spoiler,
+                    'spoiler_hint': is_spoiler ? spoiler_hint : undefined,
+                    'type': this.get('message_type')
+                }
+            },
+
+            /**
+             * Responsible for setting the editable attribute of messages.
+             * If _converse.allow_message_corrections is "last", then only the last
+             * message sent from me will be editable. If set to "all" all messages
+             * will be editable. Otherwise no messages will be editable.
+             * @method _converse.ChatBox#setEditable
+             * @memberOf _converse.ChatBox
+             * @param { Object } attrs An object containing message attributes.
+             * @param { String } send_time - time when the message was sent
+             */
+            setEditable (attrs, send_time, stanza) {
+                if (stanza && u.isHeadlineMessage(_converse, stanza)) {
+                    return;
+                }
+                if (u.isEmptyMessage(attrs) || attrs.sender !== 'me') {
+                    return;
+                }
+                if (_converse.allow_message_corrections === 'all') {
+                    attrs.editable = !(attrs.file || 'oob_url' in attrs);
+                } else if ((_converse.allow_message_corrections === 'last') &&
+                           (send_time > this.get('time_sent'))) {
+                    this.set({'time_sent': send_time});
+                    const msg = this.messages.findWhere({'editable': true});
+                    if (msg) {
+                        msg.save({'editable': false});
+                    }
+                    attrs.editable = !(attrs.file || 'oob_url' in attrs);
+                }
+            },
+
+            /**
+             * Responsible for sending off a text message inside an ongoing chat conversation.
+             * @method _converse.ChatBox#sendMessage
+             * @memberOf _converse.ChatBox
+             * @param { String } text - The chat message text
+             * @param { String } spoiler_hint - An optional hint, if the message being sent is a spoiler
+             * @returns { _converse.Message }
+             * @example
+             * const chat = _converse.api.chats.get('buddy1@example.com');
+             * chat.sendMessage('hello world');
+             */
+            sendMessage (text, spoiler_hint) {
+                const attrs = this.getOutgoingMessageAttributes(text, spoiler_hint);
+                let message = this.messages.findWhere('correcting')
+                if (message) {
+                    const older_versions = message.get('older_versions') || {};
+                    older_versions[message.get('time')] = message.get('message');
+                    message.save({
+                        'correcting': false,
+                        'edited': (new Date()).toISOString(),
+                        'message': attrs.message,
+                        'older_versions': older_versions,
+                        'references': attrs.references,
+                        'is_single_emoji':  attrs.message ? u.isSingleEmoji(attrs.message) : false,
+                        'origin_id': u.getUniqueId(),
+                        'received': undefined
+                    });
+                } else {
+                    this.setEditable(attrs, (new Date()).toISOString());
+                    message = this.messages.create(attrs);
+                }
+                _converse.api.send(this.createMessageStanza(message));
+                return message;
+            },
+
+            /**
+             * Sends a message with the current XEP-0085 chat state of the user
+             * as taken from the `chat_state` attribute of the {@link _converse.ChatBox}.
+             * @private
+             * @method _converse.ChatBox#sendChatState
+             */
+            sendChatState () {
+                if (_converse.send_chat_state_notifications && this.get('chat_state')) {
+                    const allowed = _converse.send_chat_state_notifications;
+                    if (Array.isArray(allowed) && !allowed.includes(this.get('chat_state'))) {
+                        return;
+                    }
+                    _converse.api.send(
+                        $msg({
+                            'id': u.getUniqueId(),
+                            'to': this.get('jid'),
+                            'type': 'chat'
+                        }).c(this.get('chat_state'), {'xmlns': Strophe.NS.CHATSTATES}).up()
+                        .c('no-store', {'xmlns': Strophe.NS.HINTS}).up()
+                        .c('no-permanent-store', {'xmlns': Strophe.NS.HINTS})
+                    );
+                }
+            },
+
+
+            async sendFiles (files) {
+                const result = await _converse.api.disco.features.get(Strophe.NS.HTTPUPLOAD, _converse.domain);
+                const item = result.pop();
+                if (!item) {
+                    this.messages.create({
+                        'message': __("Sorry, looks like file upload is not supported by your server."),
+                        'type': 'error',
+                        'ephemeral': true
+                    });
+                    return;
+                }
+                const data = item.dataforms.where({'FORM_TYPE': {'value': Strophe.NS.HTTPUPLOAD, 'type': "hidden"}}).pop(),
+                      max_file_size = window.parseInt(get(data, 'attributes.max-file-size.value')),
+                      slot_request_url = get(item, 'id');
+
+                if (!slot_request_url) {
+                    this.messages.create({
+                        'message': __("Sorry, looks like file upload is not supported by your server."),
+                        'type': 'error',
+                        'ephemeral': true
+                    });
+                    return;
+                }
+                Array.from(files).forEach(file => {
+                    if (!window.isNaN(max_file_size) && window.parseInt(file.size) > max_file_size) {
+                        return this.messages.create({
+                            'message': __('The size of your file, %1$s, exceeds the maximum allowed by your server, which is %2$s.',
+                                file.name, filesize(max_file_size)),
+                            'type': 'error',
+                            'ephemeral': true
+                        });
+                    } else {
+                        const attrs = Object.assign(
+                            this.getOutgoingMessageAttributes(), {
+                            'file': true,
+                            'progress': 0,
+                            'slot_request_url': slot_request_url
+                        });
+                        this.setEditable(attrs, (new Date()).toISOString());
+                        const message = this.messages.create(attrs, {'silent': true});
+                        message.file = file;
+                        this.messages.trigger('add', message);
+                        message.getRequestSlotURL();
+                    }
+                });
+            },
+
+            getReferencesFromStanza (stanza) {
+                const text = propertyOf(stanza.querySelector('body'))('textContent');
+                return sizzle(`reference[xmlns="${Strophe.NS.REFERENCE}"]`, stanza).map(ref => {
+                    const begin = ref.getAttribute('begin'),
+                          end = ref.getAttribute('end');
+                    return  {
+                        'begin': begin,
+                        'end': end,
+                        'type': ref.getAttribute('type'),
+                        'value': text.slice(begin, end),
+                        'uri': ref.getAttribute('uri')
+                    };
+                });
+            },
+
+            /**
+             * Extract the XEP-0359 stanza IDs from the passed in stanza
+             * and return a map containing them.
+             * @private
+             * @method _converse.ChatBox#getStanzaIDs
+             * @param { XMLElement } stanza - The message stanza
+             */
+            getStanzaIDs (stanza) {
+                const attrs = {};
+                const stanza_ids = sizzle(`stanza-id[xmlns="${Strophe.NS.SID}"]`, stanza);
+                if (stanza_ids.length) {
+                    stanza_ids.forEach(s => (attrs[`stanza_id ${s.getAttribute('by')}`] = s.getAttribute('id')));
+                }
+                const result = sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, stanza).pop();
+                if (result) {
+                    const by_jid = stanza.getAttribute('from');
+                    attrs[`stanza_id ${by_jid}`] = result.getAttribute('id');
+                }
+
+                const origin_id = sizzle(`origin-id[xmlns="${Strophe.NS.SID}"]`, stanza).pop();
+                if (origin_id) {
+                    attrs['origin_id'] = origin_id.getAttribute('id');
+                }
+                return attrs;
+            },
+
+            isArchived (original_stanza) {
+                return !!sizzle(`result[xmlns="${Strophe.NS.MAM}"]`, original_stanza).pop();
+            },
+
+            getErrorMessage (stanza) {
+                const error = stanza.querySelector('error');
+                return propertyOf(error.querySelector('text'))('textContent') ||
+                    __('Sorry, an error occurred:') + ' ' + error.innerHTML;
+            },
+
+            /**
+             * Given a message stanza, return the text contained in its body.
+             * @private
+             * @param { XMLElement } stanza
+             */
+            getMessageBody (stanza) {
+                const type = stanza.getAttribute('type');
+                if (type === 'error') {
+                    return this.getErrorMessage(stanza);
+                } else {
+                    const body = stanza.querySelector('body');
+                    if (body) {
+                        return body.textContent.trim();
+                    }
+                }
+            },
+
+
+            /**
+             * Parses a passed in message stanza and returns an object
+             * of attributes.
+             * @private
+             * @method _converse.ChatBox#getMessageAttributesFromStanza
+             * @param { XMLElement } stanza - The message stanza
+             * @param { XMLElement } delay - The <delay> node from the stanza, if there was one.
+             * @param { XMLElement } original_stanza - The original stanza, that contains the
+             *  message stanza, if it was contained, otherwise it's the message stanza itself.
+             */
+            async getMessageAttributesFromStanza (stanza, original_stanza) {
+                const spoiler = sizzle(`spoiler[xmlns="${Strophe.NS.SPOILER}"]`, original_stanza).pop();
+                const delay = sizzle(`delay[xmlns="${Strophe.NS.DELAY}"]`, original_stanza).pop();
+                const text = this.getMessageBody(stanza) || undefined;
+                const chat_state = stanza.getElementsByTagName(_converse.COMPOSING).length && _converse.COMPOSING ||
+                            stanza.getElementsByTagName(_converse.PAUSED).length && _converse.PAUSED ||
+                            stanza.getElementsByTagName(_converse.INACTIVE).length && _converse.INACTIVE ||
+                            stanza.getElementsByTagName(_converse.ACTIVE).length && _converse.ACTIVE ||
+                            stanza.getElementsByTagName(_converse.GONE).length && _converse.GONE;
+
+                const replaced_id = this.getReplaceId(stanza)
+                const msgid = replaced_id || stanza.getAttribute('id') || original_stanza.getAttribute('id');
+                const attrs = Object.assign({
+                    'chat_state': chat_state,
+                    'is_archived': this.isArchived(original_stanza),
+                    'is_delayed': !!delay,
+                    'is_single_emoji': text ? await u.isSingleEmoji(text) : false,
+                    'is_spoiler': !!spoiler,
+                    'message': text,
+                    'msgid': msgid,
+                    'replaced_id': replaced_id,
+                    'references': this.getReferencesFromStanza(stanza),
+                    'subject': propertyOf(stanza.querySelector('subject'))('textContent'),
+                    'thread': propertyOf(stanza.querySelector('thread'))('textContent'),
+                    'time': delay ? dayjs(delay.getAttribute('stamp')).toISOString() : (new Date()).toISOString(),
+                    'type': stanza.getAttribute('type')
+                }, this.getStanzaIDs(original_stanza));
+
+                if (attrs.type === 'groupchat') {
+                    attrs.from = stanza.getAttribute('from');
+                    attrs.nick = Strophe.unescapeNode(Strophe.getResourceFromJid(attrs.from));
+                    attrs.sender = attrs.nick === this.get('nick') ? 'me': 'them';
+                    attrs.received = (new Date()).toISOString();
+                } else {
+                    attrs.from = Strophe.getBareJidFromJid(stanza.getAttribute('from'));
+                    if (attrs.from === _converse.bare_jid) {
+                        attrs.sender = 'me';
+                        attrs.fullname = _converse.xmppstatus.get('fullname');
+                    } else {
+                        attrs.sender = 'them';
+                        attrs.fullname = this.get('fullname');
+                    }
+                }
+                sizzle(`x[xmlns="${Strophe.NS.OUTOFBAND}"]`, stanza).forEach(xform => {
+                    attrs['oob_url'] = xform.querySelector('url').textContent;
+                    attrs['oob_desc'] = xform.querySelector('url').textContent;
+                });
+                if (spoiler) {
+                    attrs.spoiler_hint = spoiler.textContent.length > 0 ? spoiler.textContent : '';
+                }
+                if (replaced_id) {
+                    attrs['edited'] = (new Date()).toISOString();
+                }
+                // We prefer to use one of the XEP-0359 unique and stable stanza IDs as the Model id, to avoid duplicates.
+                attrs['id'] = attrs['origin_id'] ||
+                    attrs[`stanza_id ${attrs.from}`] ||
+                    u.getUniqueId();
+                return attrs;
+            },
+
+            maybeShow () {
+                // Returns the chatbox
+                return this.trigger("show");
+            },
+
+            /**
+             * Indicates whether the chat is hidden and therefore
+             * whether a newly received message will be visible
+             * to the user or not.
+             * @returns {boolean}
+             */
+            isHidden () {
+                return this.get('hidden') ||
+                    this.get('minimized') ||
+                    this.isScrolledUp() ||
+                    _converse.windowState === 'hidden';
+            },
+
+            /**
+             * Given a newly received {@link _converse.Message} instance,
+             * update the unread counter if necessary.
+             * @private
+             * @param {_converse.Message} message
+             */
+            incrementUnreadMsgCounter (message) {
+                if (!message || !message.get('message')) {
+                    return;
+                }
+                if (utils.isNewMessage(message) && this.isHidden()) {
+                    this.save({'num_unread': this.get('num_unread') + 1});
+                    _converse.incrementMsgCounter();
+                }
+            },
+
+            clearUnreadMsgCounter () {
+                u.safeSave(this, {'num_unread': 0});
+            },
+
+            isScrolledUp () {
+                return this.get('scrolled', true);
+            }
+        });
+
+
+        function rejectMessage (stanza, text) {
+            // Reject an incoming message by replying with an error message of type "cancel".
+            _converse.api.send(
+                $msg({
+                    'to': stanza.getAttribute('from'),
+                    'type': 'error',
+                    'id': stanza.getAttribute('id')
+                }).c('error', {'type': 'cancel'})
+                    .c('not-allowed', {xmlns:"urn:ietf:params:xml:ns:xmpp-stanzas"}).up()
+                    .c('text', {xmlns:"urn:ietf:params:xml:ns:xmpp-stanzas"}).t(text)
+            );
+            _converse.log(`Rejecting message stanza with the following reason: ${text}`, Strophe.LogLevel.WARN);
+            _converse.log(stanza, Strophe.LogLevel.WARN);
+        }
+
+
+        async function handleErrorMessage (stanza) {
+            const from_jid =  Strophe.getBareJidFromJid(stanza.getAttribute('from'));
+            if (utils.isSameBareJID(from_jid, _converse.bare_jid)) {
+                return;
+            }
+            const chatbox = await _converse.api.chatboxes.get(from_jid);
+            if (!chatbox) {
+                return;
+            }
+            const should_show = await chatbox.shouldShowErrorMessage(stanza);
+            if (!should_show) {
+                return;
+            }
+            const attrs = await chatbox.getMessageAttributesFromStanza(stanza, stanza);
+            await chatbox.messages.create(attrs);
+        }
+
+
+        /**
+         * Handler method for all incoming single-user chat "message" stanzas.
+         * @private
+         * @method _converse#handleMessageStanza
+         * @param { XMLElement } stanza - The incoming message stanza
+         */
+        _converse.handleMessageStanza = async function (stanza) {
+            const original_stanza = stanza;
+            let to_jid = stanza.getAttribute('to');
+            const to_resource = Strophe.getResourceFromJid(to_jid);
+
+            if (_converse.filter_by_resource && (to_resource && to_resource !== _converse.resource)) {
+                return _converse.log(
+                    `onMessage: Ignoring incoming message intended for a different resource: ${to_jid}`,
+                    Strophe.LogLevel.INFO
+                );
+            } else if (utils.isHeadlineMessage(_converse, stanza)) {
+                // XXX: Prosody sends headline messages with the
+                // wrong type ('chat'), so we need to filter them out here.
+                return _converse.log(
+                    `onMessage: Ignoring incoming headline message from JID: ${stanza.getAttribute('from')}`,
+                    Strophe.LogLevel.INFO
+                );
+            }
+
+            const bare_forward = sizzle(`message > forwarded[xmlns="${Strophe.NS.FORWARD}"]`, stanza).length;
+            if (bare_forward) {
+                return rejectMessage(
+                    stanza,
+                    'Forwarded messages not part of an encapsulating protocol are not supported'
+                );
+            }
+            let from_jid = stanza.getAttribute('from') || _converse.bare_jid;
+            if (u.isCarbonMessage(stanza)) {
+                if (from_jid === _converse.bare_jid) {
+                    const selector = `[xmlns="${Strophe.NS.CARBONS}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`;
+                    stanza = sizzle(selector, stanza).pop();
+                    to_jid = stanza.getAttribute('to');
+                    from_jid = stanza.getAttribute('from');
+                } else {
+                    // Prevent message forging via carbons: https://xmpp.org/extensions/xep-0280.html#security
+                    return rejectMessage(stanza, 'Rejecting carbon from invalid JID');
+                }
+            }
+
+            if (u.isMAMMessage(stanza)) {
+                if (from_jid === _converse.bare_jid) {
+                    const selector = `[xmlns="${Strophe.NS.MAM}"] > forwarded[xmlns="${Strophe.NS.FORWARD}"] > message`;
+                    stanza = sizzle(selector, stanza).pop();
+                    to_jid = stanza.getAttribute('to');
+                    from_jid = stanza.getAttribute('from');
+                } else {
+                    return _converse.log(
+                        `onMessage: Ignoring alleged MAM message from ${stanza.getAttribute('from')}`,
+                        Strophe.LogLevel.WARN
+                    );
+                }
+            }
+
+            const from_bare_jid = Strophe.getBareJidFromJid(from_jid);
+            const is_me = from_bare_jid === _converse.bare_jid;
+            if (is_me && to_jid === null) {
+                return _converse.log(
+                    `Don't know how to handle message stanza without 'to' attribute. ${stanza.outerHTML}`,
+                    Strophe.LogLevel.ERROR
+                );
+            }
+            const contact_jid = is_me ? Strophe.getBareJidFromJid(to_jid) : from_bare_jid;
+            const contact = await _converse.api.contacts.get(contact_jid);
+            if (contact === undefined && !_converse.allow_non_roster_messaging) {
+                _converse.log(
+                    `Blocking messaging with a JID not in our roster because allow_non_roster_messaging is false.`,
+                    Strophe.LogLevel.ERROR
+                );
+                return _converse.log(stanza, Strophe.LogLevel.ERROR);
+            }
+            // Get chat box, but only create when the message has something to show to the user
+            const has_body = sizzle(`body, encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).length > 0;
+            const roster_nick = get(contact, 'attributes.nickname');
+            const chatbox = await _converse.api.chats.get(contact_jid, {'nickname': roster_nick}, has_body);
+            chatbox && await chatbox.onMessage(stanza, original_stanza, from_jid);
+            /**
+             * Triggered when a message stanza is been received and processed.
+             * @event _converse#message
+             * @type { object }
+             * @property { _converse.ChatBox | _converse.ChatRoom } chatbox
+             * @property { XMLElement } stanza
+             * @example _converse.api.listen.on('message', obj => { ... });
+             */
+            _converse.api.trigger('message', {'stanza': original_stanza, 'chatbox': chatbox});
+        }
+
+
+        function registerMessageHandlers () {
+           _converse.connection.addHandler(stanza => {
+               if (sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, stanza).pop()) {
+                   // MAM messages are handled in converse-mam.
+                   // We shouldn't get MAM messages here because
+                   // they shouldn't have a `type` attribute.
+                   _converse.log(`Received a MAM message with type "chat".`, Strophe.LogLevel.WARN);
+                   return true;
+               }
+               _converse.handleMessageStanza(stanza);
+               return true;
+           }, null, 'message', 'chat');
+
+           _converse.connection.addHandler(stanza => {
+               // Message receipts are usually without the `type` attribute. See #1353
+               if (stanza.getAttribute('type') !== null) {
+                   // TODO: currently Strophe has no way to register a handler
+                   // for stanzas without a `type` attribute.
+                   // We could update it to accept null to mean no attribute,
+                   // but that would be a backward-incompatible change
+                   return true; // Gets handled above.
+               }
+               _converse.handleMessageStanza(stanza);
+               return true;
+           }, Strophe.NS.RECEIPTS, 'message');
+
+           _converse.connection.addHandler(stanza => {
+               handleErrorMessage(stanza);
+               return true;
+           }, null, 'message', 'error');
+        }
+
+
+        function autoJoinChats () {
+            // Automatically join private chats, based on the
+            // "auto_join_private_chats" configuration setting.
+            _converse.auto_join_private_chats.forEach(jid => {
+                if (_converse.chatboxes.where({'jid': jid}).length) {
+                    return;
+                }
+                if (isString(jid)) {
+                    _converse.api.chats.open(jid);
+                } else {
+                    _converse.log(
+                        'Invalid jid criteria specified for "auto_join_private_chats"',
+                        Strophe.LogLevel.ERROR);
+                }
+            });
+            /**
+             * Triggered once any private chats have been automatically joined as
+             * specified by the `auto_join_private_chats` setting.
+             * See: https://conversejs.org/docs/html/configuration.html#auto-join-private-chats
+             * @event _converse#privateChatsAutoJoined
+             * @example _converse.api.listen.on('privateChatsAutoJoined', () => { ... });
+             * @example _converse.api.waitUntil('privateChatsAutoJoined').then(() => { ... });
+             */
+            _converse.api.trigger('privateChatsAutoJoined');
+        }
+
+
+        /************************ BEGIN Route Handlers ************************/
+        function openChat (jid) {
+            if (!utils.isValidJID(jid)) {
+                return _converse.log(
+                    `Invalid JID "${jid}" provided in URL fragment`,
+                    Strophe.LogLevel.WARN
+                );
+            }
+            _converse.api.chats.open(jid);
+        }
+        _converse.router.route('converse/chat?jid=:jid', openChat);
+        /************************ END Route Handlers ************************/
+
+
+        /************************ BEGIN Event Handlers ************************/
+        _converse.api.listen.on('chatBoxesFetched', autoJoinChats);
+        _converse.api.listen.on('presencesInitialized', (reconnecting) => (!reconnecting && registerMessageHandlers()));
+
+        _converse.api.listen.on('clearSession', () => {
+            if (_converse.shouldClearCache()) {
+                _converse.chatboxes.filter(c => c.messages && c.messages.clearSession({'silent': true}));
+            }
+        });
+        /************************ END Event Handlers ************************/
+
+
+        /************************ BEGIN API ************************/
+        Object.assign(_converse.api, {
+            /**
+             * The "chats" namespace (used for one-on-one chats)
+             *
+             * @namespace _converse.api.chats
+             * @memberOf _converse.api
+             */
+            chats: {
+                /**
+                 * @method _converse.api.chats.create
+                 * @param {string|string[]} jid|jids An jid or array of jids
+                 * @param {object} [attrs] An object containing configuration attributes.
+                 */
+                async create (jids, attrs) {
+                    if (isString(jids)) {
+                        if (attrs && !get(attrs, 'fullname')) {
+                            const contact = await _converse.api.contacts.get(jids);
+                            attrs.fullname = get(contact, 'attributes.fullname');
+                        }
+                        const chatbox = _converse.api.chats.get(jids, attrs, true);
+                        if (!chatbox) {
+                            _converse.log("Could not open chatbox for JID: "+jids, Strophe.LogLevel.ERROR);
+                            return;
+                        }
+                        return chatbox;
+                    }
+                    if (Array.isArray(jids)) {
+                        return Promise.all(jids.forEach(async jid => {
+                            const contact = await _converse.api.contacts.get(jids);
+                            attrs.fullname = get(contact, 'attributes.fullname');
+                            return _converse.api.chats.get(jid, attrs, true).maybeShow();
+                        }));
+                    }
+                    _converse.log(
+                        "chats.create: You need to provide at least one JID",
+                        Strophe.LogLevel.ERROR
+                    );
+                    return null;
+                },
+
+                /**
+                 * Opens a new one-on-one chat.
+                 *
+                 * @method _converse.api.chats.open
+                 * @param {String|string[]} name - e.g. 'buddy@example.com' or ['buddy1@example.com', 'buddy2@example.com']
+                 * @param {Object} [attrs] - Attributes to be set on the _converse.ChatBox model.
+                 * @param {Boolean} [attrs.minimized] - Should the chat be created in minimized state.
+                 * @param {Boolean} [force=false] - By default, a minimized
+                 *   chat won't be maximized (in `overlayed` view mode) and in
+                 *   `fullscreen` view mode a newly opened chat won't replace
+                 *   another chat already in the foreground.
+                 *   Set `force` to `true` if you want to force the chat to be
+                 *   maximized or shown.
+                 * @returns {Promise} Promise which resolves with the
+                 *   _converse.ChatBox representing the chat.
+                 *
+                 * @example
+                 * // To open a single chat, provide the JID of the contact you're chatting with in that chat:
+                 * converse.plugins.add('myplugin', {
+                 *     initialize: function() {
+                 *         const _converse = this._converse;
+                 *         // Note, buddy@example.org must be in your contacts roster!
+                 *         _converse.api.chats.open('buddy@example.com').then(chat => {
+                 *             // Now you can do something with the chat model
+                 *         });
+                 *     }
+                 * });
+                 *
+                 * @example
+                 * // To open an array of chats, provide an array of JIDs:
+                 * converse.plugins.add('myplugin', {
+                 *     initialize: function () {
+                 *         const _converse = this._converse;
+                 *         // Note, these users must first be in your contacts roster!
+                 *         _converse.api.chats.open(['buddy1@example.com', 'buddy2@example.com']).then(chats => {
+                 *             // Now you can do something with the chat models
+                 *         });
+                 *     }
+                 * });
+                 */
+                async open (jids, attrs, force) {
+                    if (isString(jids)) {
+                        const chat = await _converse.api.chats.get(jids, attrs, true);
+                        if (chat) {
+                            return chat.maybeShow(force);
+                        }
+                        return chat;
+                    } else if (Array.isArray(jids)) {
+                        return Promise.all(
+                            jids.map(j => _converse.api.chats.get(j, attrs, true).then(c => c && c.maybeShow(force)))
+                                .filter(c => c)
+                        );
+                    }
+                    const err_msg = "chats.open: You need to provide at least one JID";
+                    _converse.log(err_msg, Strophe.LogLevel.ERROR);
+                    throw new Error(err_msg);
+                },
+
+                /**
+                 * Retrieves a chat or all chats.
+                 *
+                 * @method _converse.api.chats.get
+                 * @param {String|string[]} jids - e.g. 'buddy@example.com' or ['buddy1@example.com', 'buddy2@example.com']
+                 * @param {Object} [attrs] - Attributes to be set on the _converse.ChatBox model.
+                 * @param {Boolean} [create=false] - Whether the chat should be created if it's not found.
+                 * @returns { Promise<_converse.ChatBox> }
+                 *
+                 * @example
+                 * // To return a single chat, provide the JID of the contact you're chatting with in that chat:
+                 * const model = await _converse.api.chats.get('buddy@example.com');
+                 *
+                 * @example
+                 * // To return an array of chats, provide an array of JIDs:
+                 * const models = await _converse.api.chats.get(['buddy1@example.com', 'buddy2@example.com']);
+                 *
+                 * @example
+                 * // To return all open chats, call the method without any parameters::
+                 * const models = await _converse.api.chats.get();
+                 *
+                 */
+                async get (jids, attrs={}, create=false) {
+                    async function _get (jid) {
+                        let model = await _converse.api.chatboxes.get(jid);
+                        if (!model && create) {
+                            model = await _converse.api.chatboxes.create(jid, attrs, _converse.ChatBox);
+                        } else {
+                            model = (model && model.get('type') === _converse.PRIVATE_CHAT_TYPE) ? model : null;
+                            if (model && Object.keys(attrs).length) {
+                                model.save(attrs);
+                            }
+                        }
+                        return model;
+                    }
+                    if (jids === undefined) {
+                        const chats = await _converse.api.chatboxes.get();
+                        return chats.filter(c => (c.get('type') === _converse.PRIVATE_CHAT_TYPE));
+                    } else if (isString(jids)) {
+                        return _get(jids);
+                    }
+                    return Promise.all(jids.map(jid => _get(jid)));
+                }
+            }
+        });
+        /************************ END API ************************/
+
+    }
+});

File diff suppressed because it is too large
+ 3 - 1044
src/headless/converse-chatboxes.js


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

@@ -78,10 +78,12 @@ const CORE_PLUGINS = [
     'converse-bosh',
     'converse-caps',
     'converse-chatboxes',
+    'converse-chat',
     'converse-disco',
     'converse-emoji',
     'converse-mam',
     'converse-muc',
+    'converse-headlines',
     'converse-ping',
     'converse-pubsub',
     'converse-roster',

+ 51 - 56
src/converse-headline.js → src/headless/converse-headlines.js

@@ -8,12 +8,12 @@
  */
 import "converse-chatview";
 import converse from "@converse/headless/converse-core";
-import tpl_chatbox from "templates/chatbox.html";
+import { isString } from "lodash";
 
 const { utils } = converse.env;
 
 
-converse.plugins.add('converse-headline', {
+converse.plugins.add('converse-headlines', {
     /* Plugin dependencies are other plugins which might be
      * overridden or relied upon, and therefore need to be loaded before
      * this plugin.
@@ -24,7 +24,7 @@ converse.plugins.add('converse-headline', {
      *
      * NB: These plugins need to have already been loaded via require.js.
      */
-    dependencies: ["converse-chatview"],
+    dependencies: ["converse-chat"],
 
     overrides: {
         // Overrides mentioned here will be picked up by converse.js's
@@ -71,52 +71,8 @@ converse.plugins.add('converse-headline', {
         });
 
 
-        _converse.HeadlinesBoxView = _converse.ChatBoxView.extend({
-            className: 'chatbox headlines',
-
-            events: {
-                'click .close-chatbox-button': 'close',
-                'click .toggle-chatbox-button': 'minimize',
-                'keypress textarea.chat-textarea': 'onKeyDown'
-            },
-
-            initialize () {
-                this.initDebounced();
-
-                this.model.disable_mam = true; // Don't do MAM queries for this box
-                this.listenTo(this.model.messages, 'add', this.onMessageAdded);
-                this.listenTo(this.model, 'show', this.show);
-                this.listenTo(this.model, 'destroy', this.hide);
-                this.listenTo(this.model, 'change:minimized', this.onMinimizedChanged);
-
-                this.render().insertHeading()
-                this.updateAfterMessagesFetched();
-                this.insertIntoDOM().hide();
-                _converse.api.trigger('chatBoxInitialized', this);
-            },
-
-            render () {
-                this.el.setAttribute('id', this.model.get('box_id'))
-                this.el.innerHTML = tpl_chatbox(
-                    Object.assign(this.model.toJSON(), {
-                            info_close: '',
-                            label_personal_message: '',
-                            show_send_button: false,
-                            show_toolbar: false,
-                            unread_msgs: ''
-                        }
-                    ));
-                this.content = this.el.querySelector('.chat-content');
-                return this;
-            },
-
-            // Override to avoid the methods in converse-chatview.js
-            'renderMessageForm': function renderMessageForm () {},
-            'afterShown': function afterShown () {}
-        });
-
         async function onHeadlineMessage (message) {
-            /* Handler method for all incoming messages of type "headline". */
+            // Handler method for all incoming messages of type "headline".
             if (utils.isHeadlineMessage(_converse, message)) {
                 const from_jid = message.getAttribute('from');
                 if (from_jid.includes('@') &&
@@ -140,6 +96,8 @@ converse.plugins.add('converse-headline', {
             }
         }
 
+
+        /************************ BEGIN Event Handlers ************************/
         function registerHeadlineHandler () {
             _converse.connection.addHandler(message => {
                 onHeadlineMessage(message);
@@ -148,15 +106,52 @@ converse.plugins.add('converse-headline', {
         }
         _converse.api.listen.on('connected', registerHeadlineHandler);
         _converse.api.listen.on('reconnected', registerHeadlineHandler);
-
-
-        _converse.api.listen.on('chatBoxViewsInitialized', () => {
-            const views = _converse.chatboxviews;
-            _converse.chatboxes.on('add', item => {
-                if (!views.get(item.get('id')) && item.get('type') === _converse.HEADLINES_TYPE) {
-                    views.add(item.get('id'), new _converse.HeadlinesBoxView({model: item}));
+        /************************ END Event Handlers ************************/
+
+
+        /************************ BEGIN API ************************/
+        Object.assign(_converse.api, {
+            /**
+             * The "headlines" namespace, which is used for headline-channels
+             * which are read-only channels containing messages of type
+             * "headline".
+             *
+             * @namespace _converse.api.headlines
+             * @memberOf _converse.api
+             */
+            headlines: {
+                /**
+                 * Retrieves a headline-channel or all headline-channels.
+                 *
+                 * @method _converse.api.headlines.get
+                 * @param {String|String[]} jids - e.g. 'buddy@example.com' or ['buddy1@example.com', 'buddy2@example.com']
+                 * @param {Object} [attrs] - Attributes to be set on the _converse.ChatBox model.
+                 * @param {Boolean} [create=false] - Whether the chat should be created if it's not found.
+                 * @returns { Promise<_converse.HeadlinesBox> }
+                 */
+                async get (jids, attrs={}, create=false) {
+                    async function _get (jid) {
+                        let model = await _converse.api.chatboxes.get(jid);
+                        if (!model && create) {
+                            model = await _converse.api.chatboxes.create(jid, attrs, _converse.HeadlinesBox);
+                        } else {
+                            model = (model && model.get('type') === _converse.HEADLINES_TYPE) ? model : null;
+                            if (model && Object.keys(attrs).length) {
+                                model.save(attrs);
+                            }
+                        }
+                        return model;
+                    }
+                    if (jids === undefined) {
+                        const chats = await _converse.api.chatboxes.get();
+                        return chats.filter(c => (c.get('type') === _converse.HEADLINES_TYPE));
+                    } else if (isString(jids)) {
+                        return _get(jids);
+                    }
+                    return Promise.all(jids.map(jid => _get(jid)));
                 }
-            });
+            }
         });
+        /************************ END API ************************/
     }
 });

+ 1 - 1
src/headless/converse-mam.js

@@ -117,7 +117,7 @@ converse.plugins.add('converse-mam', {
                 }
                 const message_handler = is_groupchat ?
                     this.onMessage.bind(this) :
-                    _converse.chatboxes.onMessage.bind(_converse.chatboxes);
+                    _converse.handleMessageStanza.bind(_converse.chatboxes);
 
                 const query = Object.assign({
                         'groupchat': is_groupchat,

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

@@ -12,6 +12,7 @@
 import "./converse-disco";
 import "./converse-emoji";
 import "./utils/muc";
+import { clone, get, intersection, invoke, isElement, isObject, isString, uniq, zipObject } from "lodash";
 import converse from "./converse-core";
 import u from "./utils/form";
 
@@ -22,7 +23,7 @@ const MUC_ROLE_WEIGHTS = {
     'none':         2,
 };
 
-const { Strophe, Backbone, $iq, $build, $msg, $pres, sizzle, _ } = converse.env;
+const { Strophe, Backbone, $iq, $build, $msg, $pres, sizzle } = converse.env;
 
 // Add Strophe Namespaces
 Strophe.addNamespace('MUC_ADMIN', Strophe.NS.MUC + "#admin");
@@ -81,13 +82,13 @@ converse.plugins.add('converse-muc', {
      *
      * NB: These plugins need to have already been loaded via require.js.
      */
-    dependencies: ["converse-chatboxes", "converse-disco", "converse-controlbox"],
+    dependencies: ["converse-chatboxes", "converse-chat", "converse-disco", "converse-controlbox"],
 
     overrides: {
         tearDown () {
             const { _converse } = this.__super__;
             const groupchats = this.chatboxes.where({'type': _converse.CHATROOMS_TYPE});
-            _.each(groupchats, gc => u.safeSave(gc, {'connection_status': converse.ROOMSTATUS.DISCONNECTED}));
+            groupchats.forEach(gc => u.safeSave(gc, {'connection_status': converse.ROOMSTATUS.DISCONNECTED}));
             this.__super__.tearDown.call(this, arguments);
         },
 
@@ -129,7 +130,7 @@ converse.plugins.add('converse-muc', {
         });
         _converse.api.promises.add(['roomsAutoJoined']);
 
-        if (_converse.locked_muc_domain && !_.isString(_converse.muc_domain)) {
+        if (_converse.locked_muc_domain && !isString(_converse.muc_domain)) {
             throw new Error("Config Error: it makes no sense to set locked_muc_domain "+
                             "to true when muc_domain is not set");
         }
@@ -234,7 +235,7 @@ converse.plugins.add('converse-muc', {
              */
             settings.type = _converse.CHATROOMS_TYPE;
             settings.id = jid;
-            const chatbox = await _converse.chatboxes.getChatBox(jid, settings, true);
+            const chatbox = await _converse.api.rooms.get(jid, settings, true);
             chatbox.maybeShow(true);
             return chatbox;
         }
@@ -263,7 +264,7 @@ converse.plugins.add('converse-muc', {
             onOccupantRemoved () {
                 this.stopListening(this.occupant);
                 delete this.occupant;
-                const chatbox = _.get(this, 'collection.chatbox');
+                const chatbox = get(this, 'collection.chatbox');
                 if (!chatbox) {
                     return _converse.log(
                         `Could not get collection.chatbox for message: ${JSON.stringify(this.toJSON())}`,
@@ -277,7 +278,7 @@ converse.plugins.add('converse-muc', {
                 if (occupant.get('nick') === Strophe.getResourceFromJid(this.get('from'))) {
                     this.occupant = occupant;
                     this.listenTo(this.occupant, 'destroy', this.onOccupantRemoved);
-                    const chatbox = _.get(this, 'collection.chatbox');
+                    const chatbox = get(this, 'collection.chatbox');
                     if (!chatbox) {
                         return _converse.log(
                             `Could not get collection.chatbox for message: ${JSON.stringify(this.toJSON())}`,
@@ -290,7 +291,7 @@ converse.plugins.add('converse-muc', {
 
             setOccupant () {
                 if (this.get('type') !== 'groupchat') { return; }
-                const chatbox = _.get(this, 'collection.chatbox');
+                const chatbox = get(this, 'collection.chatbox');
                 if (!chatbox) {
                     return _converse.log(
                         `Could not get collection.chatbox for message: ${JSON.stringify(this.toJSON())}`,
@@ -308,7 +309,7 @@ converse.plugins.add('converse-muc', {
             },
 
             getVCardForChatroomOccupant () {
-                const chatbox = _.get(this, 'collection.chatbox');
+                const chatbox = get(this, 'collection.chatbox');
                 const nick = Strophe.getResourceFromJid(this.get('from'));
 
                 if (chatbox && chatbox.get('nick') === nick) {
@@ -488,7 +489,7 @@ converse.plugins.add('converse-muc', {
             initFeatures () {
                 const id = `converse.muc-features-${_converse.bare_jid}-${this.get('jid')}`;
                 this.features = new Backbone.Model(
-                    _.assign({id}, _.zipObject(converse.ROOM_FEATURES, converse.ROOM_FEATURES.map(_.stubFalse)))
+                    Object.assign({id}, zipObject(converse.ROOM_FEATURES, converse.ROOM_FEATURES.map(() => false)))
                 );
                 this.features.browserStorage = _converse.createStore(id, "session");
             },
@@ -855,13 +856,13 @@ converse.plugins.add('converse-muc', {
                 const fields = await _converse.api.disco.getFields(this.get('jid'));
                 this.save({
                         'name': identity && identity.get('name'),
-                        'description': _.get(fields.findWhere({'var': "muc#roominfo_description"}), 'attributes.value')
+                        'description': get(fields.findWhere({'var': "muc#roominfo_description"}), 'attributes.value')
                     }
                 );
 
                 const features = await _converse.api.disco.getFeatures(this.get('jid'));
                 const attrs = Object.assign(
-                    _.zipObject(converse.ROOM_FEATURES, converse.ROOM_FEATURES.map(_.stubFalse)),
+                    zipObject(converse.ROOM_FEATURES, converse.ROOM_FEATURES.map(() => false)),
                     {'fetched': (new Date()).toISOString()}
                 );
                 features.each(feature => {
@@ -874,7 +875,7 @@ converse.plugins.add('converse-muc', {
                     }
                     attrs[fieldname.replace('muc_', '')] = true;
                 });
-                attrs.description = _.get(fields.findWhere({'var': "muc#roominfo_description"}), 'attributes.value');
+                attrs.description = get(fields.findWhere({'var': "muc#roominfo_description"}), 'attributes.value');
                 this.features.save(attrs);
             },
 
@@ -1006,7 +1007,7 @@ converse.plugins.add('converse-muc', {
              * @returns { ('none'|'visitor'|'participant'|'moderator') }
              */
             getOwnRole () {
-                return _.get(this.getOwnOccupant(), 'attributes.role');
+                return get(this.getOwnOccupant(), 'attributes.role');
             },
 
             /**
@@ -1016,7 +1017,7 @@ converse.plugins.add('converse-muc', {
              * @returns { ('none'|'outcast'|'member'|'admin'|'owner') }
              */
             getOwnAffiliation () {
-                return _.get(this.getOwnOccupant(), 'attributes.affiliation');
+                return get(this.getOwnOccupant(), 'attributes.affiliation');
             },
 
             /**
@@ -1066,7 +1067,7 @@ converse.plugins.add('converse-muc', {
              * @returns { Promise }
              */
             setAffiliations (members) {
-                const affiliations = _.uniq(members.map(m => m.affiliation));
+                const affiliations = uniq(members.map(m => m.affiliation));
                 return Promise.all(affiliations.map(a => this.setAffiliation(a, members)));
             },
 
@@ -1280,8 +1281,8 @@ converse.plugins.add('converse-muc', {
                 }
                 const jid = data.jid || '';
                 const attributes = Object.assign(data, {
-                    'jid': Strophe.getBareJidFromJid(jid) || _.get(occupant, 'attributes.jid'),
-                    'resource': Strophe.getResourceFromJid(jid) || _.get(occupant, 'attributes.resource')
+                    'jid': Strophe.getBareJidFromJid(jid) || get(occupant, 'attributes.jid'),
+                    'resource': Strophe.getResourceFromJid(jid) || get(occupant, 'attributes.resource')
                 });
                 if (occupant) {
                     occupant.save(attributes);
@@ -1326,7 +1327,7 @@ converse.plugins.add('converse-muc', {
                                     }
                                 });
                             } else if (child.getAttribute("xmlns") === Strophe.NS.VCARDUPDATE) {
-                                data.image_hash = _.get(child.querySelector('photo'), 'textContent');
+                                data.image_hash = get(child.querySelector('photo'), 'textContent');
                             }
                     }
                 });
@@ -1415,7 +1416,7 @@ converse.plugins.add('converse-muc', {
              */
             isOwnMessage (msg) {
                 let from;
-                if (_.isElement(msg)) {
+                if (isElement(msg)) {
                     from = msg.getAttribute('from');
                 } else if (msg instanceof _converse.Message) {
                     from = msg.get('from');
@@ -1460,7 +1461,7 @@ converse.plugins.add('converse-muc', {
                     await _converse.api.sendIQ(ping);
                 } catch (e) {
                     const sel = `error not-acceptable[xmlns="${Strophe.NS.STANZAS}"]`;
-                    if (_.isElement(e) && sizzle(sel, e).length) {
+                    if (isElement(e) && sizzle(sel, e).length) {
                         return false;
                     }
                 }
@@ -1576,7 +1577,7 @@ converse.plugins.add('converse-muc', {
 
 
             handleModifyError(pres) {
-                const text = _.get(pres.querySelector('error text'), 'textContent');
+                const text = get(pres.querySelector('error text'), 'textContent');
                 if (text) {
                     if (this.get('connection_status') === converse.ROOMSTATUS.CONNECTING) {
                         this.setDisconnectionMessage(text);
@@ -1598,7 +1599,7 @@ converse.plugins.add('converse-muc', {
                     return;
                 }
                 const codes = sizzle('status', x).map(s => s.getAttribute('code'));
-                const disconnection_codes = _.intersection(codes, Object.keys(_converse.muc.disconnect_messages));
+                const disconnection_codes = intersection(codes, Object.keys(_converse.muc.disconnect_messages));
                 const disconnected = is_self && disconnection_codes.length > 0;
                 if (!disconnected) {
                     return;
@@ -1608,8 +1609,8 @@ converse.plugins.add('converse-muc', {
                 // element. This appears to be a safe assumption, since
                 // each <x/> element pertains to a single user.
                 const item = x.querySelector('item');
-                const reason = item ? _.get(item.querySelector('reason'), 'textContent') : undefined;
-                const actor = item ? _.invoke(item.querySelector('actor'), 'getAttribute', 'nick') : undefined;
+                const reason = item ? get(item.querySelector('reason'), 'textContent') : undefined;
+                const actor = item ? invoke(item.querySelector('actor'), 'getAttribute', 'nick') : undefined;
                 const message = _converse.muc.disconnect_messages[disconnection_codes[0]];
                 this.setDisconnectionMessage(message, reason, actor);
             },
@@ -1639,8 +1640,8 @@ converse.plugins.add('converse-muc', {
                         const nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
                         message = __(_converse.muc.action_info_messages[code], nick);
                         const item = x.querySelector('item');
-                        const reason = item ? _.get(item.querySelector('reason'), 'textContent') : undefined;
-                        const actor = item ? _.invoke(item.querySelector('actor'), 'getAttribute', 'nick') : undefined;
+                        const reason = item ? get(item.querySelector('reason'), 'textContent') : undefined;
+                        const actor = item ? invoke(item.querySelector('actor'), 'getAttribute', 'nick') : undefined;
                         if (actor) {
                             message += '\n' + __('This action was done by %1$s.', actor);
                         }
@@ -1705,7 +1706,7 @@ converse.plugins.add('converse-muc', {
             onErrorPresence (stanza) {
                 const error = stanza.querySelector('error');
                 const error_type = error.getAttribute('type');
-                const reason = _.get(sizzle(`text[xmlns="${Strophe.NS.STANZAS}"]`, error).pop(), 'textContent');
+                const reason = get(sizzle(`text[xmlns="${Strophe.NS.STANZAS}"]`, error).pop(), 'textContent');
 
                 if (error_type === 'modify') {
                     this.handleModifyError(stanza);
@@ -1731,7 +1732,7 @@ converse.plugins.add('converse-muc', {
                         const message = __("Your nickname doesn't conform to this groupchat's policies.");
                         this.setDisconnectionMessage(message, reason);
                     } else if (sizzle(`gone[xmlns="${Strophe.NS.STANZAS}"]`, error).length) {
-                        const moved_jid = _.get(sizzle(`gone[xmlns="${Strophe.NS.STANZAS}"]`, error).pop(), 'textContent')
+                        const moved_jid = get(sizzle(`gone[xmlns="${Strophe.NS.STANZAS}"]`, error).pop(), 'textContent')
                             .replace(/^xmpp:/, '')
                             .replace(/\?join$/, '');
                         this.save({
@@ -2073,18 +2074,11 @@ converse.plugins.add('converse-muc', {
             _converse.api.listen.on('reconnected', registerDirectInvitationHandler);
         }
 
-        const getChatRoom = function (jid, attrs, create) {
-            jid = jid.toLowerCase();
-            attrs.type = _converse.CHATROOMS_TYPE;
-            attrs.id = jid;
-            return _converse.chatboxes.getChatBox(jid, attrs, create);
-        };
-
         const createChatRoom = function (jid, attrs) {
             if (jid.startsWith('xmpp:') && jid.endsWith('?join')) {
                 jid = jid.replace(/^xmpp:/, '').replace(/\?join$/, '');
             }
-            return getChatRoom(jid, attrs, true);
+            return _converse.api.rooms.get(jid, attrs, true);
         };
 
         /**
@@ -2095,13 +2089,13 @@ converse.plugins.add('converse-muc', {
          */
         function autoJoinRooms () {
             _converse.auto_join_rooms.forEach(groupchat => {
-                if (_.isString(groupchat)) {
+                if (isString(groupchat)) {
                     if (_converse.chatboxes.where({'jid': groupchat}).length) {
                         return;
                     }
                     _converse.api.rooms.open(groupchat);
-                } else if (_.isObject(groupchat)) {
-                    _converse.api.rooms.open(groupchat.jid, _.clone(groupchat));
+                } else if (isObject(groupchat)) {
+                    _converse.api.rooms.open(groupchat.jid, clone(groupchat));
                 } else {
                     _converse.log(
                         'Invalid groupchat criteria specified for "auto_join_rooms"',
@@ -2187,13 +2181,13 @@ converse.plugins.add('converse-muc', {
                  * @returns {Promise} Promise which resolves with the Backbone.Model representing the chat.
                  */
                 create (jids, attrs={}) {
-                    attrs = _.isString(attrs) ? {'nick': attrs} : (attrs || {});
+                    attrs = isString(attrs) ? {'nick': attrs} : (attrs || {});
                     if (!attrs.nick && _converse.muc_nickname_from_jid) {
                         attrs.nick = Strophe.getNodeFromJid(_converse.bare_jid);
                     }
                     if (jids === undefined) {
                         throw new TypeError('rooms.create: You need to provide at least one JID');
-                    } else if (_.isString(jids)) {
+                    } else if (isString(jids)) {
                         return createChatRoom(jids, attrs);
                     }
                     return jids.map(jid => createChatRoom(jid, attrs));
@@ -2264,7 +2258,7 @@ converse.plugins.add('converse-muc', {
                         const err_msg = 'rooms.open: You need to provide at least one JID';
                         _converse.log(err_msg, Strophe.LogLevel.ERROR);
                         throw(new TypeError(err_msg));
-                    } else if (_.isString(jids)) {
+                    } else if (isString(jids)) {
                         const room = await _converse.api.rooms.create(jids, attrs);
                         room && room.maybeShow(force);
                         return room;
@@ -2288,7 +2282,7 @@ converse.plugins.add('converse-muc', {
                  *     the user's JID will be used.
                  * @param {boolean} create A boolean indicating whether the room should be created
                  *     if not found (default: `false`)
-                 * @returns {Promise} Promise which resolves with the Backbone.Model representing the chat.
+                 * @returns { Promise<_converse.ChatRoom> }
                  * @example
                  * _converse.api.waitUntil('roomsAutoJoined').then(() => {
                  *     const create_if_not_found = true;
@@ -2299,28 +2293,26 @@ converse.plugins.add('converse-muc', {
                  *     )
                  * });
                  */
-                get (jids, attrs, create) {
-                    if (_.isString(attrs)) {
-                        attrs = {'nick': attrs};
-                    } else if (attrs === undefined) {
-                        attrs = {};
-                    }
-                    if (jids === undefined) {
-                        const result = [];
-                        _converse.chatboxes.each(function (chatbox) {
-                            if (chatbox.get('type') === _converse.CHATROOMS_TYPE) {
-                                result.push(chatbox);
+                async get (jids, attrs={}, create=false) {
+                    async function _get (jid) {
+                        let model = await _converse.api.chatboxes.get(jid);
+                        if (!model && create) {
+                            model = await _converse.api.chatboxes.create(jid, attrs, _converse.ChatRoom);
+                        } else {
+                            model = (model && model.get('type') === _converse.CHATROOMS_TYPE) ? model : null;
+                            if (model && Object.keys(attrs).length) {
+                                model.save(attrs);
                             }
-                        });
-                        return result;
-                    }
-                    if (!attrs.nick) {
-                        attrs.nick = Strophe.getNodeFromJid(_converse.bare_jid);
+                        }
+                        return model;
                     }
-                    if (_.isString(jids)) {
-                        return getChatRoom(jids, attrs, create);
+                    if (jids === undefined) {
+                        const chats = await _converse.api.chatboxes.get();
+                        return chats.filter(c => (c.get('type') === _converse.CHATROOMS_TYPE));
+                    } else if (isString(jids)) {
+                        return _get(jids);
                     }
-                    return jids.map(jid => getChatRoom(jid, attrs, create));
+                    return Promise.all(jids.map(jid => _get(jid)));
                 }
             }
         });

+ 3 - 1
src/headless/headless.js

@@ -6,15 +6,17 @@ import "./converse-bookmarks";   // XEP-0199 XMPP Ping
 import "./converse-bosh";        // XEP-0206 BOSH
 import "./converse-caps";        // XEP-0115 Entity Capabilities
 import "./converse-chatboxes";   // Backbone Collection and Models for chat boxes
+import "./converse-chat";        // Support for one-on-one chats
 import "./converse-disco";       // XEP-0030 Service discovery
 import "./converse-mam";         // XEP-0313 Message Archive Management
 import "./converse-muc";         // XEP-0045 Multi-user chat
+import "./converse-headlines";   // Support for headline messages
 import "./converse-ping";        // XEP-0199 XMPP Ping
 import "./converse-pubsub";      // XEP-0060 Pubsub
 import "./converse-roster";      // Contacts Roster
 import "./converse-rsm";         // XEP-0059 Result Set management
 import "./converse-smacks";      // XEP-0198 Stream Management
-import "./converse-status";   // XEP-0199 XMPP Ping
+import "./converse-status";      // XEP-0199 XMPP Ping
 import "./converse-vcard";       // XEP-0054 VCard-temp
 /* END: Removable components */
 

+ 2 - 2
tests/utils.js

@@ -52,7 +52,7 @@
     };
 
     utils.openControlBox = async function (_converse) {
-        const model = await _converse.api.chats.open('controlbox');
+        const model = await _converse.api.controlbox.open();
         await u.waitUntil(() => model.get('connected'));
         var toggle = document.querySelector(".toggle-controlbox");
         if (!u.isVisible(document.querySelector("#controlbox"))) {
@@ -121,7 +121,7 @@
 
     utils.openChatRoomViaModal = async function (_converse, jid, nick='') {
         // Opens a new chatroom
-        const model = await _converse.api.chats.open('controlbox');
+        const model = await _converse.api.controlbox.open('controlbox');
         await u.waitUntil(() => model.get('connected'));
         utils.openControlBox();
         const view = await _converse.chatboxviews.get('controlbox');

Some files were not shown because too many files changed in this diff