summaryrefslogtreecommitdiffstats
path: root/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests
diff options
context:
space:
mode:
Diffstat (limited to 'roles/reverseproxy/files/conversejs/src/plugins/chatview/tests')
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/chatbox.js1056
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/corrections.js354
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/emojis.js210
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/http-file-upload.js477
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/markers.js114
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/me-messages.js56
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-audio.js24
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-gifs.js23
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-images.js239
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-videos.js98
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/messages.js1331
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/oob.js168
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/receipts.js151
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/spoilers.js238
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/styling.js517
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/unreads.js156
-rw-r--r--roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/xss.js254
17 files changed, 5466 insertions, 0 deletions
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/chatbox.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/chatbox.js
new file mode 100644
index 0000000..c1ce92c
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/chatbox.js
@@ -0,0 +1,1056 @@
+/*global mock, converse */
+
+const $msg = converse.env.$msg;
+const Strophe = converse.env.Strophe;
+const u = converse.env.utils;
+const sizzle = converse.env.sizzle;
+const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
+
+describe("Chatboxes", function () {
+
+ beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
+ afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
+
+ describe("A Chatbox", function () {
+
+ it("has a /help command to show the available commands", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.openControlBox(_converse);
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ mock.sendMessage(view, '/help');
+ await u.waitUntil(() => sizzle('.chat-info:not(.chat-date)', view).length);
+ const info_messages = await u.waitUntil(() => sizzle('.chat-info:not(.chat-date)', view));
+ expect(info_messages.length).toBe(4);
+ expect(info_messages.pop().textContent).toBe('/help: Show this menu');
+ expect(info_messages.pop().textContent).toBe('/me: Write in the third person');
+ expect(info_messages.pop().textContent).toBe('/close: Close this chat');
+ expect(info_messages.pop().textContent).toBe('/clear: Remove messages');
+
+ const msg = $msg({
+ from: contact_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t('hello world').tree();
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+ const msg_txt_sel = 'converse-chat-message:last-child .chat-msg__body';
+ await u.waitUntil(() => view.querySelector(msg_txt_sel).textContent.trim() === 'hello world');
+ }));
+
+
+ it("has a /clear command", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
+
+ for (const i of Array(10).keys()) {
+ mock.sendMessage(view, `Message ${i}`);
+ }
+ await u.waitUntil(() => sizzle('converse-chat-message', view).length === 10);
+
+ const textarea = view.querySelector('textarea.chat-textarea');
+ textarea.value = '/clear';
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => _converse.api.confirm.calls.count() === 1);
+ await u.waitUntil(() => sizzle('converse-chat-message', view).length === 0);
+ expect(true).toBe(true);
+ }));
+
+
+ it("is created when you click on a roster item", mock.initConverse(
+ ['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ // openControlBox was called earlier, so the controlbox is
+ // visible, but no other chat boxes have been created.
+ expect(_converse.chatboxes.length).toEqual(1);
+ spyOn(_converse.minimize, 'trimChats');
+ expect(document.querySelectorAll("#conversejs .chatbox").length).toBe(1); // Controlbox is open
+
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group li').length, 700);
+ const online_contacts = rosterview.querySelectorAll('.roster-group .current-xmpp-contact a.open-chat');
+ expect(online_contacts.length).toBe(17);
+ let el = online_contacts[0];
+ el.click();
+ await u.waitUntil(() => document.querySelectorAll("#conversejs .chatbox").length == 2);
+ expect(_converse.minimize.trimChats).toHaveBeenCalled();
+ online_contacts[1].click();
+ await u.waitUntil(() => _converse.chatboxes.length == 3);
+ el = online_contacts[1];
+ expect(_converse.minimize.trimChats).toHaveBeenCalled();
+ // Check that new chat boxes are created to the left of the
+ // controlbox (but to the right of all existing chat boxes)
+ expect(document.querySelectorAll("#conversejs .chatbox").length).toBe(3);
+ }));
+
+ it("opens when a new message is received", mock.initConverse(
+ [], {'allow_non_roster_messaging': true},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const stanza = u.toStanza(`
+ <message from="${sender_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>Hey\nHave you heard the news?</body>
+ </message>`);
+
+ const message_promise = new Promise(resolve => _converse.api.listen.on('message', resolve));
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await new Promise(resolve => _converse.api.listen.once('chatBoxViewInitialized', resolve));
+ await u.waitUntil(() => message_promise);
+ expect(_converse.chatboxviews.keys().length).toBe(2);
+ expect(_converse.chatboxviews.keys().pop()).toBe(sender_jid);
+ }));
+
+ it("doesn't open when a message without body is received", mock.initConverse([], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const stanza = u.toStanza(`
+ <message from="${sender_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <composing xmlns="http://jabber.org/protocol/chatstates"/>
+ </message>`);
+ const message_promise = new Promise(resolve => _converse.api.listen.on('message', resolve))
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => message_promise);
+ expect(_converse.chatboxviews.keys().length).toBe(1);
+ }));
+
+ it("is focused if its already open and you click on its corresponding roster item",
+ mock.initConverse(['chatBoxesFetched'], {'auto_focus': true}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ expect(_converse.chatboxes.length).toEqual(1);
+
+ const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ spyOn(_converse.ChatBoxView.prototype, 'focus').and.callThrough();
+ const view = await mock.openChatBoxFor(_converse, contact_jid);
+ const rosterview = document.querySelector('converse-roster');
+ const el = sizzle('a.open-chat:contains("'+view.model.getDisplayName()+'")', rosterview).pop();
+ await u.waitUntil(() => u.isVisible(el));
+ const textarea = view.querySelector('.chat-textarea');
+ await u.waitUntil(() => u.isVisible(textarea));
+ textarea.blur();
+ el.click();
+ await u.waitUntil(() => view.focus.calls.count(), 1000);
+ expect(view.focus).toHaveBeenCalled();
+ expect(_converse.chatboxes.length).toEqual(2);
+ }));
+
+ it("can be saved to, and retrieved from, browserStorage",
+ mock.initConverse([], {}, async function (_converse) {
+
+ spyOn(_converse.minimize, 'trimChats');
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ spyOn(_converse.api, "trigger").and.callThrough();
+
+ mock.openChatBoxes(_converse, 6);
+ await u.waitUntil(() => _converse.chatboxes.length == 7);
+ expect(_converse.minimize.trimChats).toHaveBeenCalled();
+ // We instantiate a new ChatBoxes collection, which by default
+ // will be empty.
+ const newchatboxes = new _converse.ChatBoxes();
+ expect(newchatboxes.length).toEqual(0);
+ // The chatboxes will then be fetched from browserStorage inside the
+ // onConnected method
+ newchatboxes.onConnected();
+ await new Promise(resolve => _converse.api.listen.on('chatBoxesFetched', resolve));
+ expect(newchatboxes.length).toEqual(7);
+ // Check that the chatboxes items retrieved from browserStorage
+ // have the same attributes values as the original ones.
+ const attrs = ['id', 'box_id', 'visible'];
+ let new_attrs, old_attrs;
+ for (let i=0; i<attrs.length; i++) {
+ new_attrs = newchatboxes.models.map(m => m.attributes[i]);
+ old_attrs = _converse.chatboxes.models.map(m => m.attributes[i]);
+ expect(new_attrs).toEqual(old_attrs);
+ }
+ }));
+
+ it("can be closed by clicking a DOM element with class 'close-chatbox-button'",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[7].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const chatview = _converse.chatboxviews.get(contact_jid);
+ spyOn(chatview.model, 'close').and.callThrough();
+ spyOn(_converse.api, "trigger").and.callThrough();
+ chatview.querySelector('.close-chatbox-button').click();
+ expect(chatview.model.close).toHaveBeenCalled();
+ await new Promise(resolve => _converse.api.listen.once('chatBoxClosed', resolve));
+ expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
+ }));
+
+ it("will be removed from browserStorage when closed",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ spyOn(_converse.minimize, 'trimChats');
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ spyOn(_converse.api, "trigger").and.callThrough();
+ const promise = new Promise(resolve => _converse.api.listen.once('controlBoxClosed', resolve));
+ mock.closeControlBox();
+ await promise;
+ expect(_converse.chatboxes.length).toEqual(1);
+ expect(_converse.chatboxes.pluck('id')).toEqual(['controlbox']);
+ mock.openChatBoxes(_converse, 6);
+ await u.waitUntil(() => _converse.chatboxes.length == 7)
+ expect(_converse.minimize.trimChats).toHaveBeenCalled();
+ expect(_converse.chatboxes.length).toEqual(7);
+ expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxViewInitialized', jasmine.any(Object));
+ await mock.closeAllChatBoxes(_converse);
+
+ expect(_converse.chatboxes.length).toEqual(1);
+ expect(_converse.chatboxes.pluck('id')).toEqual(['controlbox']);
+ expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
+ const newchatboxes = new _converse.ChatBoxes();
+ expect(newchatboxes.length).toEqual(0);
+ expect(_converse.chatboxes.pluck('id')).toEqual(['controlbox']);
+ // onConnected will fetch chatboxes in browserStorage, but
+ // because there aren't any open chatboxes, there won't be any
+ // in browserStorage either. XXX except for the controlbox
+ newchatboxes.onConnected();
+ await new Promise(resolve => _converse.api.listen.on('chatBoxesFetched', resolve));
+ expect(newchatboxes.length).toEqual(1);
+ expect(newchatboxes.models[0].id).toBe("controlbox");
+ }));
+
+ describe("A chat toolbar", function () {
+
+ it("shows the remaining character count if a message_limit is configured",
+ mock.initConverse(['chatBoxesFetched'], {'message_limit': 200}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 3);
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const toolbar = view.querySelector('.chat-toolbar');
+ const counter = toolbar.querySelector('.message-limit');
+ expect(counter.textContent).toBe('200');
+ view.getMessageForm().insertIntoTextArea('hello world');
+ await u.waitUntil(() => counter.textContent === '188');
+
+ toolbar.querySelector('.toggle-emojis').click();
+ const picker = await u.waitUntil(() => view.querySelector('.emoji-picker__lists'));
+ const item = await u.waitUntil(() => picker.querySelector('.emoji-picker li.insert-emoji a'));
+ item.click()
+ await u.waitUntil(() => counter.textContent === '179');
+
+ const textarea = view.querySelector('.chat-textarea');
+ const ev = {
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ };
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown(ev);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ message_form.onKeyUp(ev);
+ expect(counter.textContent).toBe('200');
+
+ textarea.value = 'hello world';
+ message_form.onKeyUp(ev);
+ await u.waitUntil(() => counter.textContent === '189');
+ }));
+
+
+ it("does not show a remaining character count if message_limit is zero",
+ mock.initConverse(['chatBoxesFetched'], {'message_limit': 0}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 3);
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const counter = view.querySelector('.chat-toolbar .message-limit');
+ expect(counter).toBe(null);
+ }));
+
+
+ it("can contain a button for starting a call",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const { api } = _converse;
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ let toolbar, call_button;
+ const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ spyOn(_converse.api, "trigger").and.callThrough();
+ // First check that the button doesn't show if it's not enabled
+ // via "visible_toolbar_buttons"
+
+ let buttons = api.settings.get('visible_toolbar_buttons');
+ api.settings.set('visible_toolbar_buttons', Object.assign({}, buttons, {'call': false}));
+
+ await mock.openChatBoxFor(_converse, contact_jid);
+ let view = _converse.chatboxviews.get(contact_jid);
+ toolbar = view.querySelector('.chat-toolbar');
+ call_button = toolbar.querySelector('.toggle-call');
+ expect(call_button === null).toBeTruthy();
+ view.close();
+ // Now check that it's shown if enabled and that it emits
+ // callButtonClicked
+ buttons = api.settings.get('visible_toolbar_buttons');
+ api.settings.set('visible_toolbar_buttons', Object.assign({}, buttons, {'call': true}));
+
+ await mock.openChatBoxFor(_converse, contact_jid);
+ view = _converse.chatboxviews.get(contact_jid);
+ toolbar = view.querySelector('.chat-toolbar');
+ call_button = toolbar.querySelector('.toggle-call');
+ call_button.click();
+ expect(_converse.api.trigger).toHaveBeenCalledWith('callButtonClicked', jasmine.any(Object));
+ }));
+ });
+
+ describe("A Chat Status Notification", function () {
+
+ it("does not open a new chatbox", mock.initConverse([], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ // <composing> state
+ const stanza = $msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId()
+ }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+
+ spyOn(_converse.api, "trigger").and.callThrough();
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.api.trigger.calls.count());
+ expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+ expect(_converse.chatboxviews.keys().length).toBe(1);
+ }));
+
+ describe("An active notification", function () {
+
+ it("is sent when the user opens a chat box",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ spyOn(_converse.connection, 'send');
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const model = _converse.chatboxes.get(contact_jid);
+ expect(model.get('chat_state')).toBe('active');
+ expect(_converse.connection.send).toHaveBeenCalled();
+ const stanza = _converse.connection.send.calls.argsFor(0)[0];
+ expect(stanza.getAttribute('to')).toBe(contact_jid);
+ expect(stanza.childNodes.length).toBe(3);
+ expect(stanza.childNodes[0].tagName).toBe('active');
+ expect(stanza.childNodes[1].tagName).toBe('no-store');
+ expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
+ }));
+
+ it("is sent when the user maximizes a minimized a chat box", mock.initConverse(
+ ['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const model = _converse.chatboxes.get(contact_jid);
+ _converse.minimize.minimize(model);
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ sent_stanzas.splice(0, sent_stanzas.length);
+ expect(model.get('chat_state')).toBe('inactive');
+ _converse.minimize.maximize(model);
+ await u.waitUntil(() => model.get('chat_state') === 'active', 1000);
+ const stanza = await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`active`, s).length).pop());
+ expect(Strophe.serialize(stanza)).toBe(
+ `<message id="${stanza.getAttribute('id')}" to="${contact_jid}" 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>`
+ );
+ }));
+ });
+
+ describe("A composing notification", function () {
+
+ it("is sent as soon as the user starts typing a message which is not a command",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ expect(view.model.get('chat_state')).toBe('active');
+ spyOn(_converse.connection, 'send');
+ spyOn(_converse.api, "trigger").and.callThrough();
+
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: view.querySelector('textarea.chat-textarea'),
+ keyCode: 1
+ });
+ expect(view.model.get('chat_state')).toBe('composing');
+ expect(_converse.connection.send).toHaveBeenCalled();
+
+ const stanza = _converse.connection.send.calls.argsFor(0)[0];
+ expect(stanza.getAttribute('to')).toBe(contact_jid);
+ expect(stanza.childNodes.length).toBe(3);
+ expect(stanza.childNodes[0].tagName).toBe('composing');
+ expect(stanza.childNodes[1].tagName).toBe('no-store');
+ expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
+
+ // The notification is not sent again
+ message_form.onKeyDown({
+ target: view.querySelector('textarea.chat-textarea'),
+ keyCode: 1
+ });
+ expect(view.model.get('chat_state')).toBe('composing');
+ expect(_converse.api.trigger.calls.count(), 1);
+ }));
+
+ it("is NOT sent out if send_chat_state_notifications doesn't allow it",
+ mock.initConverse(['chatBoxesFetched'], {'send_chat_state_notifications': []},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ expect(view.model.get('chat_state')).toBe('active');
+ spyOn(_converse.connection, 'send');
+ spyOn(_converse.api, "trigger").and.callThrough();
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: view.querySelector('textarea.chat-textarea'),
+ keyCode: 1
+ });
+ expect(view.model.get('chat_state')).toBe('composing');
+ expect(_converse.connection.send).not.toHaveBeenCalled();
+ }));
+
+ it("will be shown if received", mock.initConverse([], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ await mock.openChatBoxFor(_converse, sender_jid);
+
+ // <composing> state
+ let msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ const view = _converse.chatboxviews.get(sender_jid);
+ let csn = mock.cur_names[1] + ' is typing';
+ await u.waitUntil( () => view.querySelector('.chat-content__notifications').innerText === csn);
+ expect(view.model.messages.length).toEqual(0);
+
+ // <paused> state
+ msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ csn = mock.cur_names[1] + ' has stopped typing';
+ await u.waitUntil( () => view.querySelector('.chat-content__notifications').innerText === csn);
+
+ msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t('hello world').tree();
+ await _converse.handleMessageStanza(msg);
+ const msg_el = await u.waitUntil(() => view.querySelector('.chat-msg'));
+ await u.waitUntil( () => view.querySelector('.chat-content__notifications').innerText === '');
+ expect(msg_el.querySelector('.chat-msg__text').textContent).toBe('hello world');
+ }));
+
+ it("is ignored if it's a composing carbon message sent by this user from a different client",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
+ await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
+ await mock.waitForRoster(_converse, 'current');
+ // Send a message from a different resource
+ const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const view = await mock.openChatBoxFor(_converse, recipient_jid);
+
+ spyOn(u, 'shouldCreateMessage').and.callThrough();
+
+ const msg = $msg({
+ 'from': _converse.bare_jid,
+ 'id': u.getUniqueId(),
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'xmlns': 'jabber:client'
+ }).c('sent', {'xmlns': 'urn:xmpp:carbons:2'})
+ .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+ .c('message', {
+ 'xmlns': 'jabber:client',
+ 'from': _converse.bare_jid+'/another-resource',
+ 'to': recipient_jid,
+ 'type': 'chat'
+ }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+
+ await u.waitUntil(() => u.shouldCreateMessage.calls.count());
+ expect(view.model.messages.length).toEqual(0);
+ const el = view.querySelector('.chat-content__notifications');
+ expect(el.textContent).toBe('');
+ }));
+ });
+
+ describe("A paused notification", function () {
+
+ it("is sent if the user has stopped typing since 30 seconds",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group li').length, 700);
+ _converse.TIMEOUTS.PAUSED = 200; // Make the timeout shorter so that we can test
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'setChatState').and.callThrough();
+ expect(view.model.get('chat_state')).toBe('active');
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: view.querySelector('textarea.chat-textarea'),
+ keyCode: 1
+ });
+ expect(view.model.get('chat_state')).toBe('composing');
+
+ const xmlns = 'https://jabber.org/protocol/chatstates';
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ let stanza = await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`composing`, s).length).pop(), 1000);
+
+ expect(Strophe.serialize(stanza)).toBe(
+ `<message id="${stanza.getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+
+ `<composing xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<no-store xmlns="urn:xmpp:hints"/>`+
+ `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+ `</message>`
+ );
+
+ await u.waitUntil(() => view.model.get('chat_state') === 'paused', 500);
+
+ stanza = await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`[xmlns="${xmlns}"]`, s)).pop());
+ expect(Strophe.serialize(stanza)).toBe(
+ `<message id="${stanza.getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+
+ `<paused xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<no-store xmlns="urn:xmpp:hints"/>`+
+ `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+ `</message>`
+ );
+
+ // Test #359. A paused notification should not be sent
+ // out if the user simply types longer than the
+ // timeout.
+ message_form.onKeyDown({
+ target: view.querySelector('textarea.chat-textarea'),
+ keyCode: 1
+ });
+ expect(view.model.setChatState).toHaveBeenCalled();
+ expect(view.model.get('chat_state')).toBe('composing');
+
+ message_form.onKeyDown({
+ target: view.querySelector('textarea.chat-textarea'),
+ keyCode: 1
+ });
+ expect(view.model.get('chat_state')).toBe('composing');
+ }));
+
+ it("will be shown if received", mock.initConverse([], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ // TODO: only show paused state if the previous state was composing
+ // 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';
+ const view = await mock.openChatBoxFor(_converse, sender_jid);
+ // <paused> state
+ const msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ const csn = mock.cur_names[1] + ' has stopped typing';
+ await u.waitUntil( () => view.querySelector('.chat-content__notifications').innerText === csn);
+ expect(view.model.messages.length).toEqual(0);
+ }));
+
+ it("will not be shown if it's a paused carbon message that this user sent from a different client",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
+ await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
+ await mock.waitForRoster(_converse, 'current');
+ // Send a message from a different resource
+ const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ spyOn(u, 'shouldCreateMessage').and.callThrough();
+ const view = await mock.openChatBoxFor(_converse, recipient_jid);
+ const msg = $msg({
+ 'from': _converse.bare_jid,
+ 'id': u.getUniqueId(),
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'xmlns': 'jabber:client'
+ }).c('sent', {'xmlns': 'urn:xmpp:carbons:2'})
+ .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+ .c('message', {
+ 'xmlns': 'jabber:client',
+ 'from': _converse.bare_jid+'/another-resource',
+ 'to': recipient_jid,
+ 'type': 'chat'
+ }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ await u.waitUntil(() => u.shouldCreateMessage.calls.count());
+ expect(view.model.messages.length).toEqual(0);
+ const el = view.querySelector('.chat-content__notifications');
+ expect(el.textContent).toBe('');
+ }));
+ });
+
+ describe("An inactive notification", function () {
+
+ it("is sent if the user has stopped typing since 2 minutes",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ // Make the timeouts shorter so that we can test
+ _converse.TIMEOUTS.PAUSED = 100;
+ _converse.TIMEOUTS.INACTIVE = 100;
+
+ await mock.waitForRoster(_converse, 'current');
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 1000);
+
+ sent_stanzas.splice(0, sent_stanzas.length);
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ await u.waitUntil(() => view.model.get('chat_state') === 'active');
+ expect(view.model.get('chat_state')).toBe('active');
+
+ const messages = sent_stanzas.filter(s => s.matches('message'));
+ expect(Strophe.serialize(messages[0])).toBe(
+ `<message id="${messages[0].getAttribute('id')}" to="mercutio@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>`);
+
+
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: view.querySelector('textarea.chat-textarea'),
+ keyCode: 1
+ });
+ await u.waitUntil(() => view.model.get('chat_state') === 'composing', 600);
+ let stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message composing')).pop());
+ expect(Strophe.serialize(stanza)).toBe(
+ `<message id="${stanza.getAttribute('id')}" to="mercutio@montague.lit" type="chat" xmlns="jabber:client">`+
+ `<composing xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<no-store xmlns="urn:xmpp:hints"/>`+
+ `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+ `</message>`);
+
+ await u.waitUntil(() => view.model.get('chat_state') === 'paused', 600);
+ stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message paused')).pop());
+ expect(Strophe.serialize(stanza)).toBe(
+ `<message id="${stanza.getAttribute('id')}" to="mercutio@montague.lit" type="chat" xmlns="jabber:client">`+
+ `<paused xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<no-store xmlns="urn:xmpp:hints"/>`+
+ `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+ `</message>`);
+
+ await u.waitUntil(() => view.model.get('chat_state') === 'inactive', 600);
+ stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message inactive')).pop());
+ expect(Strophe.serialize(stanza)).toBe(
+ `<message id="${stanza.getAttribute('id')}" to="mercutio@montague.lit" type="chat" xmlns="jabber:client">`+
+ `<inactive xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<no-store xmlns="urn:xmpp:hints"/>`+
+ `<no-permanent-store xmlns="urn:xmpp:hints"/>`+
+ `</message>`);
+
+ }));
+
+ it("is sent when the user a minimizes a chat box",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(_converse.connection, 'send');
+ _converse.minimize.minimize(view.model);
+ expect(view.model.get('chat_state')).toBe('inactive');
+ expect(_converse.connection.send).toHaveBeenCalled();
+ var stanza = _converse.connection.send.calls.argsFor(0)[0];
+ expect(stanza.getAttribute('to')).toBe(contact_jid);
+ expect(stanza.childNodes[0].tagName).toBe('inactive');
+ }));
+
+ it("is sent if the user closes a chat box",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ const view = await mock.openChatBoxFor(_converse, contact_jid);
+ expect(view.model.get('chat_state')).toBe('active');
+ spyOn(_converse.connection, 'send');
+ view.close();
+ expect(view.model.get('chat_state')).toBe('inactive');
+ expect(_converse.connection.send).toHaveBeenCalled();
+ const stanza = _converse.connection.send.calls.argsFor(0)[0];
+ expect(stanza.getAttribute('to')).toBe(contact_jid);
+ expect(stanza.childNodes.length).toBe(3);
+ expect(stanza.childNodes[0].tagName).toBe('inactive');
+ expect(stanza.childNodes[1].tagName).toBe('no-store');
+ expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
+ }));
+
+ it("will clear any other chat status notifications",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const view = _converse.chatboxviews.get(sender_jid);
+ expect(view.querySelectorAll('.chat-event').length).toBe(0);
+ // Insert <composing> message, to also check that
+ // text messages are inserted correctly with
+ // temporary chat events in the chat contents.
+ let msg = $msg({
+ 'to': _converse.bare_jid,
+ 'xmlns': 'jabber:client',
+ 'from': sender_jid,
+ 'type': 'chat'})
+ .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
+ .tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
+ expect(csntext).toEqual(mock.cur_names[1] + ' is typing');
+ expect(view.model.messages.length).toBe(0);
+
+ msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('inactive', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+
+ await u.waitUntil(() => !view.querySelector('.chat-content__notifications').textContent);
+ }));
+ });
+
+ describe("A gone notification", function () {
+
+ it("will be shown if received", mock.initConverse([], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current', 3);
+ await mock.openControlBox(_converse);
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, sender_jid);
+
+ const msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').c('gone', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+
+ const view = _converse.chatboxviews.get(sender_jid);
+ const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
+ expect(csntext).toEqual(mock.cur_names[1] + ' has gone away');
+ }));
+ });
+
+ describe("On receiving a message correction", function () {
+
+ it("will be removed", mock.initConverse([], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ await mock.openChatBoxFor(_converse, sender_jid);
+
+ // Original message
+ const original_id = u.getUniqueId();
+ const original = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: original_id,
+ body: "Original message",
+ }).c('active', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+
+ spyOn(_converse.api, "trigger").and.callThrough();
+ _converse.connection._dataRecv(mock.createRequest(original));
+ await u.waitUntil(() => _converse.api.trigger.calls.count());
+ expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+ const view = _converse.chatboxviews.get(sender_jid);
+ expect(view).toBeDefined();
+
+ // <composing> state
+ const msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+
+ const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
+ expect(csntext).toEqual(mock.cur_names[1] + ' is typing');
+
+ // Edited message
+ const edited = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId(),
+ body: "Edited message",
+ })
+ .c('active', {'xmlns': Strophe.NS.CHATSTATES}).up()
+ .c('replace', {'xmlns': Strophe.NS.MESSAGE_CORRECT, 'id': original_id }).tree();
+
+ await _converse.handleMessageStanza(edited);
+ await u.waitUntil(() => !view.querySelector('.chat-content__notifications').textContent);
+ }));
+ });
+ });
+ });
+
+ describe("Special Messages", function () {
+
+ it("'/clear' can be used to clear messages in a conversation",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ spyOn(_converse.api, "trigger").and.callThrough();
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ let message = 'This message is another sent from this chatbox';
+ await mock.sendMessage(view, message);
+
+ expect(view.model.messages.length === 1).toBeTruthy();
+ const stored_messages = await view.model.messages.browserStorage.findAll();
+ expect(stored_messages.length).toBe(1);
+ await u.waitUntil(() => view.querySelector('.chat-msg'));
+
+ message = '/clear';
+ const message_form = view.querySelector('converse-message-form');
+ spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
+ view.querySelector('.chat-textarea').value = message;
+ message_form.onKeyDown({
+ target: view.querySelector('textarea.chat-textarea'),
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+ await u.waitUntil(() => _converse.api.confirm.calls.count() === 1);
+ expect(_converse.api.confirm).toHaveBeenCalledWith('Are you sure you want to clear the messages from this conversation?');
+ await u.waitUntil(() => view.model.messages.length === 0);
+ await u.waitUntil(() => !view.querySelectorAll('.chat-msg__body').length);
+ }));
+ });
+
+
+ describe("A RosterView's Unread Message Count", function () {
+
+ it("is updated when message is received and chatbox is scrolled up",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ let msg, indicator_el;
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 500);
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ chatbox.ui.set('scrolled', true);
+ msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+ 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, rosterview).pop();
+ expect(indicator_el.textContent).toBe('1');
+ msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread too');
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => chatbox.messages.length > 1);
+ indicator_el = sizzle(selector, rosterview).pop();
+ expect(indicator_el.textContent).toBe('2');
+ }));
+
+ it("is updated when message is received and chatbox is minimized",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ let indicator_el, msg;
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 500);
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ _converse.minimize.minimize(chatbox);
+
+ msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+ 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, rosterview).pop();
+ expect(indicator_el.textContent).toBe('1');
+
+ msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread too');
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => chatbox.messages.length === 2);
+ indicator_el = sizzle(selector, rosterview).pop();
+ expect(indicator_el.textContent).toBe('2');
+ }));
+
+ it("is cleared when chatbox is maximzied after receiving messages in minimized mode",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read');
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 500);
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ const view = _converse.chatboxviews.get(sender_jid);
+ const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator';
+ const select_msgs_indicator = () => sizzle(selector, rosterview).pop();
+ _converse.minimize.minimize(view.model);
+ _converse.handleMessageStanza(msgFactory());
+ await u.waitUntil(() => chatbox.messages.length);
+ expect(select_msgs_indicator().textContent).toBe('1');
+ _converse.handleMessageStanza(msgFactory());
+ await u.waitUntil(() => chatbox.messages.length > 1);
+ expect(select_msgs_indicator().textContent).toBe('2');
+ _converse.minimize.maximize(view.model);
+ u.waitUntil(() => typeof select_msgs_indicator() === 'undefined');
+ }));
+
+ it("is cleared when unread messages are viewed which were received in scrolled-up chatbox",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.openControlBox(_converse);
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 500);
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read');
+ const selector = `a.open-chat:contains("${chatbox.get('nickname')}") .msgs-indicator`;
+ const select_msgs_indicator = () => sizzle(selector, rosterview).pop();
+ chatbox.ui.set('scrolled', true);
+ _converse.handleMessageStanza(msgFactory());
+ const view = _converse.chatboxviews.get(sender_jid);
+ await u.waitUntil(() => view.model.messages.length);
+ expect(select_msgs_indicator().textContent).toBe('1');
+ const chat_new_msgs_indicator = await u.waitUntil(() => view.querySelector('.new-msgs-indicator'));
+ chat_new_msgs_indicator.click();
+ await u.waitUntil(() => select_msgs_indicator() === undefined);
+ }));
+
+ it("is not cleared after user clicks on roster view when chatbox is already opened and scrolled up",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 500);
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ const view = _converse.chatboxviews.get(sender_jid);
+ const msg = 'This message will be received as unread, but eventually will be read';
+ const msgFactory = () => mock.createChatMessage(_converse, sender_jid, msg);
+ const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator';
+ const select_msgs_indicator = () => sizzle(selector, rosterview).pop();
+ chatbox.ui.set('scrolled', true);
+ _converse.handleMessageStanza(msgFactory());
+ await u.waitUntil(() => view.model.messages.length);
+ expect(select_msgs_indicator().textContent).toBe('1');
+ await mock.openChatBoxFor(_converse, sender_jid);
+ expect(select_msgs_indicator().textContent).toBe('1');
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/corrections.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/corrections.js
new file mode 100644
index 0000000..b6f395e
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/corrections.js
@@ -0,0 +1,354 @@
+/*global mock, converse */
+
+const { Promise, $msg, Strophe, sizzle, u } = converse.env;
+
+describe("A Chat Message", function () {
+
+ it("can be sent as a correction by using the up arrow",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+ const textarea = view.querySelector('textarea.chat-textarea');
+ expect(textarea.value).toBe('');
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+ expect(textarea.value).toBe('');
+
+ textarea.value = 'But soft, what light through yonder airlock breaks?';
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelector('.chat-msg__text').textContent)
+ .toBe('But soft, what light through yonder airlock breaks?');
+
+ const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
+ expect(textarea.value).toBe('');
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+ expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
+ expect(view.model.messages.at(0).get('correcting')).toBe(true);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
+
+ spyOn(_converse.connection, 'send');
+ let new_text = 'But soft, what light through yonder window breaks?';
+ textarea.value = new_text;
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.replace(/<!-.*?->/g, '') === new_text);
+
+ expect(_converse.connection.send).toHaveBeenCalled();
+ const msg = _converse.connection.send.calls.all()[0].args[0];
+ expect(Strophe.serialize(msg))
+ .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
+ `to="mercutio@montague.lit" type="chat" `+
+ `xmlns="jabber:client">`+
+ `<body>But soft, what light through yonder window breaks?</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<request xmlns="urn:xmpp:receipts"/>`+
+ `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
+ `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+ `</message>`);
+ expect(view.model.messages.models.length).toBe(1);
+ const corrected_message = view.model.messages.at(0);
+ expect(corrected_message.get('msgid')).toBe(first_msg.get('msgid'));
+ expect(corrected_message.get('correcting')).toBe(false);
+
+ const older_versions = corrected_message.get('older_versions');
+ const keys = Object.keys(older_versions);
+ expect(keys.length).toBe(1);
+ expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?');
+
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ await u.waitUntil(() => (u.hasClass('correcting', view.querySelector('.chat-msg')) === false), 500);
+
+ // Test that pressing the down arrow cancels message correction
+ await u.waitUntil(() => textarea.value === '')
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+ expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+ expect(view.model.messages.at(0).get('correcting')).toBe(true);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
+ expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 40 // Down arrow
+ });
+ expect(textarea.value).toBe('');
+ expect(view.model.messages.at(0).get('correcting')).toBe(false);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ await u.waitUntil(() => (u.hasClass('correcting', view.querySelector('.chat-msg')) === false), 500);
+
+ new_text = 'It is the east, and Juliet is the one.';
+ textarea.value = new_text;
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text'))
+ .filter(m => m.textContent.replace(/<!-.*?->/g, '') === new_text).length);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(2);
+
+ textarea.value = 'Arise, fair sun, and kill the envious moon';
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
+
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+ expect(textarea.value).toBe('Arise, fair sun, and kill the envious moon');
+ await u.waitUntil(() => view.model.messages.at(2).get('correcting') === true);
+ expect(view.model.messages.at(0).get('correcting')).toBeFalsy();
+ expect(view.model.messages.at(1).get('correcting')).toBeFalsy();
+ await u.waitUntil(() => u.hasClass('correcting', sizzle('.chat-msg:last', view).pop()), 750);
+
+ textarea.selectionEnd = 0; // Happens by pressing up,
+ // but for some reason not in tests, so we set it manually.
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+ expect(textarea.value).toBe('It is the east, and Juliet is the one.');
+ expect(view.model.messages.at(0).get('correcting')).toBeFalsy();
+ expect(view.model.messages.at(1).get('correcting')).toBe(true);
+ expect(view.model.messages.at(2).get('correcting')).toBeFalsy();
+ await u.waitUntil(() => u.hasClass('correcting', sizzle('.chat-msg', view)[1]), 500);
+
+ textarea.value = 'It is the east, and Juliet is the sun.';
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => textarea.value === '');
+ await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text')).filter(
+ m => m.textContent === 'It is the east, and Juliet is the sun.').length);
+
+ const messages = view.querySelectorAll('.chat-msg');
+ expect(messages.length).toBe(3);
+ expect(messages[0].querySelector('.chat-msg__text').textContent)
+ .toBe('But soft, what light through yonder window breaks?');
+ expect(messages[1].querySelector('.chat-msg__text').textContent)
+ .toBe('It is the east, and Juliet is the sun.');
+ expect(messages[2].querySelector('.chat-msg__text').textContent)
+ .toBe('Arise, fair sun, and kill the envious moon');
+
+ expect(view.model.messages.at(0).get('correcting')).toBeFalsy();
+ expect(view.model.messages.at(1).get('correcting')).toBeFalsy();
+ expect(view.model.messages.at(2).get('correcting')).toBeFalsy();
+ }));
+
+
+ it("can be sent as a correction by clicking the pencil icon",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const textarea = view.querySelector('textarea.chat-textarea');
+
+ textarea.value = 'But soft, what light through yonder airlock breaks?';
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelector('.chat-msg__text').textContent)
+ .toBe('But soft, what light through yonder airlock breaks?');
+ await u.waitUntil(() => textarea.value === '');
+
+ const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg .chat-msg__action').length === 2);
+ let action = view.querySelector('.chat-msg .chat-msg__action');
+ expect(action.textContent.trim()).toBe('Edit');
+
+ action.style.opacity = 1;
+ action.click();
+
+ expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?');
+ expect(view.model.messages.at(0).get('correcting')).toBe(true);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')));
+
+ spyOn(_converse.connection, 'send');
+ const text = 'But soft, what light through yonder window breaks?';
+ textarea.value = text;
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.replace(/<!-.*?->/g, '') === text);
+ expect(_converse.connection.send).toHaveBeenCalled();
+
+ const msg = _converse.connection.send.calls.all()[0].args[0];
+ expect(Strophe.serialize(msg))
+ .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
+ `to="mercutio@montague.lit" type="chat" `+
+ `xmlns="jabber:client">`+
+ `<body>But soft, what light through yonder window breaks?</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<request xmlns="urn:xmpp:receipts"/>`+
+ `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
+ `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+ `</message>`);
+ expect(view.model.messages.models.length).toBe(1);
+ const corrected_message = view.model.messages.at(0);
+ expect(corrected_message.get('msgid')).toBe(first_msg.get('msgid'));
+ expect(corrected_message.get('correcting')).toBe(false);
+
+ const older_versions = corrected_message.get('older_versions');
+ const keys = Object.keys(older_versions);
+ expect(keys.length).toBe(1);
+ expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?');
+
+ await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')) === false);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+
+ // Test that clicking the pencil icon a second time cancels editing.
+ action = view.querySelector('.chat-msg .chat-msg__action');
+ action.style.opacity = 1;
+ action.click();
+
+ expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+ expect(view.model.messages.at(0).get('correcting')).toBe(true);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')) === true);
+
+ action = view.querySelector('.chat-msg .chat-msg__action');
+ action.style.opacity = 1;
+ action.click();
+ expect(view.model.messages.at(0).get('correcting')).toBe(false);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(textarea.value).toBe('');
+ await u.waitUntil(() => (u.hasClass('correcting', view.querySelector('.chat-msg')) === false), 500);
+
+ // Test that messages from other users don't have the pencil icon
+ _converse.handleMessageStanza(
+ $msg({
+ 'from': contact_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId()
+ }).c('body').t('Hello').up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
+ );
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ expect(view.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(2);
+
+ // Test confirmation dialog
+ spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
+ textarea.value = 'But soft, what light through yonder airlock breaks?';
+ action = view.querySelector('.chat-msg .chat-msg__action');
+ action.style.opacity = 1;
+ action.click();
+
+ await u.waitUntil(() => _converse.api.confirm.calls.count());
+ expect(_converse.api.confirm).toHaveBeenCalledWith(
+ 'You have an unsent message which will be lost if you continue. Are you sure?');
+ expect(view.model.messages.at(0).get('correcting')).toBe(true);
+ expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
+
+ textarea.value = 'But soft, what light through yonder airlock breaks?'
+ action.click();
+
+ await u.waitUntil(() => _converse.api.confirm.calls.count() === 2);
+ expect(view.model.messages.at(0).get('correcting')).toBe(false);
+ expect(_converse.api.confirm.calls.argsFor(0)).toEqual(
+ ['You have an unsent message which will be lost if you continue. Are you sure?']);
+ expect(_converse.api.confirm.calls.argsFor(1)).toEqual(
+ ['You have an unsent message which will be lost if you continue. Are you sure?']);
+ }));
+
+
+ describe("when received from someone else", function () {
+
+ it("can be replaced with a correction",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.openControlBox(_converse);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg_id = u.getUniqueId();
+ const view = await mock.openChatBoxFor(_converse, sender_jid);
+ _converse.handleMessageStanza($msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': msg_id,
+ }).c('body').t('But soft, what light through yonder airlock breaks?').tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelector('.chat-msg__text').textContent)
+ .toBe('But soft, what light through yonder airlock breaks?');
+
+ _converse.handleMessageStanza($msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId(),
+ }).c('body').t('But soft, what light through yonder chimney breaks?').up()
+ .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ expect(view.querySelector('.chat-msg__text').textContent)
+ .toBe('But soft, what light through yonder chimney breaks?');
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
+ expect(view.model.messages.models.length).toBe(1);
+
+ _converse.handleMessageStanza($msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId(),
+ }).c('body').t('But soft, what light through yonder window breaks?').up()
+ .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ expect(view.querySelector('.chat-msg__text').textContent)
+ .toBe('But soft, what light through yonder window breaks?');
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1);
+ view.querySelector('.chat-msg__content .fa-edit').click();
+
+ const modal = _converse.api.modal.get('converse-message-versions-modal');
+ await u.waitUntil(() => u.isVisible(modal), 1000);
+ const older_msgs = modal.querySelectorAll('.older-msg');
+ expect(older_msgs.length).toBe(2);
+ expect(older_msgs[0].textContent.includes('But soft, what light through yonder airlock breaks?')).toBe(true);
+ expect(view.model.messages.models.length).toBe(1);
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/emojis.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/emojis.js
new file mode 100644
index 0000000..88d3ecd
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/emojis.js
@@ -0,0 +1,210 @@
+/*global mock, converse */
+
+const { Promise, $msg } = converse.env;
+const u = converse.env.utils;
+const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
+
+describe("Emojis", function () {
+ describe("The emoji picker", function () {
+
+ beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000));
+ afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
+
+ it("can be opened by clicking a button in the chat toolbar",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const toolbar = await u.waitUntil(() => view.querySelector('converse-chat-toolbar'));
+ toolbar.querySelector('.toggle-emojis').click();
+ await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')), 1000);
+ const item = view.querySelector('.emoji-picker li.insert-emoji a');
+ item.click()
+ expect(view.querySelector('textarea.chat-textarea').value).toBe(':smiley: ');
+ toolbar.querySelector('.toggle-emojis').click(); // Close the panel again
+ }));
+ });
+
+ describe("A Chat Message", function () {
+
+ it("will display larger if it's only emojis",
+ mock.initConverse(['chatBoxesFetched'], {'use_system_emojis': true}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.handleMessageStanza($msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': _converse.connection.getUniqueId()
+ }).c('body').t('😇').up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
+ const view = _converse.chatboxviews.get(sender_jid);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.querySelector('.chat-msg__text')));
+
+ _converse.handleMessageStanza($msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': _converse.connection.getUniqueId()
+ }).c('body').t('😇 Hello world! 😇 😇').up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+
+ let sel = '.message:last-child .chat-msg__text';
+ await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.querySelector(sel)));
+
+ // Test that a modified message that no longer contains only
+ // emojis now renders normally again.
+ const textarea = view.querySelector('textarea.chat-textarea');
+ textarea.value = ':poop: :innocent:';
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
+ const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
+ await u.waitUntil(() => view.querySelector(last_msg_sel).textContent === '💩 😇');
+
+ expect(textarea.value).toBe('');
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+ expect(textarea.value).toBe('💩 😇');
+ expect(view.model.messages.at(2).get('correcting')).toBe(true);
+ sel = 'converse-chat-message:last-child .chat-msg'
+ await u.waitUntil(() => u.hasClass('correcting', view.querySelector(sel)), 500);
+ const edited_text = textarea.value += 'This is no longer an emoji-only message';
+ textarea.value = edited_text;
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text'))
+ .filter(el => el.textContent === edited_text).length);
+ expect(view.model.messages.models.length).toBe(3);
+ let message = view.querySelector(last_msg_sel);
+ expect(u.hasClass('chat-msg__text--larger', message)).toBe(false);
+
+ textarea.value = ':smile: Hello world!';
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
+
+ textarea.value = ':smile: :smiley: :imp:';
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 5);
+
+ message = view.querySelector('.message:last-child .chat-msg__text');
+ expect(u.hasClass('chat-msg__text--larger', message)).toBe(true);
+ }));
+
+ it("can render emojis as images",
+ mock.initConverse(
+ ['chatBoxesFetched'], {'use_system_emojis': false},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const contact_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ _converse.handleMessageStanza($msg({
+ 'from': contact_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': _converse.connection.getUniqueId()
+ }).c('body').t('😇').up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
+ const view = _converse.chatboxviews.get(contact_jid);
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ await u.waitUntil(() => view.querySelector('.chat-msg__text').innerHTML.replace(/<!-.*?->/g, '') ===
+ '<img class="emoji" loading="lazy" draggable="false" title=":innocent:" alt="😇" src="https://twemoji.maxcdn.com/v/12.1.6//72x72/1f607.png">');
+
+ const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
+ let message = view.querySelector(last_msg_sel);
+ await u.waitUntil(() => u.isVisible(message.querySelector('.emoji')), 1000);
+ let imgs = message.querySelectorAll('.emoji');
+ expect(imgs.length).toBe(1);
+ expect(imgs[0].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f607.png');
+
+ const textarea = view.querySelector('textarea.chat-textarea');
+ textarea.value = ':poop: :innocent:';
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ message = view.querySelector(last_msg_sel);
+ await u.waitUntil(() => u.isVisible(message.querySelector('.emoji')), 1000);
+ imgs = message.querySelectorAll('.emoji');
+ expect(imgs.length).toBe(2);
+ expect(imgs[0].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f4a9.png');
+ expect(imgs[1].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f607.png');
+
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ const sent_stanza = sent_stanzas.filter(s => s.nodeName === 'message').pop();
+ expect(sent_stanza.querySelector('body').innerHTML).toBe('💩 😇');
+ }));
+
+ it("can show custom emojis",
+ mock.initConverse(
+ ['chatBoxesFetched'],
+ { emoji_categories: {
+ "smileys": ":grinning:",
+ "people": ":thumbsup:",
+ "activity": ":soccer:",
+ "travel": ":motorcycle:",
+ "objects": ":bomb:",
+ "nature": ":rainbow:",
+ "food": ":hotdog:",
+ "symbols": ":musical_note:",
+ "flags": ":flag_ac:",
+ "custom": ':xmpp:'
+ } },
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar'));
+ toolbar.querySelector('.toggle-emojis').click();
+ await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')), 1000);
+ const picker = await u.waitUntil(() => view.querySelector('converse-emoji-picker'), 1000);
+ const custom_category = picker.querySelector('.pick-category[data-category="custom"]');
+ expect(custom_category.innerHTML.replace(/<!-.*?->/g, '').trim()).toBe(
+ '<img class="emoji" loading="lazy" draggable="false" title=":xmpp:" alt=":xmpp:" src="/dist/images/custom_emojis/xmpp.png">');
+
+ const textarea = view.querySelector('textarea.chat-textarea');
+ textarea.value = 'Running tests for :converse:';
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ const body = view.querySelector('converse-chat-message-body');
+ await u.waitUntil(() => body.innerHTML.replace(/<!-.*?->/g, '').trim() ===
+ 'Running tests for <img class="emoji" loading="lazy" draggable="false" title=":converse:" alt=":converse:" src="/dist/images/custom_emojis/converse.png">');
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/http-file-upload.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/http-file-upload.js
new file mode 100644
index 0000000..8bbd8f2
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/http-file-upload.js
@@ -0,0 +1,477 @@
+/*global mock, converse */
+
+const Strophe = converse.env.Strophe;
+const $iq = converse.env.$iq;
+const u = converse.env.utils;
+
+describe("XEP-0363: HTTP File Upload", function () {
+
+ describe("Discovering support", function () {
+
+ it("is done automatically", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ const { api } = _converse;
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], []);
+ let selector = 'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]';
+ let stanza = await u.waitUntil(() => IQ_stanzas.find(iq => iq.querySelector(selector)), 1000);
+
+ /* <iq type='result'
+ * from='plays.shakespeare.lit'
+ * to='romeo@montague.net/orchard'
+ * id='info1'>
+ * <query xmlns='http://jabber.org/protocol/disco#info'>
+ * <identity
+ * category='server'
+ * type='im'/>
+ * <feature var='http://jabber.org/protocol/disco#info'/>
+ * <feature var='http://jabber.org/protocol/disco#items'/>
+ * </query>
+ * </iq>
+ */
+ stanza = $iq({
+ 'type': 'result',
+ 'from': 'montague.lit',
+ 'to': 'romeo@montague.lit/orchard',
+ 'id': stanza.getAttribute('id'),
+ }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', {
+ 'category': 'server',
+ 'type': 'im'}).up()
+ .c('feature', {
+ 'var': 'http://jabber.org/protocol/disco#info'}).up()
+ .c('feature', {
+ 'var': 'http://jabber.org/protocol/disco#items'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ // Converse.js sees that the entity has a disco#items feature,
+ // so it will make a query for it.
+ selector = 'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]';
+ await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).length, 1000);
+ /* <iq from='montague.tld'
+ * id='step_01'
+ * to='romeo@montague.tld/garden'
+ * type='result'>
+ * <query xmlns='http://jabber.org/protocol/disco#items'>
+ * <item jid='upload.montague.tld' name='HTTP File Upload' />
+ * <item jid='conference.montague.tld' name='Chatroom Service' />
+ * </query>
+ * </iq>
+ */
+ selector = 'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]';
+ stanza = IQ_stanzas.find(iq => iq.querySelector(selector), 500);
+ stanza = $iq({
+ 'type': 'result',
+ 'from': 'montague.lit',
+ 'to': 'romeo@montague.lit/orchard',
+ 'id': stanza.getAttribute('id'),
+ }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'})
+ .c('item', {
+ 'jid': 'upload.montague.lit',
+ 'name': 'HTTP File Upload'});
+
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ let entities = await api.disco.entities.get();
+ expect(entities.length).toBe(3);
+ expect(entities.pluck('jid')).toEqual(['montague.lit', 'romeo@montague.lit', 'upload.montague.lit']);
+
+ expect(entities.get(_converse.domain).features.length).toBe(2);
+ expect(entities.get(_converse.domain).identities.length).toBe(1);
+
+ api.disco.entities.get().then(entities => {
+ expect(entities.length).toBe(3);
+ expect(entities.pluck('jid')).toEqual(['montague.lit', 'romeo@montague.lit', 'upload.montague.lit']);
+ expect(api.disco.entities.items('montague.lit').length).toBe(1);
+ // Converse.js sees that the entity has a disco#info feature, so it will make a query for it.
+ const selector = 'iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]';
+ return u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).length > 0);
+ });
+
+ selector = 'iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]';
+ stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).pop(), 1000);
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq from="romeo@montague.lit/orchard" id="`+stanza.getAttribute('id')+`" to="upload.montague.lit" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/disco#info"/>`+
+ `</iq>`);
+
+ // Upload service responds and reports a maximum file size of 5MiB
+ /* <iq from='upload.montague.tld'
+ * id='step_02'
+ * to='romeo@montague.tld/garden'
+ * type='result'>
+ * <query xmlns='http://jabber.org/protocol/disco#info'>
+ * <identity category='store'
+ * type='file'
+ * name='HTTP File Upload' />
+ * <feature var='urn:xmpp:http:upload:0' />
+ * <x type='result' xmlns='jabber:x:data'>
+ * <field var='FORM_TYPE' type='hidden'>
+ * <value>urn:xmpp:http:upload:0</value>
+ * </field>
+ * <field var='max-file-size'>
+ * <value>5242880</value>
+ * </field>
+ * </x>
+ * </query>
+ * </iq>
+ */
+ stanza = $iq({'type': 'result', 'to': 'romeo@montague.lit/orchard', 'id': stanza.getAttribute('id'), 'from': 'upload.montague.lit'})
+ .c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', {'category':'store', 'type':'file', 'name':'HTTP File Upload'}).up()
+ .c('feature', {'var':'urn:xmpp:http:upload:0'}).up()
+ .c('x', {'type':'result', 'xmlns':'jabber:x:data'})
+ .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
+ .c('value').t('urn:xmpp:http:upload:0').up().up()
+ .c('field', {'var':'max-file-size'})
+ .c('value').t('5242880');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ entities = await _converse.api.disco.entities.get();
+ const entity = await api.disco.entities.get('upload.montague.lit');
+ expect(entity.get('parent_jids')).toEqual(['montague.lit']);
+ expect(entity.identities.where({'category': 'store'}).length).toBe(1);
+ const supported = await _converse.api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain);
+ expect(supported).toBe(true);
+ const features = await _converse.api.disco.features.get(Strophe.NS.HTTPUPLOAD, _converse.domain);
+ expect(features.length).toBe(1);
+ expect(features[0].get('jid')).toBe('upload.montague.lit');
+ expect(features[0].dataforms.where({'FORM_TYPE': {value: "urn:xmpp:http:upload:0", type: "hidden"}}).length).toBe(1);
+ }));
+ });
+
+ describe("When not supported", function () {
+ describe("A file upload toolbar button", function () {
+
+ it("does not appear in private chats",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 3);
+ mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.domain,
+ [{'category': 'server', 'type':'IM'}],
+ ['http://jabber.org/protocol/disco#items'], [], 'info');
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], [], 'items');
+ const view = _converse.chatboxviews.get(contact_jid);
+ expect(view.querySelector('.chat-toolbar .fileupload')).toBe(null);
+ }));
+ });
+ });
+
+ describe("When supported", function () {
+
+ describe("A file upload toolbar button", function () {
+
+ it("appears in private chats", mock.initConverse([], {}, async (_converse) => {
+ await mock.waitForRoster(_converse, 'current', 3);
+ const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.domain,
+ [{'category': 'server', 'type':'IM'}],
+ ['http://jabber.org/protocol/disco#items'], [], 'info');
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.lit'], 'items')
+ await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.lit', [], [Strophe.NS.HTTPUPLOAD], []);
+
+ const el = await u.waitUntil(() => view.querySelector('.chat-toolbar .fileupload'));
+ expect(el).not.toEqual(null);
+ }));
+
+ describe("when clicked and a file chosen", function () {
+
+ it("is uploaded and sent out", mock.initConverse(['chatBoxesFetched'], {} ,async (_converse) => {
+ const base_url = 'https://conversejs.org';
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.domain,
+ [{'category': 'server', 'type':'IM'}],
+ ['http://jabber.org/protocol/disco#items'], [], 'info');
+
+ const send_backup = XMLHttpRequest.prototype.send;
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items');
+ await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []);
+ await mock.waitForRoster(_converse, 'current');
+ const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const file = {
+ 'type': 'image/jpeg',
+ 'size': '23456' ,
+ 'lastModifiedDate': "",
+ 'name': "my-juliet.jpg"
+ };
+ view.model.sendFiles([file]);
+
+ await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length);
+ const iq = IQ_stanzas.pop();
+ expect(Strophe.serialize(iq)).toBe(
+ `<iq from="romeo@montague.lit/orchard" `+
+ `id="${iq.getAttribute("id")}" `+
+ `to="upload.montague.tld" `+
+ `type="get" `+
+ `xmlns="jabber:client">`+
+ `<request `+
+ `content-type="image/jpeg" `+
+ `filename="my-juliet.jpg" `+
+ `size="23456" `+
+ `xmlns="urn:xmpp:http:upload:0"/>`+
+ `</iq>`);
+
+ const message = base_url+"/logo/conversejs-filled.svg";
+
+ const stanza = u.toStanza(`
+ <iq from="upload.montague.tld"
+ id="${iq.getAttribute("id")}"
+ to="romeo@montague.lit/orchard"
+ type="result">
+ <slot xmlns="urn:xmpp:http:upload:0">
+ <put url="https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg">
+ <header name="Authorization">Basic Base64String==</header>
+ <header name="Cookie">foo=bar; user=romeo</header>
+ </put>
+ <get url="${message}" />
+ </slot>
+ </iq>`);
+
+ spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async function () {
+ const message = view.model.messages.at(0);
+ const el = await u.waitUntil(() => view.querySelector('.chat-content progress'));
+ expect(el.getAttribute('value')).toBe('0');
+ message.set('progress', 0.5);
+ await u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '0.5')
+ message.set('progress', 1);
+ await u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '1')
+ message.save({
+ 'upload': _converse.SUCCESS,
+ 'oob_url': message.get('get'),
+ 'body': message.get('get'),
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ });
+ let sent_stanza;
+ spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza));
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await u.waitUntil(() => sent_stanza, 1000);
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<message from="romeo@montague.lit/orchard" `+
+ `id="${sent_stanza.getAttribute("id")}" `+
+ `to="lady.montague@montague.lit" `+
+ `type="chat" `+
+ `xmlns="jabber:client">`+
+ `<body>${message}</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<request xmlns="urn:xmpp:receipts"/>`+
+ `<x xmlns="jabber:x:oob">`+
+ `<url>${message}</url>`+
+ `</x>`+
+ `<origin-id id="${sent_stanza.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+ `</message>`);
+ const img_link_el = await u.waitUntil(() => view.querySelector('converse-chat-message-body .chat-image__link'), 1000);
+ // Check that the image renders
+ expect(img_link_el.outerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
+ `<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
+ `<img class="chat-image img-thumbnail" loading="lazy" src="${base_url}/logo/conversejs-filled.svg"></a>`);
+ XMLHttpRequest.prototype.send = send_backup;
+ }));
+
+ it("shows an error message if the file is too large",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const { api } = _converse;
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+ const IQ_ids = _converse.connection.IQ_ids;
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], []);
+ await u.waitUntil(() => IQ_stanzas.filter(
+ iq => iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]')).length
+ );
+
+ let stanza = IQ_stanzas.find((iq) =>
+ iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]'));
+
+ const info_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
+ stanza = $iq({
+ 'type': 'result',
+ 'from': 'montague.lit',
+ 'to': 'romeo@montague.lit/orchard',
+ 'id': info_IQ_id
+ }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', {
+ 'category': 'server',
+ 'type': 'im'}).up()
+ .c('feature', {
+ 'var': 'http://jabber.org/protocol/disco#info'}).up()
+ .c('feature', {
+ 'var': 'http://jabber.org/protocol/disco#items'});
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await u.waitUntil(function () {
+ // Converse.js sees that the entity has a disco#items feature,
+ // so it will make a query for it.
+ return IQ_stanzas.filter(function (iq) {
+ return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]');
+ }).length > 0;
+ }, 300);
+
+ stanza = IQ_stanzas.find(function (iq) {
+ return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]');
+ });
+ const items_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
+ stanza = $iq({
+ 'type': 'result',
+ 'from': 'montague.lit',
+ 'to': 'romeo@montague.lit/orchard',
+ 'id': items_IQ_id
+ }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'})
+ .c('item', {
+ 'jid': 'upload.montague.lit',
+ 'name': 'HTTP File Upload'});
+
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ let entities = await _converse.api.disco.entities.get();
+
+ expect(entities.length).toBe(3);
+ expect(entities.get(_converse.domain).features.length).toBe(2);
+ expect(entities.get(_converse.domain).identities.length).toBe(1);
+ expect(entities.pluck('jid')).toEqual(['montague.lit', 'romeo@montague.lit', 'upload.montague.lit']);
+ expect(api.disco.entities.items('montague.lit').length).toBe(1);
+ await u.waitUntil(function () {
+ // Converse.js sees that the entity has a disco#info feature,
+ // so it will make a query for it.
+ return IQ_stanzas.filter(iq =>
+ iq.querySelector('iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]')
+ ).length > 0;
+ }, 300);
+
+ stanza = IQ_stanzas.find(iq => iq.querySelector('iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]'));
+ const IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)];
+ expect(Strophe.serialize(stanza)).toBe(
+ `<iq from="romeo@montague.lit/orchard" id="${IQ_id}" to="upload.montague.lit" type="get" xmlns="jabber:client">`+
+ `<query xmlns="http://jabber.org/protocol/disco#info"/>`+
+ `</iq>`);
+
+ // Upload service responds and reports a maximum file size of 5MiB
+ stanza = $iq({'type': 'result', 'to': 'romeo@montague.lit/orchard', 'id': IQ_id, 'from': 'upload.montague.lit'})
+ .c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'})
+ .c('identity', {'category':'store', 'type':'file', 'name':'HTTP File Upload'}).up()
+ .c('feature', {'var':'urn:xmpp:http:upload:0'}).up()
+ .c('x', {'type':'result', 'xmlns':'jabber:x:data'})
+ .c('field', {'var':'FORM_TYPE', 'type':'hidden'})
+ .c('value').t('urn:xmpp:http:upload:0').up().up()
+ .c('field', {'var':'max-file-size'})
+ .c('value').t('5242880');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ entities = await _converse.api.disco.entities.get();
+ const entity = await api.disco.entities.get('upload.montague.lit');
+ expect(entity.get('parent_jids')).toEqual(['montague.lit']);
+ expect(entity.identities.where({'category': 'store'}).length).toBe(1);
+ await _converse.api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain);
+ await mock.waitForRoster(_converse, 'current');
+
+ const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const file = {
+ 'type': 'image/jpeg',
+ 'size': '5242881',
+ 'lastModifiedDate': "",
+ 'name': "my-juliet.jpg"
+ };
+ view.model.sendFiles([file]);
+ await u.waitUntil(() => view.querySelectorAll('.message').length)
+ const messages = view.querySelectorAll('.message.chat-error');
+ expect(messages.length).toBe(1);
+ expect(messages[0].textContent.trim()).toBe(
+ 'The size of your file, my-juliet.jpg, exceeds the maximum allowed by your server, which is 5.24 MB.');
+ }));
+ });
+ });
+
+ describe("While a file is being uploaded", function () {
+
+ it("shows a progress bar", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ await mock.waitUntilDiscoConfirmed(
+ _converse, _converse.domain,
+ [{'category': 'server', 'type':'IM'}],
+ ['http://jabber.org/protocol/disco#items'], [], 'info');
+
+ const IQ_stanzas = _converse.connection.IQ_stanzas;
+
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items');
+ await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []);
+ await mock.waitForRoster(_converse, 'current');
+ const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const file = {
+ 'type': 'image/jpeg',
+ 'size': '23456' ,
+ 'lastModifiedDate': "",
+ 'name': "my-juliet.jpg"
+ };
+ view.model.sendFiles([file]);
+ await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length)
+ const iq = IQ_stanzas.pop();
+ expect(Strophe.serialize(iq)).toBe(
+ `<iq from="romeo@montague.lit/orchard" `+
+ `id="${iq.getAttribute("id")}" `+
+ `to="upload.montague.tld" `+
+ `type="get" `+
+ `xmlns="jabber:client">`+
+ `<request `+
+ `content-type="image/jpeg" `+
+ `filename="my-juliet.jpg" `+
+ `size="23456" `+
+ `xmlns="urn:xmpp:http:upload:0"/>`+
+ `</iq>`);
+
+ const base_url = 'https://conversejs.org';
+ const message = base_url+"/logo/conversejs-filled.svg";
+ const stanza = u.toStanza(`
+ <iq from="upload.montague.tld"
+ id="${iq.getAttribute("id")}"
+ to="romeo@montague.lit/orchard"
+ type="result">
+ <slot xmlns="urn:xmpp:http:upload:0">
+ <put url="https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg">
+ <header name="Authorization">Basic Base64String==</header>
+ <header name="Cookie">foo=bar; user=romeo</header>
+ </put>
+ <get url="${message}" />
+ </slot>
+ </iq>`);
+
+ const promise = u.getOpenPromise();
+
+ spyOn(XMLHttpRequest.prototype, 'setRequestHeader');
+ spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async () => {
+ const message = view.model.messages.at(0);
+ const el = await u.waitUntil(() => view.querySelector('.chat-content progress'));
+ expect(el.getAttribute('value')).toBe('0');
+ message.set('progress', 0.5);
+ await u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '0.5');
+ message.set('progress', 1);
+ await u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '1');
+ expect(view.querySelector('.chat-content .chat-msg__text').textContent).toBe('Uploading file: my-juliet.jpg, 23.46 kB');
+ promise.resolve();
+ });
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await promise;
+ expect(XMLHttpRequest.prototype.setRequestHeader.calls.count()).toBe(2);
+ expect(XMLHttpRequest.prototype.setRequestHeader.calls.all()[0].args[0]).toBe('Content-type');
+ expect(XMLHttpRequest.prototype.setRequestHeader.calls.all()[0].args[1]).toBe('image/jpeg');
+ expect(XMLHttpRequest.prototype.setRequestHeader.calls.all()[1].args[0]).toBe('Authorization');
+ expect(XMLHttpRequest.prototype.setRequestHeader.calls.all()[1].args[1]).toBe('Basic Base64String==');
+ }));
+ });
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/markers.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/markers.js
new file mode 100644
index 0000000..5b61b84
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/markers.js
@@ -0,0 +1,114 @@
+/*global mock, converse */
+
+const Strophe = converse.env.Strophe;
+const u = converse.env.utils;
+// See: https://xmpp.org/rfcs/rfc3921.html
+
+
+describe("A XEP-0333 Chat Marker", function () {
+
+ it("is sent when a markable message is received from a roster contact",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const msgid = u.getUniqueId();
+ const stanza = u.toStanza(`
+ <message from='${contact_jid}'
+ id='${msgid}'
+ type="chat"
+ to='${_converse.jid}'>
+ <body>My lord, dispatch; read o'er these articles.</body>
+ <markable xmlns='urn:xmpp:chat-markers:0'/>
+ </message>`);
+
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => sent_stanzas.length === 2);
+ expect(Strophe.serialize(sent_stanzas[0])).toBe(
+ `<message from="romeo@montague.lit/orchard" `+
+ `id="${sent_stanzas[0].getAttribute('id')}" `+
+ `to="${contact_jid}" type="chat" xmlns="jabber:client">`+
+ `<received id="${msgid}" xmlns="urn:xmpp:chat-markers:0"/>`+
+ `</message>`);
+ }));
+
+ it("is not sent when a markable message is received from someone not on the roster",
+ mock.initConverse([], {'allow_non_roster_messaging': true}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ const contact_jid = 'someone@montague.lit';
+ const msgid = u.getUniqueId();
+ const stanza = u.toStanza(`
+ <message from='${contact_jid}'
+ id='${msgid}'
+ type="chat"
+ to='${_converse.jid}'>
+ <body>My lord, dispatch; read o'er these articles.</body>
+ <markable xmlns='urn:xmpp:chat-markers:0'/>
+ </message>`);
+
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s));
+ await _converse.handleMessageStanza(stanza);
+ const sent_messages = sent_stanzas
+ .map(s => s?.nodeTree ?? s)
+ .filter(e => e.nodeName === 'message');
+
+ await u.waitUntil(() => sent_messages.length === 2);
+ expect(Strophe.serialize(sent_messages[0])).toBe(
+ `<message id="${sent_messages[0].getAttribute('id')}" to="${contact_jid}" 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>`
+ );
+ }));
+
+ it("is ignored if it's a carbon copy of one that I sent from a different client",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]);
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ let stanza = u.toStanza(`
+ <message xmlns="jabber:client"
+ to="${_converse.bare_jid}"
+ type="chat"
+ id="2e972ea0-0050-44b7-a830-f6638a2595b3"
+ from="${contact_jid}">
+ <body>😊</body>
+ <markable xmlns="urn:xmpp:chat-markers:0"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
+ <stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.model.messages.length).toBe(1);
+
+ stanza = u.toStanza(
+ `<message xmlns="jabber:client" to="${_converse.bare_jid}" type="chat" from="${contact_jid}">
+ <sent xmlns="urn:xmpp:carbons:2">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <message xmlns="jabber:client" to="${contact_jid}" type="chat" from="${_converse.bare_jid}/other-resource">
+ <received xmlns="urn:xmpp:chat-markers:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/>
+ <store xmlns="urn:xmpp:hints"/>
+ <stanza-id xmlns="urn:xmpp:sid:0" id="F4TC6CvHwzqRbeHb" by="${_converse.bare_jid}"/>
+ </message>
+ </forwarded>
+ </sent>
+ </message>`);
+ spyOn(_converse.api, "trigger").and.callThrough();
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => _converse.api.trigger.calls.count(), 500);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.model.messages.length).toBe(1);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/me-messages.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/me-messages.js
new file mode 100644
index 0000000..aed3bf9
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/me-messages.js
@@ -0,0 +1,56 @@
+/*global mock, converse */
+
+const { u, sizzle, $msg } = converse.env;
+
+describe("A Message", function () {
+
+ it("supports the /me command", mock.initConverse([], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
+ await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
+ await mock.openControlBox(_converse);
+ expect(_converse.chatboxes.length).toEqual(1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ let message = '/me is tired';
+ const msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t(message).up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+
+ await _converse.handleMessageStanza(msg);
+ const view = _converse.chatboxviews.get(sender_jid);
+ await u.waitUntil(() => view.querySelector('.chat-msg__text'));
+ expect(view.querySelectorAll('.chat-msg--action').length).toBe(1);
+ await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.trim() === 'is tired');
+ expect(view.querySelector('.chat-msg__author').textContent.includes('**Mercutio')).toBeTruthy();
+
+ message = '/me is as well';
+ await mock.sendMessage(view, message);
+ expect(view.querySelectorAll('.chat-msg--action').length).toBe(2);
+ await u.waitUntil(() => sizzle('.chat-msg__author:last', view).pop().textContent.trim() === '**Romeo');
+ const last_el = sizzle('.chat-msg__text:last', view).pop();
+ await u.waitUntil(() => last_el.textContent === 'is as well');
+ expect(u.hasClass('chat-msg--followup', last_el)).toBe(false);
+
+ // Check that /me messages after a normal message don't
+ // get the 'chat-msg--followup' class.
+ message = 'This a normal message';
+ await mock.sendMessage(view, message);
+ const msg_txt_sel = 'converse-chat-message:last-child .chat-msg__text';
+ await u.waitUntil(() => view.querySelector(msg_txt_sel).textContent.trim() === message);
+ let el = view.querySelector('converse-chat-message:last-child .chat-msg__body');
+ expect(u.hasClass('chat-msg--followup', el)).toBeFalsy();
+
+ message = '/me wrote a 3rd person message';
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelector(msg_txt_sel).textContent.trim() === message.replace('/me ', ''));
+ el = view.querySelector('converse-chat-message:last-child .chat-msg__body');
+ expect(view.querySelectorAll('.chat-msg--action').length).toBe(3);
+
+ expect(sizzle('.chat-msg__text:last', view).pop().textContent).toBe('wrote a 3rd person message');
+ expect(u.isVisible(sizzle('.chat-msg__author:last', view).pop())).toBeTruthy();
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-audio.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-audio.js
new file mode 100644
index 0000000..2d4dbf3
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-audio.js
@@ -0,0 +1,24 @@
+/*global mock, converse */
+
+const { sizzle, u } = converse.env;
+
+describe("A Chat Message", function () {
+
+ it("will render audio files from their URLs",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ const base_url = 'https://conversejs.org';
+ const message = base_url+"/logo/audio.mp3";
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content audio').length, 1000)
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '').replace(/(\r\n|\n|\r)/gm, "").trim()).toEqual(
+ `<audio controls="" src="${message}"></audio>`+
+ `<a target="_blank" rel="noopener" href="${message}">${message}</a>`);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-gifs.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-gifs.js
new file mode 100644
index 0000000..5552dc5
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-gifs.js
@@ -0,0 +1,23 @@
+/*global mock, converse */
+
+const { sizzle, u } = converse.env;
+
+describe("A Chat Message", function () {
+
+ it("will render gifs from their URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ const gif_url = 'https://media.giphy.com/media/Byana3FscAMGQ/giphy.gif';
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, gif_url);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content canvas').length);
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+ const html = `<converse-gif autoplay="" noloop="" fallback="empty" src="${gif_url}">`+
+ `<canvas class="gif-canvas"><img class="gif" src="${gif_url}"></canvas></converse-gif>`+
+ `<a target="_blank" rel="noopener" href="${gif_url}">${gif_url}</a>`;
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '').trim() === html, 1000);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-images.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-images.js
new file mode 100644
index 0000000..517fd12
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-images.js
@@ -0,0 +1,239 @@
+/*global mock, converse */
+
+const { sizzle, u } = converse.env;
+
+describe("A Chat Message", function () {
+
+ it("will render images from their URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ const base_url = 'https://conversejs.org';
+ let message = base_url+"/logo/conversejs-filled.svg";
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length, 1000)
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
+ `<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
+ `<img class="chat-image img-thumbnail" loading="lazy" src="https://conversejs.org/logo/conversejs-filled.svg">`+
+ `</a>`);
+
+ message += "?param1=val1&param2=val2";
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length === 2, 1000);
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
+ `<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg?param1=val1&amp;param2=val2">`+
+ `<img class="chat-image img-thumbnail" loading="lazy" src="${message.replace(/&/g, '&amp;')}">`+
+ `</a>`);
+
+ // Test now with two images in one message
+ message += ' hello world '+base_url+"/logo/conversejs-filled.svg";
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length === 4, 1000);
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+ expect(msg.textContent.trim()).toEqual('hello world');
+ expect(msg.querySelectorAll('img.chat-image').length).toEqual(2);
+
+ // Configured image URLs are rendered
+ _converse.api.settings.set('image_urls_regex', /^https?:\/\/(?:www.)?(?:imgur\.com\/\w{7})\/?$/i);
+ message = 'https://imgur.com/oxymPax';
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length === 5, 1000);
+ expect(view.querySelectorAll('.chat-content .chat-image').length).toBe(5);
+
+ // Check that the Imgur URL gets a .png attached to make it render
+ await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-content .chat-image')).pop().src.endsWith('png'), 1000);
+ }));
+
+ it("will not render images if render_media is false",
+ mock.initConverse(['chatBoxesFetched'], {'render_media': false}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ const base_url = 'https://conversejs.org';
+ const message = base_url+"/logo/conversejs-filled.svg";
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ await mock.sendMessage(view, message);
+ const sel = '.chat-content .chat-msg:last .chat-msg__text';
+ await u.waitUntil(() => sizzle(sel).pop().innerHTML.replace(/<!-.*?->/g, '').trim() === message);
+ expect(true).toBe(true);
+ }));
+
+ it("will automatically render images from approved URLs only",
+ mock.initConverse(
+ ['chatBoxesFetched'], {'render_media': ['imgur.com']},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const base_url = 'https://conversejs.org';
+ let message = 'https://imgur.com/oxymPax.png';
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg').length === 1);
+
+ message = base_url+"/logo/conversejs-filled.svg";
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg').length === 2, 1000);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length === 1, 1000)
+ expect(view.querySelectorAll('.chat-content .chat-image').length).toBe(1);
+ }));
+
+ it("will automatically update its rendering of media and the message actions when settings change",
+ mock.initConverse(
+ ['chatBoxesFetched'], {'render_media': ['imgur.com']},
+ async function (_converse) {
+
+ const { api } = _converse;
+ await mock.waitForRoster(_converse, 'current');
+ const message = 'https://imgur.com/oxymPax.png';
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg').length === 1);
+
+ const actions_el = view.querySelector('converse-message-actions');
+ await u.waitUntil(() => actions_el.textContent.includes('Hide media'));
+
+ actions_el.querySelector('.chat-msg__action-hide-previews').click();
+ await u.waitUntil(() => !view.querySelector('converse-chat-message-body img'));
+ await u.waitUntil(() => actions_el.textContent.includes('Show media'));
+
+ actions_el.querySelector('.chat-msg__action-hide-previews').click();
+ await u.waitUntil(() => actions_el.textContent.includes('Hide media'));
+
+ api.settings.set('render_media', false);
+ await u.waitUntil(() => actions_el.textContent.includes('Show media'));
+ await u.waitUntil(() => !view.querySelector('converse-chat-message-body img'));
+
+ actions_el.querySelector('.chat-msg__action-hide-previews').click();
+ await u.waitUntil(() => actions_el.textContent.includes('Hide media'));
+
+ api.settings.set('render_media', ['imgur.com']);
+ await u.waitUntil(() => actions_el.textContent.includes('Hide media'));
+ await u.waitUntil(() => view.querySelector('converse-chat-message-body img'));
+
+ api.settings.set('render_media', ['conversejs.org']);
+ await u.waitUntil(() => actions_el.textContent.includes('Show media'));
+ await u.waitUntil(() => !view.querySelector('converse-chat-message-body img'));
+
+ api.settings.set('allowed_image_domains', ['conversejs.org']);
+ await u.waitUntil(() => !actions_el.textContent.includes('Show media'));
+ expect(actions_el.textContent.includes('Hide media')).toBe(false);
+
+ api.settings.set('render_media', ['imgur.com']);
+ return new Promise(resolve => setTimeout(() => {
+ expect(actions_el.textContent.includes('Hide media')).toBe(false);
+ expect(actions_el.textContent.includes('Show media')).toBe(false);
+ expect(view.querySelector('converse-chat-message-body img')).toBe(null);
+ resolve();
+ }, 500));
+ }));
+
+
+ it("will fall back to rendering images as URLs",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const base_url = 'https://conversejs.org';
+ const message = base_url+"/logo/non-existing.svg";
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length, 1000)
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '').trim() ==
+ `<a target="_blank" rel="noopener" href="https://conversejs.org/logo/non-existing.svg">https://conversejs.org/logo/non-existing.svg</a>`, 1000);
+ }));
+
+ it("will fall back to rendering URLs that match image_urls_regex as URLs",
+ mock.initConverse(
+ ['rosterGroupsFetched', 'chatBoxesFetched'], {
+ 'render_media': true,
+ 'image_urls_regex': /^https?:\/\/(www.)?(pbs\.twimg\.com\/)/i
+ },
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const message = "https://pbs.twimg.com/media/string?format=jpg&name=small";
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ await u.waitUntil(() => view.querySelector('.chat-content .chat-msg'), 1000);
+ const msg = view.querySelector('.chat-content .chat-msg .chat-msg__text');
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '').trim() ==
+ `<a target="_blank" rel="noopener" href="https://pbs.twimg.com/media/string?format=jpg&amp;name=small">https://pbs.twimg.com/media/string?format=jpg&amp;name=small</a>`, 1000);
+ }));
+
+ it("will respect a changed allowed_image_domains setting when re-rendered",
+ mock.initConverse(
+ ['chatBoxesFetched'], {'render_media': true},
+ async function (_converse) {
+
+ const { api } = _converse;
+ await mock.waitForRoster(_converse, 'current');
+ const message = 'https://imgur.com/oxymPax.png';
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('converse-chat-message-body .chat-image').length === 1);
+ expect(view.querySelector('.chat-msg__action-hide-previews')).not.toBe(null);
+
+ api.settings.set('allowed_image_domains', []);
+
+ await u.waitUntil(() => view.querySelector('converse-chat-message-body .chat-image') === null);
+ expect(view.querySelector('.chat-msg__action-hide-previews')).toBe(null);
+
+ api.settings.set('allowed_image_domains', null);
+ await u.waitUntil(() => view.querySelector('converse-chat-message-body .chat-image'));
+ expect(view.querySelector('.chat-msg__action-hide-previews')).not.toBe(null);
+ }));
+
+ it("will allow the user to toggle visibility of rendered images",
+ mock.initConverse(['chatBoxesFetched'], {'render_media': true}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ // let message = "https://i.imgur.com/Py9ifJE.mp4";
+ const base_url = 'https://conversejs.org';
+ const message = base_url+"/logo/conversejs-filled.svg";
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ await mock.sendMessage(view, message);
+
+ const sel = '.chat-content .chat-msg:last .chat-msg__text';
+ await u.waitUntil(() => sizzle(sel).pop().innerHTML.replace(/<!-.*?->/g, '').trim() === message);
+
+ const actions_el = view.querySelector('converse-message-actions');
+ await u.waitUntil(() => actions_el.textContent.includes('Hide media'));
+ await u.waitUntil(() => view.querySelector('converse-chat-message-body img'));
+
+ actions_el.querySelector('.chat-msg__action-hide-previews').click();
+ await u.waitUntil(() => actions_el.textContent.includes('Show media'));
+ await u.waitUntil(() => !view.querySelector('converse-chat-message-body img'));
+
+ expect(view.querySelector('converse-chat-message-body').innerHTML.replace(/<!-.*?->/g, '').trim())
+ .toBe(`<a target="_blank" rel="noopener" href="${message}">${message}</a>`)
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-videos.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-videos.js
new file mode 100644
index 0000000..dfa388e
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-videos.js
@@ -0,0 +1,98 @@
+/*global mock, converse */
+
+const { Strophe, sizzle, u } = converse.env;
+
+describe("A chat message containing video URLs", function () {
+
+ it("will render videos from their URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ // let message = "https://i.imgur.com/Py9ifJE.mp4";
+ const base_url = 'https://conversejs.org';
+ let message = base_url+"/logo/conversejs-filled.mp4";
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content video').length, 1000)
+ let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
+ `<video controls="" preload="metadata" src="${message}"></video>`+
+ `<a target="_blank" rel="noopener" href="${message}">${message}</a>`);
+
+ message += "?param1=val1&param2=val2";
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content video').length === 2, 1000);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
+ `<video controls="" preload="metadata" src="${Strophe.xmlescape(message)}"></video>`+
+ `<a target="_blank" rel="noopener" href="${Strophe.xmlescape(message)}">${Strophe.xmlescape(message)}</a>`);
+ }));
+
+ it("will not render videos if render_media is false",
+ mock.initConverse(['chatBoxesFetched'], {'render_media': false}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ // let message = "https://i.imgur.com/Py9ifJE.mp4";
+ const base_url = 'https://conversejs.org';
+ const message = base_url+"/logo/conversejs-filled.mp4";
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ await mock.sendMessage(view, message);
+ const sel = '.chat-content .chat-msg:last .chat-msg__text';
+ await u.waitUntil(() => sizzle(sel).pop().innerHTML.replace(/<!-.*?->/g, '').trim() === message);
+ expect(true).toBe(true);
+ }));
+
+ it("will allow rendering of videos from approved URLs only",
+ mock.initConverse(
+ ['chatBoxesFetched'], {'allowed_video_domains': ['conversejs.org']},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ let message = "https://i.imgur.com/Py9ifJE.mp4";
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg').length === 1);
+
+ const base_url = 'https://conversejs.org';
+ message = base_url+"/logo/conversejs-filled.mp4";
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content video').length, 1000)
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop();
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual(
+ `<video controls="" preload="metadata" src="${message}"></video>`+
+ `<a target="_blank" rel="noopener" href="${message}">${message}</a>`);
+ }));
+
+ it("will allow the user to toggle visibility of rendered videos",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ // let message = "https://i.imgur.com/Py9ifJE.mp4";
+ const base_url = 'https://conversejs.org';
+ const message = base_url+"/logo/conversejs-filled.mp4";
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ await mock.sendMessage(view, message);
+ const sel = '.chat-content .chat-msg:last .chat-msg__text';
+ await u.waitUntil(() => sizzle(sel).pop().innerHTML.replace(/<!-.*?->/g, '').trim() === message);
+
+ const actions_el = view.querySelector('converse-message-actions');
+ await u.waitUntil(() => actions_el.textContent.includes('Hide media'));
+ await u.waitUntil(() => view.querySelector('converse-chat-message-body video'));
+
+ actions_el.querySelector('.chat-msg__action-hide-previews').click();
+ await u.waitUntil(() => actions_el.textContent.includes('Show media'));
+ await u.waitUntil(() => !view.querySelector('converse-chat-message-body video'));
+
+ expect(view.querySelector('converse-chat-message-body').innerHTML.replace(/<!-.*?->/g, '').trim())
+ .toBe(`<a target="_blank" rel="noopener" href="${message}">${message}</a>`)
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/messages.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/messages.js
new file mode 100644
index 0000000..777494c
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/messages.js
@@ -0,0 +1,1331 @@
+/*global mock, converse */
+
+const { Promise, Strophe, $msg, dayjs, sizzle, u } = converse.env;
+
+
+describe("A Chat Message", function () {
+
+ it("will be demarcated if it's the first newly received message",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ await _converse.handleMessageStanza(mock.createChatMessage(_converse, contact_jid, 'This message will be read'));
+ await u.waitUntil(() => view.querySelector('converse-chat-message .chat-msg__text')?.textContent === 'This message will be read');
+ expect(view.model.get('num_unread')).toBe(0);
+
+ _converse.windowState = 'hidden';
+ await _converse.handleMessageStanza(mock.createChatMessage(_converse, contact_jid, 'This message will be new'));
+
+ await u.waitUntil(() => view.model.messages.length);
+ expect(view.model.get('num_unread')).toBe(1);
+ expect(view.model.get('first_unread_id')).toBe(view.model.messages.last().get('id'));
+
+ await u.waitUntil(() => view.querySelectorAll('converse-chat-message').length === 2);
+ await u.waitUntil(() => view.querySelector('converse-chat-message:last-child .chat-msg__text')?.textContent === 'This message will be new');
+ const last_msg_el = view.querySelector('converse-chat-message:last-child');
+ expect(last_msg_el.firstElementChild?.textContent).toBe('New messages');
+ }));
+
+
+ it("is rejected if it's an unencapsulated forwarded message",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 2);
+ 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 mock.openChatBoxFor(_converse, contact_jid);
+ 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>
+ <forwarded xmlns='urn:xmpp:forward:0'>
+ <delay xmlns='urn:xmpp:delay' stamp='2019-07-10T23:08:25Z'/>
+ <message from='${forwarded_contact_jid}'
+ id='0202197'
+ to='${_converse.bare_jid}'
+ type='chat'
+ xmlns='jabber:client'>
+ <body>Yet I should kill thee with much cherishing.</body>
+ <mood xmlns='http://jabber.org/protocol/mood'>
+ <amorous/>
+ </mood>
+ </message>
+ </forwarded>
+ </message>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(received_stanza));
+ const sent_stanzas = _converse.connection.sent_stanzas;
+ const sent_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('error')).pop());
+ expect(Strophe.serialize(sent_stanza)).toBe(
+ `<message id="${received_stanza.getAttribute('id')}" to="${contact_jid}" type="error" xmlns="jabber:client">`+
+ '<error type="cancel">'+
+ '<not-allowed xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>'+
+ '<text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">'+
+ 'Forwarded messages not part of an encapsulating protocol are not supported</text>'+
+ '</error>'+
+ '</message>');
+ models = await _converse.api.chats.get();
+ expect(models.length).toBe(1);
+ }));
+
+ it("can be received out of order, and will still be displayed in the right order",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const { api } = _converse;
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length)
+ api.settings.set('filter_by_resource', true);
+
+ let msg = $msg({
+ 'xmlns': 'jabber:client',
+ 'id': _converse.connection.getUniqueId(),
+ 'to': _converse.bare_jid,
+ 'from': sender_jid,
+ 'type': 'chat'})
+ .c('body').t("message").up()
+ .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T13:08:25Z'})
+ .tree();
+ await _converse.handleMessageStanza(msg);
+ const view = _converse.chatboxviews.get(sender_jid);
+
+ msg = $msg({
+ 'xmlns': 'jabber:client',
+ 'id': _converse.connection.getUniqueId(),
+ 'to': _converse.bare_jid,
+ 'from': sender_jid,
+ 'type': 'chat'})
+ .c('body').t("Older message").up()
+ .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2017-12-31T22:08:25Z'})
+ .tree();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
+
+ msg = $msg({
+ 'xmlns': 'jabber:client',
+ 'id': _converse.connection.getUniqueId(),
+ 'to': _converse.bare_jid,
+ 'from': sender_jid,
+ 'type': 'chat'})
+ .c('body').t("Inbetween message").up()
+ .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'})
+ .tree();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 3);
+
+ msg = $msg({
+ 'xmlns': 'jabber:client',
+ 'id': _converse.connection.getUniqueId(),
+ 'to': _converse.bare_jid,
+ 'from': sender_jid,
+ 'type': 'chat'})
+ .c('body').t("another inbetween message").up()
+ .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'})
+ .tree();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 4);
+
+ msg = $msg({
+ 'xmlns': 'jabber:client',
+ 'id': _converse.connection.getUniqueId(),
+ 'to': _converse.bare_jid,
+ 'from': sender_jid,
+ 'type': 'chat'})
+ .c('body').t("An earlier message on the next day").up()
+ .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T12:18:23Z'})
+ .tree();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 5);
+
+ msg = $msg({
+ 'xmlns': 'jabber:client',
+ 'id': _converse.connection.getUniqueId(),
+ 'to': _converse.bare_jid,
+ 'from': sender_jid,
+ 'type': 'chat'})
+ .c('body').t("newer message from the next day").up()
+ .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T22:28:23Z'})
+ .tree();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 6);
+
+ // Insert <composing> message, to also check that
+ // text messages are inserted correctly with
+ // temporary chat events in the chat contents.
+ msg = $msg({
+ 'id': _converse.connection.getUniqueId(),
+ 'to': _converse.bare_jid,
+ 'xmlns': 'jabber:client',
+ 'from': sender_jid,
+ 'type': 'chat'})
+ .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
+ .tree();
+ _converse.handleMessageStanza(msg);
+ const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent);
+ expect(csntext.trim()).toEqual('Mercutio is typing');
+
+ msg = $msg({
+ 'id': _converse.connection.getUniqueId(),
+ 'to': _converse.bare_jid,
+ 'xmlns': 'jabber:client',
+ 'from': sender_jid,
+ 'type': 'chat'})
+ .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
+ .c('body').t("latest message")
+ .tree();
+
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 7);
+
+ expect(view.querySelectorAll('.date-separator').length).toEqual(4);
+
+ let day = sizzle('.date-separator:first', view).pop();
+ expect(day.getAttribute('data-isodate')).toEqual(dayjs('2017-12-31T00:00:00').toISOString());
+
+ let time = sizzle('time:first', view).pop();
+ expect(time.textContent).toEqual('Sunday Dec 31st 2017')
+
+ day = sizzle('.date-separator:first', view).pop();
+ expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('Older message');
+
+ let el = sizzle('.chat-msg:first', view).pop().querySelector('.chat-msg__text')
+ expect(u.hasClass('chat-msg--followup', el)).toBe(false);
+ expect(el.textContent).toEqual('Older message');
+
+ time = sizzle('time.separator-text:eq(1)', view).pop();
+ expect(time.textContent).toEqual("Monday Jan 1st 2018");
+
+ day = sizzle('.date-separator:eq(1)', view).pop();
+ expect(day.getAttribute('data-isodate')).toEqual(dayjs('2018-01-01T00:00:00').toISOString());
+ expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('Inbetween message');
+
+ el = sizzle('.chat-msg:eq(1)', view).pop();
+ expect(el.querySelector('.chat-msg__text').textContent).toEqual('Inbetween message');
+ expect(el.parentElement.nextElementSibling.querySelector('.chat-msg__text').textContent).toEqual('another inbetween message');
+ el = sizzle('.chat-msg:eq(2)', view).pop();
+ expect(el.querySelector('.chat-msg__text').textContent)
+ .toEqual('another inbetween message');
+ expect(u.hasClass('chat-msg--followup', el)).toBe(true);
+
+ time = sizzle('time.separator-text:nth(2)', view).pop();
+ expect(time.textContent).toEqual("Tuesday Jan 2nd 2018");
+
+ day = sizzle('.date-separator:nth(2)', view).pop();
+ expect(day.getAttribute('data-isodate')).toEqual(dayjs('2018-01-02T00:00:00').toISOString());
+ expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('An earlier message on the next day');
+
+ el = sizzle('.chat-msg:eq(3)', view).pop();
+ expect(el.querySelector('.chat-msg__text').textContent).toEqual('An earlier message on the next day');
+ expect(u.hasClass('chat-msg--followup', el)).toBe(false);
+
+ el = sizzle('.chat-msg:eq(4)', view).pop();
+ expect(el.querySelector('.chat-msg__text').textContent).toEqual('message');
+ expect(el.parentElement.nextElementSibling.querySelector('.chat-msg__text').textContent).toEqual('newer message from the next day');
+ expect(u.hasClass('chat-msg--followup', el)).toBe(false);
+
+ day = sizzle('.date-separator:last', view).pop();
+ expect(day.getAttribute('data-isodate')).toEqual(dayjs().startOf('day').toISOString());
+ expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('latest message');
+ expect(u.hasClass('chat-msg--followup', el)).toBe(false);
+ }));
+
+ it("is ignored if it's a malformed headline message",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ // Ideally we wouldn't have to filter out headline
+ // messages, but Prosody gives them the wrong 'type' :(
+ spyOn(converse.env.log, 'info');
+ spyOn(_converse.api.chatboxes, 'get');
+ const msg = $msg({
+ from: 'montague.lit',
+ to: _converse.bare_jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t("This headline message will not be shown").tree();
+ await _converse.handleMessageStanza(msg);
+ expect(converse.env.log.info).toHaveBeenCalledWith(
+ "handleMessageStanza: Ignoring incoming server message from JID: montague.lit"
+ );
+ expect(_converse.api.chatboxes.get).not.toHaveBeenCalled();
+ }));
+
+ it("will render Openstreetmap-URL from geo-URI",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const message = "geo:37.786971,-122.399677";
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg').length, 1000);
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ await u.waitUntil(() => msg.innerHTML.replace(/\<!-.*?-\>/g, '') ===
+ '<a target="_blank" rel="noopener" href="https://www.openstreetmap.org/?mlat=37.786971&amp;'+
+ 'mlon=-122.399677#map=18/37.786971/-122.399677">https://www.openstreetmap.org/?mlat=37.786971&amp;mlon=-122.399677#map=18/37.786971/-122.399677</a>');
+ }));
+
+ it("can be a carbon message, as defined in XEP-0280",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const include_nick = false;
+ await mock.waitForRoster(_converse, 'current', 2, include_nick);
+ await mock.openControlBox(_converse);
+
+ // Send a message from a different resource
+ const msgtext = 'This is a carbon message';
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg = $msg({
+ 'from': _converse.bare_jid,
+ 'id': u.getUniqueId(),
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'xmlns': 'jabber:client'
+ }).c('received', {'xmlns': 'urn:xmpp:carbons:2'})
+ .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+ .c('message', {
+ 'xmlns': 'jabber:client',
+ 'from': sender_jid,
+ 'to': _converse.bare_jid+'/another-resource',
+ 'type': 'chat'
+ }).c('body').t(msgtext).tree();
+
+ await _converse.handleMessageStanza(msg);
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ const view = _converse.chatboxviews.get(sender_jid);
+
+ expect(chatbox).toBeDefined();
+ expect(view).toBeDefined();
+ // Check that the message was received and check the message parameters
+ await u.waitUntil(() => chatbox.messages.length);
+ const msg_obj = chatbox.messages.models[0];
+ expect(msg_obj.get('message')).toEqual(msgtext);
+ expect(msg_obj.get('fullname')).toBeUndefined();
+ expect(msg_obj.get('nickname')).toBe(null);
+ expect(msg_obj.get('sender')).toEqual('them');
+ expect(msg_obj.get('is_delayed')).toEqual(false);
+ // Now check that the message appears inside the chatbox in the DOM
+ await u.waitUntil(() => view.querySelector('.chat-msg .chat-msg__text'));
+
+ expect(view.querySelector('.chat-msg .chat-msg__text').textContent).toEqual(msgtext);
+ expect(view.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
+ await u.waitUntil(() => chatbox.vcard.get('fullname') === 'Juliet Capulet')
+ expect(view.querySelector('span.chat-msg__author').textContent.trim()).toBe('Juliet Capulet');
+ }));
+
+ it("can be a carbon message that this user sent from a different client, as defined in XEP-0280",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']);
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ // Send a message from a different resource
+ const msgtext = 'This is a sent carbon message';
+ const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg = $msg({
+ 'from': _converse.bare_jid,
+ 'id': u.getUniqueId(),
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'xmlns': 'jabber:client'
+ }).c('sent', {'xmlns': 'urn:xmpp:carbons:2'})
+ .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+ .c('message', {
+ 'xmlns': 'jabber:client',
+ 'from': _converse.bare_jid+'/another-resource',
+ 'to': recipient_jid,
+ 'type': 'chat'
+ }).c('body').t(msgtext).tree();
+
+ 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.chatboxviews.get(recipient_jid);
+ expect(chatbox).toBeDefined();
+ expect(view).toBeDefined();
+
+ // Check that the message was received and check the message parameters
+ expect(chatbox.messages.length).toEqual(1);
+ const msg_obj = chatbox.messages.models[0];
+ expect(msg_obj.get('message')).toEqual(msgtext);
+ expect(msg_obj.get('fullname')).toEqual(_converse.xmppstatus.get('fullname'));
+ expect(msg_obj.get('sender')).toEqual('me');
+ expect(msg_obj.get('is_delayed')).toEqual(false);
+ // Now check that the message appears inside the chatbox in the DOM
+ const msg_el = await u.waitUntil(() => view.querySelector('.chat-content .chat-msg .chat-msg__text'));
+ expect(msg_el.textContent).toEqual(msgtext);
+ }));
+
+ it("will be discarded if it's a malicious message meant to look like a carbon copy",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ /* <message from="mallory@evil.example" to="b@xmpp.example">
+ * <received xmlns='urn:xmpp:carbons:2'>
+ * <forwarded xmlns='urn:xmpp:forward:0'>
+ * <message from="alice@xmpp.example" to="bob@xmpp.example/client1">
+ * <body>Please come to Creepy Valley tonight, alone!</body>
+ * </message>
+ * </forwarded>
+ * </received>
+ * </message>
+ */
+ const msgtext = 'Please come to Creepy Valley tonight, alone!';
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const impersonated_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg = $msg({
+ 'from': sender_jid,
+ 'id': u.getUniqueId(),
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'xmlns': 'jabber:client'
+ }).c('received', {'xmlns': 'urn:xmpp:carbons:2'})
+ .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+ .c('message', {
+ 'xmlns': 'jabber:client',
+ 'from': impersonated_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat'
+ }).c('body').t(msgtext).tree();
+ await _converse.handleMessageStanza(msg);
+
+ // Check that chatbox for impersonated user is not created.
+ let chatbox = await _converse.api.chats.get(impersonated_jid);
+ 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).toBe(null);
+ }));
+
+ it("will indicate when it has a time difference of more than a day between it and its predecessor",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ const include_nick = false;
+ await mock.waitForRoster(_converse, 'current', 2, include_nick);
+ await mock.openControlBox(_converse);
+ spyOn(_converse.api, "trigger").and.callThrough();
+ const contact_name = mock.cur_names[1];
+ const contact_jid = contact_name.replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length);
+ await mock.openChatBoxFor(_converse, contact_jid);
+
+ const one_day_ago = dayjs().subtract(1, 'day');
+ const chatbox = _converse.chatboxes.get(contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ let message = 'This is a day old message';
+ let msg = $msg({
+ from: contact_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: one_day_ago.toDate().getTime()
+ }).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.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+
+ expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+ expect(chatbox.messages.length).toEqual(1);
+ let msg_obj = chatbox.messages.models[0];
+ expect(msg_obj.get('message')).toEqual(message);
+ expect(msg_obj.get('fullname')).toBeUndefined();
+ expect(msg_obj.get('nickname')).toBe(null);
+ expect(msg_obj.get('sender')).toEqual('them');
+ expect(msg_obj.get('is_delayed')).toEqual(true);
+ await u.waitUntil(() => chatbox.vcard.get('fullname') === 'Juliet Capulet')
+ expect(view.querySelector('.chat-msg .chat-msg__text').textContent).toEqual(message);
+ expect(view.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
+ expect(view.querySelector('span.chat-msg__author').textContent.trim()).toBe('Juliet Capulet');
+
+ expect(view.querySelectorAll('.date-separator').length).toEqual(1);
+ let day = view.querySelector('.date-separator');
+ expect(day.getAttribute('class')).toEqual('message date-separator');
+ expect(day.getAttribute('data-isodate')).toEqual(dayjs(one_day_ago.startOf('day')).toISOString());
+
+ let time = view.querySelector('time.separator-text');
+ expect(time.textContent).toEqual(dayjs(one_day_ago.startOf('day')).format("dddd MMM Do YYYY"));
+
+ message = 'This is a current message';
+ msg = $msg({
+ from: contact_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: new Date().getTime()
+ }).c('body').t(message).up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2);
+
+ expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+ // Check that there is a <time> element, with the required props.
+ expect(view.querySelectorAll('time.separator-text').length).toEqual(2); // There are now two time elements
+
+ const message_date = new Date();
+ day = sizzle('.date-separator:last', view);
+ expect(day.length).toEqual(1);
+ expect(day[0].getAttribute('class')).toEqual('message date-separator');
+ expect(day[0].getAttribute('data-isodate')).toEqual(dayjs(message_date).startOf('day').toISOString());
+
+ time = sizzle('time.separator-text:last', view).pop();
+ expect(time.textContent).toEqual(dayjs(message_date).startOf('day').format("dddd MMM Do YYYY"));
+
+ // Normal checks for the 2nd message
+ expect(chatbox.messages.length).toEqual(2);
+ msg_obj = chatbox.messages.models[1];
+ expect(msg_obj.get('message')).toEqual(message);
+ expect(msg_obj.get('fullname')).toBeUndefined();
+ expect(msg_obj.get('sender')).toEqual('them');
+ expect(msg_obj.get('is_delayed')).toEqual(false);
+ const msg_txt = sizzle('.chat-msg:last .chat-msg__text', view).pop().textContent;
+ expect(msg_txt).toEqual(message);
+
+ expect(view.querySelector('converse-chat-message:last-child .chat-msg__text').textContent).toEqual(message);
+ expect(view.querySelector('converse-chat-message:last-child .chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
+ expect(view.querySelector('converse-chat-message:last-child .chat-msg__author').textContent.trim()).toBe('Juliet Capulet');
+ }));
+
+ it("is sanitized to prevent Javascript injection attacks",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+ const message = '<p>This message contains <em>some</em> <b>markup</b></p>';
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual('&lt;p&gt;This message contains &lt;em&gt;some&lt;/em&gt; &lt;b&gt;markup&lt;/b&gt;&lt;/p&gt;');
+ }));
+
+ it("can contain hyperlinks, which will be clickable",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+ const message = 'This message contains a hyperlink: www.opkode.com';
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'This message contains a hyperlink: <a target="_blank" rel="noopener" href="http://www.opkode.com">www.opkode.com</a>');
+ }));
+
+ it("will remove url query parameters from hyperlinks as set",
+ mock.initConverse(['chatBoxesFetched'], {'filter_url_query_params': ['utm_medium', 'utm_content', 's']},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ let message = 'This message contains a hyperlink with forbidden query params: https://www.opkode.com/?id=0&utm_content=1&utm_medium=2&s=1';
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'This message contains a hyperlink with forbidden query params: <a target="_blank" rel="noopener" href="https://www.opkode.com/?id=0">https://www.opkode.com/?id=0</a>');
+
+ // Test assigning a string to filter_url_query_params
+ _converse.api.settings.set('filter_url_query_params', 'utm_medium');
+ message = 'Another message with a hyperlink with forbidden query params: https://www.opkode.com/?id=0&utm_content=1&utm_medium=2&s=1';
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'Another message with a hyperlink with forbidden query params: '+
+ '<a target="_blank" rel="noopener" href="https://www.opkode.com/?id=0&amp;utm_content=1&amp;s=1">https://www.opkode.com/?id=0&amp;utm_content=1&amp;s=1</a>');
+ }));
+
+ it("properly renders URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const message = 'https://mov.im/?node/pubsub.movim.eu/Dino/urn-uuid-979bd24f-0bf3-5099-9fa7-510b9ce9a884';
+ await mock.sendMessage(view, message);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ const anchor = await u.waitUntil(() => msg.querySelector('a'));
+ expect(anchor.innerHTML.replace(/<!-.*?->/g, '')).toBe(message);
+ expect(anchor.getAttribute('href')).toBe(message);
+ }));
+
+ it("will render newlines", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ await mock.waitForRoster(_converse, 'current');
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const view = await mock.openChatBoxFor(_converse, contact_jid);
+ let stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>Hey\nHave you heard the news?</body>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ expect(view.querySelector('.chat-msg__text').innerHTML.replace(/<!-.*?->/g, '')).toBe('Hey\nHave you heard the news?');
+ stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>Hey\n\n\nHave you heard the news?</body>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+ const text = view.querySelector('converse-chat-message:last-child .chat-msg__text').innerHTML.replace(/<!-.*?->/g, '');
+ expect(text).toBe('Hey\n\u200B\nHave you heard the news?');
+ stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>Hey\nHave you heard\nthe news?</body>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
+ expect(view.querySelector('converse-chat-message:last-child .chat-msg__text').innerHTML.replace(/<!-.*?->/g, '')).toBe('Hey\nHave you heard\nthe news?');
+
+ stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>Hey\nHave you heard\n\n\nthe news?\nhttps://conversejs.org</body>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
+ await u.waitUntil(() => {
+ const text = view.querySelector('converse-chat-message:last-child .chat-msg__text').innerHTML.replace(/<!-.*?->/g, '');
+ return text === 'Hey\nHave you heard\n\u200B\nthe news?\n<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>';
+ });
+ }));
+
+ it("will render the message time as configured",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ const { api } = _converse;
+ await mock.waitForRoster(_converse, 'current');
+ api.settings.set('time_format', 'hh:mm');
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+ const message = 'This message is sent from this chatbox';
+ await mock.sendMessage(view, message);
+
+ const chatbox = await _converse.api.chats.get(contact_jid);
+ expect(chatbox.messages.models.length, 1);
+ const msg_object = chatbox.messages.models[0];
+
+ const msg_author = view.querySelector('.chat-content .chat-msg:last-child .chat-msg__author');
+ expect(msg_author.textContent.trim()).toBe('Romeo');
+
+ const msg_time = view.querySelector('.chat-content .chat-msg:last-child .chat-msg__time');
+ const time = dayjs(msg_object.get('time')).format(api.settings.get('time_format'));
+ expect(msg_time.textContent).toBe(time);
+ }));
+
+ it("will be correctly identified and rendered as a followup message",
+ mock.initConverse(
+ [], {'debounced_content_rendering': false},
+ async function (_converse) {
+
+ const { api } = _converse;
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const base_time = new Date();
+ const ONE_MINUTE_LATER = 60000;
+
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 300);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ api.settings.set('filter_by_resource', true);
+
+ jasmine.clock().install();
+ jasmine.clock().mockDate(base_time);
+
+ _converse.handleMessageStanza($msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId()
+ }).c('body').t('A message').up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
+ const view = _converse.chatboxviews.get(sender_jid);
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ jasmine.clock().tick(3*ONE_MINUTE_LATER);
+ _converse.handleMessageStanza($msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId()
+ }).c('body').t("Another message 3 minutes later").up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ jasmine.clock().tick(11*ONE_MINUTE_LATER);
+ _converse.handleMessageStanza($msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId()
+ }).c('body').t("Another message 14 minutes since we started").up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ jasmine.clock().tick(1*ONE_MINUTE_LATER);
+
+ _converse.handleMessageStanza($msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': _converse.connection.getUniqueId()
+ }).c('body').t("Another message 1 minute and 1 second since the previous one").up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ jasmine.clock().tick(1*ONE_MINUTE_LATER);
+ await mock.sendMessage(view, "Another message within 10 minutes, but from a different person");
+
+ await u.waitUntil(() => view.querySelectorAll('.message').length === 6);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(5);
+
+ const nth_child = (n) => `converse-chat-message:nth-child(${n}) .chat-msg`;
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(2)))).toBe(false);
+ expect(view.querySelector(`${nth_child(2)} .chat-msg__text`).textContent).toBe("A message");
+
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(3)))).toBe(true);
+ expect(view.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe(
+ "Another message 3 minutes later");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(4)))).toBe(false);
+ expect(view.querySelector(`${nth_child(4)} .chat-msg__text`).textContent).toBe(
+ "Another message 14 minutes since we started");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(5)))).toBe(true);
+ expect(view.querySelector(`${nth_child(5)} .chat-msg__text`).textContent).toBe(
+ "Another message 1 minute and 1 second since the previous one");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(6)))).toBe(false);
+ expect(view.querySelector(`${nth_child(6)} .chat-msg__text`).textContent).toBe(
+ "Another message within 10 minutes, but from a different person");
+
+ // Let's add a delayed, inbetween message
+ _converse.handleMessageStanza(
+ $msg({
+ 'xmlns': 'jabber:client',
+ 'id': _converse.connection.getUniqueId(),
+ 'to': _converse.bare_jid,
+ 'from': sender_jid,
+ 'type': 'chat'
+ }).c('body').t("A delayed message, sent 5 minutes since we started").up()
+ .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp': dayjs(base_time).add(5, 'minutes').toISOString()})
+ .tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ expect(view.querySelectorAll('.message').length).toBe(7);
+ expect(view.querySelectorAll('.chat-msg').length).toBe(6);
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(2)))).toBe(false);
+ expect(view.querySelector(`${nth_child(2)} .chat-msg__text`).textContent).toBe("A message");
+
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(3)))).toBe(true);
+ expect(view.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe(
+ "Another message 3 minutes later");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(4)))).toBe(true);
+ expect(view.querySelector(`${nth_child(4)} .chat-msg__text`).textContent).toBe(
+ "A delayed message, sent 5 minutes since we started");
+
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(5)))).toBe(false);
+ expect(view.querySelector(`${nth_child(5)} .chat-msg__text`).textContent).toBe(
+ "Another message 14 minutes since we started");
+
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(6)))).toBe(true);
+ expect(view.querySelector(`${nth_child(6)} .chat-msg__text`).textContent).toBe(
+ "Another message 1 minute and 1 second since the previous one");
+
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(7)))).toBe(false);
+ expect(view.querySelector(`${nth_child(7)} .chat-msg__text`).textContent).toBe(
+ "Another message within 10 minutes, but from a different person");
+
+ _converse.handleMessageStanza(
+ $msg({
+ 'xmlns': 'jabber:client',
+ 'id': _converse.connection.getUniqueId(),
+ 'to': sender_jid,
+ 'from': _converse.bare_jid+"/some-other-resource",
+ 'type': 'chat'})
+ .c('body').t("A carbon message 4 minutes later").up()
+ .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':dayjs(base_time).add(4, 'minutes').toISOString()})
+ .tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ expect(view.querySelectorAll('.chat-msg').length).toBe(7);
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(2)))).toBe(false);
+ expect(view.querySelector(`${nth_child(2)} .chat-msg__text`).textContent).toBe("A message");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(3)))).toBe(true);
+ expect(view.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe(
+ "Another message 3 minutes later");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(4)))).toBe(false);
+ expect(view.querySelector(`${nth_child(4)} .chat-msg__text`).textContent).toBe(
+ "A carbon message 4 minutes later");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(5)))).toBe(true);
+ expect(view.querySelector(`${nth_child(5)} .chat-msg__text`).textContent).toBe(
+ "A delayed message, sent 5 minutes since we started");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(6)))).toBe(false);
+ expect(view.querySelector(`${nth_child(6)} .chat-msg__text`).textContent).toBe(
+ "Another message 14 minutes since we started");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(7)))).toBe(true);
+ expect(view.querySelector(`${nth_child(7)} .chat-msg__text`).textContent).toBe(
+ "Another message 1 minute and 1 second since the previous one");
+ expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(8)))).toBe(false);
+ expect(view.querySelector(`${nth_child(8)} .chat-msg__text`).textContent).toBe(
+ "Another message within 10 minutes, but from a different person");
+
+ jasmine.clock().uninstall();
+
+ }));
+
+
+ describe("when sent", function () {
+
+ it("will appear inside the chatbox it was sent from",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+ spyOn(_converse.api, "trigger").and.callThrough();
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+ const message = 'This message is sent from this chatbox';
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ await mock.sendMessage(view, message);
+ expect(view.model.sendMessage).toHaveBeenCalled();
+ expect(view.model.messages.length, 2);
+ expect(sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop().textContent).toEqual(message);
+ }));
+
+
+ it("will be trimmed of leading and trailing whitespace",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+ const message = ' \nThis message is sent from this chatbox \n \n';
+ await mock.sendMessage(view, message);
+ expect(view.model.messages.at(0).get('message')).toEqual(message.trim());
+ const message_el = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(message_el.textContent).toEqual(message.trim());
+ }));
+ });
+
+
+ describe("when received from someone else", function () {
+
+ it("will open a chatbox and be displayed inside it",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const include_nick = false;
+ await mock.waitForRoster(_converse, 'current', 1, include_nick);
+ await mock.openControlBox(_converse);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 300);
+ spyOn(_converse.api, "trigger").and.callThrough();
+ const message = 'This is a received message';
+ 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();
+ await _converse.handleMessageStanza(
+ $msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId()
+ }).c('body').t(message).up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
+ );
+ const chatbox = await _converse.chatboxes.get(sender_jid);
+ expect(chatbox).toBeDefined();
+ const view = _converse.chatboxviews.get(sender_jid);
+ expect(view).toBeDefined();
+
+ expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+ // Check that the message was received and check the message parameters
+ await u.waitUntil(() => chatbox.messages.length);
+ expect(chatbox.messages.length).toEqual(1);
+ const msg_obj = chatbox.messages.models[0];
+ expect(msg_obj.get('message')).toEqual(message);
+ expect(msg_obj.get('fullname')).toBeUndefined();
+ expect(msg_obj.get('sender')).toEqual('them');
+ expect(msg_obj.get('is_delayed')).toEqual(false);
+ // Now check that the message appears inside the chatbox in the DOM
+ const mel = await u.waitUntil(() => view.querySelector('.chat-msg .chat-msg__text'));
+ expect(mel.textContent).toEqual(message);
+ expect(view.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(view.querySelector('span.chat-msg__author').textContent.trim()).toBe('Mercutio');
+ }));
+
+ it("will be trimmed of leading and trailing whitespace",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1, false);
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.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';
+ await _converse.handleMessageStanza(
+ $msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId()
+ }).c('body').t(message).up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
+ );
+ const view = _converse.chatboxviews.get(sender_jid);
+ await u.waitUntil(() => view.model.messages.length);
+ expect(view.model.messages.length).toEqual(1);
+ const msg_obj = view.model.messages.at(0);
+ expect(msg_obj.get('message')).toEqual(message.trim());
+ const mel = await u.waitUntil(() => view.querySelector('.chat-msg .chat-msg__text'));
+ expect(mel.textContent).toEqual(message.trim());
+ }));
+
+
+ describe("when a chatbox is opened for someone who is not in the roster", function () {
+
+ it("the VCard for that user is fetched and the chatbox updated with the results",
+ mock.initConverse([], {'allow_non_roster_messaging': true},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+ spyOn(_converse.api, "trigger").and.callThrough();
+
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ var vcard_fetched = false;
+ spyOn(_converse.api.vcard, "get").and.callFake(function () {
+ vcard_fetched = true;
+ return Promise.resolve({
+ 'fullname': mock.cur_names[0],
+ 'vcard_updated': (new Date()).toISOString(),
+ 'jid': sender_jid
+ });
+ });
+ const message = 'This is a received message from someone not on the roster';
+ const msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t(message).up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+
+ // We don't already have an open chatbox for this user
+ expect(_converse.chatboxes.get(sender_jid)).not.toBeDefined();
+
+ await _converse.handleMessageStanza(msg);
+ const view = await u.waitUntil(() => _converse.chatboxviews.get(sender_jid));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+ expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+
+ // Check that the chatbox and its view now exist
+ const chatbox = await _converse.api.chats.get(sender_jid);
+ expect(chatbox.get('fullname') === sender_jid);
+
+ await u.waitUntil(() => view.querySelector('.chat-msg__author').textContent.trim() === 'Mercutio');
+ let author_el = view.querySelector('.chat-msg__author');
+ expect(author_el.textContent.trim().includes('Mercutio')).toBeTruthy();
+ await u.waitUntil(() => vcard_fetched, 100);
+ expect(_converse.api.vcard.get).toHaveBeenCalled();
+ await u.waitUntil(() => chatbox.vcard.get('fullname') === mock.cur_names[0])
+ author_el = view.querySelector('.chat-msg__author');
+ expect(author_el.textContent.trim().includes('Mercutio')).toBeTruthy();
+ }));
+ });
+
+
+ describe("who is not on the roster", function () {
+
+ it("will open a chatbox and be displayed inside it if allow_non_roster_messaging is true",
+ mock.initConverse(
+ [], {'allow_non_roster_messaging': false},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 0);
+
+ const { api } = _converse;
+ spyOn(api, "trigger").and.callThrough();
+ const message = 'This is a received message from someone not on the roster';
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg = $msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t(message).up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+
+ // We don't already have an open chatbox for this user
+ expect(_converse.chatboxes.get(sender_jid)).not.toBeDefined();
+
+ let chatbox = await _converse.api.chats.get(sender_jid);
+ expect(chatbox).toBe(null);
+ await _converse.handleMessageStanza(msg);
+ let view = _converse.chatboxviews.get(sender_jid);
+ expect(view).not.toBeDefined();
+
+ api.settings.set('allow_non_roster_messaging', true);
+ await _converse.handleMessageStanza(msg);
+ view = _converse.chatboxviews.get(sender_jid);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+ expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object));
+ // Check that the chatbox and its view now exist
+ chatbox = await _converse.api.chats.get(sender_jid);
+ expect(chatbox).toBeDefined();
+ expect(view).toBeDefined();
+ // Check that the message was received and check the message parameters
+ expect(chatbox.messages.length).toEqual(1);
+ const msg_obj = chatbox.messages.models[0];
+ expect(msg_obj.get('message')).toEqual(message);
+ expect(msg_obj.get('fullname')).toEqual(undefined);
+ expect(msg_obj.get('sender')).toEqual('them');
+ expect(msg_obj.get('is_delayed')).toEqual(false);
+
+ await u.waitUntil(() => view.querySelector('.chat-msg__author').textContent.trim() === 'Mercutio');
+ // Now check that the message appears inside the chatbox in the DOM
+ expect(view.querySelector('.chat-msg .chat-msg__text').textContent).toEqual(message);
+ expect(view.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy();
+ expect(view.querySelector('span.chat-msg__author').textContent.trim()).toBe('Mercutio');
+ }));
+ });
+
+ describe("and for which then an error message is received from the server", function () {
+
+ it("will have the error message displayed after itself",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ await mock.openChatBoxFor(_converse, sender_jid);
+
+ // TODO: what could still be done for error
+ // messages... if the <error> element has type
+ // "cancel", then we know the messages wasn't sent,
+ // and can give the user a nicer indication of
+ // that.
+ /* <message from="scotty@enterprise.com/_converse.js-84843526"
+ * to="kirk@enterprise.com.com"
+ * type="chat"
+ * id="82bc02ce-9651-4336-baf0-fa04762ed8d2"
+ * xmlns="jabber:client">
+ * <body>yo</body>
+ * <active xmlns="http://jabber.org/protocol/chatstates"/>
+ * </message>
+ */
+ const error_txt = 'Server-to-server connection failed: Connecting failed: connection timeout';
+ let msg_text = 'This message will not be sent, due to an error';
+ const view = _converse.chatboxviews.get(sender_jid);
+ const message = await view.model.sendMessage({'body': msg_text});
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg').length);
+ let msg_txt = sizzle('.chat-msg:last .chat-msg__text', view).pop().textContent;
+ expect(msg_txt).toEqual(msg_text);
+
+ // We send another message, for which an error will
+ // not be received, to test that errors appear
+ // after the relevant message.
+ msg_text = 'This message will be sent, and also receive an error';
+ const second_message = await view.model.sendMessage({'body': msg_text});
+ await u.waitUntil(() => sizzle('.chat-msg .chat-msg__text', view).length === 2, 1000);
+ msg_txt = sizzle('.chat-msg:last .chat-msg__text', view).pop().textContent;
+ expect(msg_txt).toEqual(msg_text);
+
+ /* <message xmlns="jabber:client"
+ * to="scotty@enterprise.com/_converse.js-84843526"
+ * type="error"
+ * id="82bc02ce-9651-4336-baf0-fa04762ed8d2"
+ * from="kirk@enterprise.com.com">
+ * <error type="cancel">
+ * <remote-server-not-found xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+ * <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">Server-to-server connection failed: Connecting failed: connection timeout</text>
+ * </error>
+ * </message>
+ */
+ let stanza = $msg({
+ 'to': _converse.connection.jid,
+ 'type': 'error',
+ 'id': message.get('msgid'),
+ 'from': sender_jid
+ })
+ .c('error', {'type': 'cancel'})
+ .c('remote-server-not-found', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
+ .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
+ .t('Server-to-server connection failed: Connecting failed: connection timeout');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelector('.chat-msg__error').textContent.trim() === error_txt);
+
+ const other_error_txt = 'Server-to-server connection failed: Connecting failed: connection timeout';
+ stanza = $msg({
+ 'to': _converse.connection.jid,
+ 'type': 'error',
+ 'id': second_message.get('id'),
+ 'from': sender_jid
+ })
+ .c('error', {'type': 'cancel'})
+ .c('remote-server-not-found', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
+ .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
+ .t(other_error_txt);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() =>
+ view.querySelector('converse-chat-message:last-child .chat-msg__error').textContent.trim() === other_error_txt);
+
+ // We don't render duplicates
+ stanza = $msg({
+ 'to': _converse.connection.jid,
+ 'type':'error',
+ 'id': second_message.get('id'),
+ 'from': sender_jid
+ })
+ .c('error', {'type': 'cancel'})
+ .c('remote-server-not-found', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
+ .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
+ .t('Server-to-server connection failed: Connecting failed: connection timeout');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ expect(view.querySelectorAll('.chat-msg__error').length).toEqual(2);
+
+ msg_text = 'This message will be sent, and also receive an error';
+ const third_message = await view.model.sendMessage({'body': msg_text});
+ await u.waitUntil(() => sizzle('converse-chat-message:last-child .chat-msg__text', view).pop()?.textContent === msg_text);
+
+ // A different error message will however render
+ stanza = $msg({
+ 'to': _converse.connection.jid,
+ 'type':'error',
+ 'id': third_message.get('id'),
+ 'from': sender_jid
+ })
+ .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('Something else went wrong as well');
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.model.messages.length > 2);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__error').length === 3);
+
+ // Ensure messages with error are not editable or retractable
+ await u.waitUntil(() => !view.model.messages.models.reduce((acc, m) => acc || m.get('editable'), false), 1000);
+ view.querySelectorAll('.chat-msg').forEach(el => {
+ expect(el.querySelector('.chat-msg__action-edit')).toBe(null)
+ expect(el.querySelector('.chat-msg__action-retract')).toBe(null)
+ })
+ }));
+
+ it("will not show to the user an error message for a CSI message",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ // See #1317
+ // https://github.com/conversejs/converse.js/issues/1317
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = 'hello world'
+ const enter_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 13 // Enter
+ }
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown(enter_event);
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ const msg = $msg({
+ from: contact_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+ await _converse.handleMessageStanza(msg);
+
+ _converse.connection._dataRecv(mock.createRequest(u.toStanza(`
+ <message xml:lang="en" type="error" from="${contact_jid}">
+ <active xmlns="http://jabber.org/protocol/chatstates"/>
+ <no-store xmlns="urn:xmpp:hints"/>
+ <no-permanent-store xmlns="urn:xmpp:hints"/>
+ <error code="503" type="cancel">
+ <service-unavailable xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+ <text xml:lang="en" xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">User session not found</text></error>
+ </message>
+ `)));
+ return new Promise(resolve => setTimeout(() => {
+ expect(view.querySelector('.chat-msg__error').textContent).toBe('');
+ resolve();
+ }, 500));
+ }));
+
+ it("will have the error displayed below it",
+ mock.initConverse([], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
+ textarea.value = 'hello world'
+ const enter_event = {
+ 'target': textarea,
+ 'preventDefault': function preventDefault () {},
+ 'stopPropagation': function stopPropagation () {},
+ 'keyCode': 13 // Enter
+ }
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown(enter_event);
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ // Normally "modify" errors need to have their id set to the
+ // message that couldn't be sent. Not doing that here on purpose to
+ // check the case where it's not done.
+ // See issue #2683
+ const err_txt = `Your message to ${contact_jid} was not end-to-end encrypted. For security reasons, using one of the following E2EE schemes is *REQUIRED* for conversations on this server: pgp, omemo`;
+ const error = u.toStanza(`
+ <message xmlns="jabber:client" from="${contact_jid}" type="error" to="${_converse.jid}">
+ <error type="modify">
+ <policy-violation xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+ <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">${err_txt}</text>
+ </error>
+ </message>
+ `);
+ _converse.connection._dataRecv(mock.createRequest(error));
+
+ expect(await u.waitUntil(() => view.querySelector('.chat-error')?.textContent?.trim())).toBe(err_txt);
+ expect(view.model.messages.length).toBe(2);
+ }));
+
+ });
+
+ it("will cause the chat area to be scrolled down only if it was at the bottom originally",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, sender_jid)
+ const view = _converse.chatboxviews.get(sender_jid);
+ // Create enough messages so that there's a scrollbar.
+ const promises = [];
+ view.querySelector('.chat-content').scrollTop = 0;
+ view.model.ui.set('scrolled', true);
+
+ for (let i=0; i<20; i++) {
+ _converse.handleMessageStanza($msg({
+ from: sender_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: _converse.connection.getUniqueId(),
+ }).c('body').t('Message: '+i).up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+ promises.push(new Promise(resolve => view.model.messages.once('rendered', resolve)));
+ }
+ await Promise.all(promises);
+
+ const indicator_el = await u.waitUntil(() => view.querySelector('.new-msgs-indicator'));
+
+ expect(view.model.ui.get('scrolled')).toBe(true);
+ expect(view.querySelector('.chat-content').scrollTop).toBe(0);
+ indicator_el.click();
+ await u.waitUntil(() => !view.querySelector('.new-msgs-indicator'));
+ await u.waitUntil(() => !view.model.get('scrolled'));
+ }));
+
+ it("is ignored if it's intended for a different resource and filter_by_resource is set to true",
+ mock.initConverse([], {}, async function (_converse) {
+
+ const { api } = _converse;
+ await mock.waitForRoster(_converse, 'current');
+ const rosterview = document.querySelector('converse-roster');
+ await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length)
+ // Send a message from a different resource
+ spyOn(converse.env.log, 'error');
+ spyOn(_converse.api.chatboxes, 'create').and.callThrough();
+ api.settings.set('filter_by_resource', true);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ let msg = $msg({
+ from: sender_jid,
+ to: _converse.bare_jid+"/some-other-resource",
+ type: 'chat',
+ id: u.getUniqueId()
+ }).c('body').t("This message will not be shown").up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+ await _converse.handleMessageStanza(msg);
+
+ expect(converse.env.log.error.calls.all().pop().args[0]).toBe(
+ "Ignoring incoming message intended for a different resource: romeo@montague.lit/some-other-resource",
+ );
+ expect(_converse.api.chatboxes.create).not.toHaveBeenCalled();
+ api.settings.set('filter_by_resource', false);
+
+ const message = "This message sent to a different resource will be shown";
+ msg = $msg({
+ from: sender_jid,
+ to: _converse.bare_jid+"/some-other-resource",
+ type: 'chat',
+ id: '134234623462346'
+ }).c('body').t(message).up()
+ .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
+ 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.api.chatboxes.create).toHaveBeenCalled();
+ const last_message = await u.waitUntil(() => sizzle('.chat-content:last .chat-msg__text', view).pop());
+ const msg_txt = last_message.textContent;
+ expect(msg_txt).toEqual(message);
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/oob.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/oob.js
new file mode 100644
index 0000000..62ae5ba
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/oob.js
@@ -0,0 +1,168 @@
+/*global mock, converse */
+
+const { Strophe, Promise, u } = converse.env;
+
+describe("A Chat Message", function () {
+ describe("which contains an OOB URL", function () {
+
+ it("will render audio from oob mp3 URLs",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+
+ const url = 'https://montague.lit/audio.mp3';
+ let stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>Have you heard this funny audio?</body>
+ <x xmlns="jabber:x:oob"><url>${url}</url></x>
+ </message>`)
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg audio').length, 1000);
+ let msg = view.querySelector('.chat-msg .chat-msg__text');
+ expect(msg.classList.length).toEqual(1);
+ expect(u.hasClass('chat-msg__text', msg)).toBe(true);
+ expect(msg.textContent).toEqual('Have you heard this funny audio?');
+ const media = view.querySelector('.chat-msg .chat-msg__media');
+ expect(media.innerHTML.replace(/<!-.*?->/g, '').replace(/(\r\n|\n|\r)/gm, "").trim()).toEqual(
+ `<audio controls="" src="https://montague.lit/audio.mp3"></audio>`+
+ `<a target="_blank" rel="noopener" href="https://montague.lit/audio.mp3">${url}</a>`);
+
+ // If the <url> and <body> contents is the same, don't duplicate.
+ stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>${url}</body>
+ <x xmlns="jabber:x:oob"><url>${url}</url></x>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ msg = view.querySelector('.chat-msg .chat-msg__text');
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual('Have you heard this funny audio?'); // Emtpy
+
+ // We don't render the OOB data
+ expect(view.querySelector('converse-chat-message:last-child .chat-msg__media')).toBe(null);
+
+ // But we do render the body
+ const msg_el = view.querySelector('converse-chat-message:last-child .chat-msg__text');
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '').replace(/(\r\n|\n|\r)/gm, "").trim() ===
+ `<audio controls="" src="https://montague.lit/audio.mp3"></audio>`+
+ `<a target="_blank" rel="noopener" href="${url}">${url}</a>`);
+ }));
+
+ it("will render video from oob mp4 URLs",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+
+ const url = 'https://montague.lit/video.mp4';
+ let stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>Have you seen this funny video?</body>
+ <x xmlns="jabber:x:oob"><url>${url}</url></x>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg video').length, 2000)
+ let msg = view.querySelector('.chat-msg .chat-msg__text');
+ expect(msg.classList.length).toBe(1);
+ expect(msg.textContent).toEqual('Have you seen this funny video?');
+ const media = view.querySelector('.chat-msg .chat-msg__media');
+ expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "").replace(/<!-.*?->/g, '')).toEqual(
+ `<video controls="" preload="metadata" src="${Strophe.xmlescape(url)}"></video>`+
+ `<a target="_blank" rel="noopener" href="${Strophe.xmlescape(url)}">${Strophe.xmlescape(url)}</a>`);
+
+ // If the <url> and <body> contents is the same, don't duplicate.
+ stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>https://montague.lit/video.mp4</body>
+ <x xmlns="jabber:x:oob"><url>https://montague.lit/video.mp4</url></x>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ msg = view.querySelector('converse-chat-message .chat-msg__text');
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual('Have you seen this funny video?');
+ expect(view.querySelector('converse-chat-message:last-child .chat-msg__media')).toBe(null);
+ }));
+
+ it("will render download links for files from oob URLs",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ const stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>Have you downloaded this funny file?</body>
+ <x xmlns="jabber:x:oob"><url>https://montague.lit/funny.pdf</url></x>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg a').length, 1000);
+ const msg = view.querySelector('.chat-msg .chat-msg__text');
+ expect(u.hasClass('chat-msg__text', msg)).toBe(true);
+ expect(msg.textContent).toEqual('Have you downloaded this funny file?');
+ const media = view.querySelector('.chat-msg .chat-msg__media');
+ expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "").replace(/<!-.*?->/g, '')).toEqual(
+ `<a target="_blank" rel="noopener" href="https://montague.lit/funny.pdf">Download file "funny.pdf"</a>`);
+ }));
+
+ it("will render images from oob URLs",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ const base_url = 'https://conversejs.org';
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(view.model, 'sendMessage').and.callThrough();
+ const url = base_url+"/logo/conversejs-filled.svg";
+
+ const stanza = u.toStanza(`
+ <message from="${contact_jid}"
+ type="chat"
+ to="romeo@montague.lit/orchard">
+ <body>Have you seen this funny image?</body>
+ <x xmlns="jabber:x:oob"><url>${url}</url></x>
+ </message>`);
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ _converse.connection._dataRecv(mock.createRequest(stanza));
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg a').length, 1000);
+ const msg = view.querySelector('.chat-msg .chat-msg__text');
+ expect(u.hasClass('chat-msg__text', msg)).toBe(true);
+ expect(msg.textContent).toEqual('Have you seen this funny image?');
+ const media = view.querySelector('.chat-msg .chat-msg__media');
+ expect(media.innerHTML.replace(/<!-.*?->/g, '').replace(/(\r\n|\n|\r)/gm, "")).toEqual(
+ `<a target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+
+ `Download file "conversejs-filled.svg"</a>`);
+ }));
+ });
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/receipts.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/receipts.js
new file mode 100644
index 0000000..8fac60f
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/receipts.js
@@ -0,0 +1,151 @@
+/*global mock, converse, _ */
+
+const { Promise, Strophe, $msg, sizzle } = converse.env;
+const u = converse.env.utils;
+
+
+describe("A delivery receipt", function () {
+
+ it("is emitted for a received message which requests it",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg_id = u.getUniqueId();
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(stanza => sent_stanzas.push(stanza));
+ const msg = $msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': msg_id,
+ }).c('body').t('Message!').up()
+ .c('request', {'xmlns': Strophe.NS.RECEIPTS}).tree();
+ await _converse.handleMessageStanza(msg);
+ const sent_messages = sent_stanzas.map(s => _.isElement(s) ? s : s.nodeTree).filter(s => s.nodeName === 'message');
+ // A chat state message is also included
+ expect(sent_messages.length).toBe(2);
+ const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, sent_messages[1]).pop();
+ expect(Strophe.serialize(receipt)).toBe(`<received id="${msg_id}" xmlns="${Strophe.NS.RECEIPTS}"/>`);
+ }));
+
+ it("is not emitted for a carbon message",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg_id = u.getUniqueId();
+ const view = await mock.openChatBoxFor(_converse, sender_jid);
+ spyOn(view.model, 'sendReceiptStanza').and.callThrough();
+ const msg = $msg({
+ 'from': sender_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId(),
+ }).c('received', {'xmlns': 'urn:xmpp:carbons:2'})
+ .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'})
+ .c('message', {
+ 'xmlns': 'jabber:client',
+ 'from': sender_jid,
+ 'to': _converse.bare_jid+'/another-resource',
+ 'type': 'chat',
+ 'id': msg_id
+ }).c('body').t('Message!').up()
+ .c('request', {'xmlns': Strophe.NS.RECEIPTS}).tree();
+ await _converse.handleMessageStanza(msg);
+ expect(view.model.sendReceiptStanza).not.toHaveBeenCalled();
+ }));
+
+ it("is not emitted for an archived message",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const view = await mock.openChatBoxFor(_converse, sender_jid);
+ spyOn(view.model, 'sendReceiptStanza').and.callThrough();
+
+ const stanza = u.toStanza(
+ `<message xmlns="jabber:client" to="${_converse.jid}">
+ <result xmlns="urn:xmpp:mam:2" id="9ZWxmXMR8SVor-tC" queryid="f543c5f9-55e7-400e-860a-56baac121e6a">
+ <forwarded xmlns="urn:xmpp:forward:0">
+ <delay xmlns="urn:xmpp:delay" stamp="2020-01-10T22:19:30Z"/>
+ <message xmlns="jabber:client" type="chat" to="${_converse.jid}" from="${sender_jid}" id="id8b6426b4-40fe-4151-941e-4c64e380acb9">
+ <body>Please confirm receipt</body>
+ <request xmlns="urn:xmpp:receipts"/>
+ <origin-id xmlns="urn:xmpp:sid:0" id="id8b6426b4-40fe-4151-941e-4c64e380acb9"/>
+ </message>
+ </forwarded>
+ </result>
+ </message>`);
+
+ spyOn(view.model, 'getDuplicateMessage').and.callThrough();
+ _converse.handleMAMResult(view.model, { 'messages': [stanza] });
+ let message_attrs;
+ _converse.api.listen.on('MAMResult', async data => {
+ message_attrs = await data.messages[0];
+ });
+ await u.waitUntil(() => view.model.getDuplicateMessage.calls.count());
+ expect(message_attrs.is_archived).toBe(true);
+ expect(message_attrs.is_valid_receipt_request).toBe(false);
+ expect(view.model.sendReceiptStanza).not.toHaveBeenCalled();
+ }));
+
+ it("can be received for a sent message",
+ mock.initConverse(
+ ['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const textarea = view.querySelector('textarea.chat-textarea');
+ textarea.value = 'But soft, what light through yonder airlock breaks?';
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ const chatbox = _converse.chatboxes.get(contact_jid);
+ expect(chatbox).toBeDefined();
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ let msg_obj = chatbox.messages.models[0];
+ let msg_id = msg_obj.get('msgid');
+ let msg = $msg({
+ 'from': contact_jid,
+ 'to': _converse.connection.jid,
+ 'id': u.getUniqueId(),
+ }).c('received', {'id': msg_id, xmlns: Strophe.NS.RECEIPTS}).up().tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__receipt').length === 1);
+
+ // Also handle receipts with type 'chat'. See #1353
+ spyOn(_converse, 'handleMessageStanza').and.callThrough();
+ textarea.value = 'Another message';
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ msg_obj = chatbox.messages.models[1];
+ msg_id = msg_obj.get('msgid');
+ msg = $msg({
+ 'from': contact_jid,
+ 'type': 'chat',
+ 'to': _converse.connection.jid,
+ 'id': u.getUniqueId(),
+ }).c('received', {'id': msg_id, xmlns: Strophe.NS.RECEIPTS}).up().tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__receipt').length === 2);
+ expect(_converse.handleMessageStanza.calls.count()).toBe(1);
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/spoilers.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/spoilers.js
new file mode 100644
index 0000000..8035b63
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/spoilers.js
@@ -0,0 +1,238 @@
+/* global mock, converse */
+
+const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
+
+describe("A spoiler message", function () {
+
+ beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000));
+ afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
+
+ it("can be received with a hint",
+ mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => {
+
+ await mock.waitForRoster(_converse, 'current');
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ /* <message to='romeo@montague.net/orchard' from='juliet@capulet.net/balcony' id='spoiler2'>
+ * <body>And at the end of the story, both of them die! It is so tragic!</body>
+ * <spoiler xmlns='urn:xmpp:spoiler:0'>Love story end</spoiler>
+ * </message>
+ */
+ const spoiler_hint = "Love story end"
+ const spoiler = "And at the end of the story, both of them die! It is so tragic!";
+ const $msg = converse.env.$msg;
+ const u = converse.env.utils;
+ const msg = $msg({
+ 'xmlns': 'jabber:client',
+ 'to': _converse.bare_jid,
+ 'from': sender_jid,
+ 'type': 'chat'
+ }).c('body').t(spoiler).up()
+ .c('spoiler', {
+ 'xmlns': 'urn:xmpp:spoiler:0',
+ }).t(spoiler_hint)
+ .tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ await new Promise(resolve => _converse.api.listen.once('chatBoxViewInitialized', resolve));
+ const view = _converse.chatboxviews.get(sender_jid);
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ await u.waitUntil(() => view.model.vcard.get('fullname') === 'Mercutio')
+ expect(view.querySelector('.chat-msg__author').textContent.trim()).toBe('Mercutio');
+ const message_content = view.querySelector('.chat-msg__text');
+ await u.waitUntil(() => message_content.textContent === spoiler);
+ const spoiler_hint_el = view.querySelector('.spoiler-hint');
+ expect(spoiler_hint_el.textContent).toBe(spoiler_hint);
+ }));
+
+ it("can be received without a hint",
+ mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => {
+
+ await mock.waitForRoster(_converse, 'current');
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ /* <message to='romeo@montague.net/orchard' from='juliet@capulet.net/balcony' id='spoiler2'>
+ * <body>And at the end of the story, both of them die! It is so tragic!</body>
+ * <spoiler xmlns='urn:xmpp:spoiler:0'>Love story end</spoiler>
+ * </message>
+ */
+ const $msg = converse.env.$msg;
+ const u = converse.env.utils;
+ const spoiler = "And at the end of the story, both of them die! It is so tragic!";
+ const msg = $msg({
+ 'xmlns': 'jabber:client',
+ 'to': _converse.bare_jid,
+ 'from': sender_jid,
+ 'type': 'chat'
+ }).c('body').t(spoiler).up()
+ .c('spoiler', {
+ 'xmlns': 'urn:xmpp:spoiler:0',
+ }).tree();
+ _converse.connection._dataRecv(mock.createRequest(msg));
+ await new Promise(resolve => _converse.api.listen.once('chatBoxViewInitialized', resolve));
+ const view = _converse.chatboxviews.get(sender_jid);
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ await u.waitUntil(() => u.isVisible(view));
+ await u.waitUntil(() => view.model.vcard.get('fullname') === 'Mercutio')
+ await u.waitUntil(() => u.isVisible(view.querySelector('.chat-msg__author')));
+ expect(view.querySelector('.chat-msg__author').textContent.includes('Mercutio')).toBeTruthy();
+ const message_content = view.querySelector('.chat-msg__text');
+ await u.waitUntil(() => message_content.textContent === spoiler);
+ const spoiler_hint_el = view.querySelector('.spoiler-hint');
+ expect(spoiler_hint_el.textContent).toBe('');
+ }));
+
+ it("can be sent without a hint",
+ mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ const { $pres, Strophe} = converse.env;
+ const u = converse.env.utils;
+
+ // XXX: We need to send a presence from the contact, so that we
+ // have a resource, that resource is then queried to see
+ // whether Strophe.NS.SPOILER is supported, in which case
+ // the spoiler button will appear.
+ const presence = $pres({
+ 'from': contact_jid+'/phone',
+ 'to': 'romeo@montague.lit'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await mock.openChatBoxFor(_converse, contact_jid);
+ await mock.waitUntilDiscoConfirmed(_converse, contact_jid+'/phone', [], [Strophe.NS.SPOILER]);
+ const view = _converse.chatboxviews.get(contact_jid);
+ spyOn(_converse.connection, 'send');
+
+ await u.waitUntil(() => view.querySelector('.toggle-compose-spoiler'));
+ let spoiler_toggle = view.querySelector('.toggle-compose-spoiler');
+ spoiler_toggle.click();
+
+ const textarea = view.querySelector('.chat-textarea');
+ textarea.value = 'This is the spoiler';
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ /* Test the XML stanza
+ *
+ * <message from="romeo@montague.lit/orchard"
+ * to="max.frankfurter@montague.lit"
+ * type="chat"
+ * id="4547c38b-d98b-45a5-8f44-b4004dbc335e"
+ * xmlns="jabber:client">
+ * <body>This is the spoiler</body>
+ * <active xmlns="http://jabber.org/protocol/chatstates"/>
+ * <spoiler xmlns="urn:xmpp:spoiler:0"/>
+ * </message>"
+ */
+ const stanza = _converse.connection.send.calls.argsFor(0)[0];
+ const spoiler_el = await u.waitUntil(() => stanza.querySelector('spoiler[xmlns="urn:xmpp:spoiler:0"]'));
+ expect(spoiler_el.textContent).toBe('');
+
+ const spoiler = 'This is the spoiler';
+ const body_el = stanza.querySelector('body');
+ expect(body_el.textContent).toBe(spoiler);
+
+ /* Test the HTML spoiler message */
+ expect(view.querySelector('.chat-msg__author').textContent.trim()).toBe('Romeo');
+
+ const message_content = view.querySelector('.chat-msg__text');
+ await u.waitUntil(() => message_content.textContent === spoiler);
+
+ const spoiler_msg_el = view.querySelector('.chat-msg__text.spoiler');
+ expect(Array.from(spoiler_msg_el.classList).includes('hidden')).toBeTruthy();
+
+ spoiler_toggle = view.querySelector('.spoiler-toggle');
+ expect(spoiler_toggle.textContent.trim()).toBe('Show more');
+ spoiler_toggle.click();
+ await u.waitUntil(() => !Array.from(spoiler_msg_el.classList).includes('hidden'));
+ expect(spoiler_toggle.textContent.trim()).toBe('Show less');
+ spoiler_toggle.click();
+ await u.waitUntil(() => Array.from(spoiler_msg_el.classList).includes('hidden'));
+ }));
+
+ it("can be sent with a hint",
+ mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+
+ const { $pres, Strophe} = converse.env;
+ const u = converse.env.utils;
+
+ // XXX: We need to send a presence from the contact, so that we
+ // have a resource, that resource is then queried to see
+ // whether Strophe.NS.SPOILER is supported, in which case
+ // the spoiler button will appear.
+ const presence = $pres({
+ 'from': contact_jid+'/phone',
+ 'to': 'romeo@montague.lit'
+ });
+ _converse.connection._dataRecv(mock.createRequest(presence));
+ await mock.openChatBoxFor(_converse, contact_jid);
+ await mock.waitUntilDiscoConfirmed(_converse, contact_jid+'/phone', [], [Strophe.NS.SPOILER]);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ await u.waitUntil(() => view.querySelector('.toggle-compose-spoiler'));
+ let spoiler_toggle = view.querySelector('.toggle-compose-spoiler');
+ spoiler_toggle.click();
+
+ spyOn(_converse.connection, 'send');
+
+ const textarea = view.querySelector('.chat-textarea');
+ textarea.value = 'This is the spoiler';
+ const hint_input = view.querySelector('.spoiler-hint');
+ hint_input.value = 'This is the hint';
+
+ const message_form = view.querySelector('converse-message-form');
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13
+ });
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ const stanza = _converse.connection.send.calls.argsFor(0)[0];
+ expect(Strophe.serialize(stanza)).toBe(
+ `<message from="romeo@montague.lit/orchard" ` +
+ `id="${stanza.getAttribute('id')}" `+
+ `to="mercutio@montague.lit" `+
+ `type="chat" `+
+ `xmlns="jabber:client">`+
+ `<body>This is the spoiler</body>`+
+ `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+ `<request xmlns="urn:xmpp:receipts"/>`+
+ `<spoiler xmlns="urn:xmpp:spoiler:0">This is the hint</spoiler>`+
+ `<origin-id id="${stanza.querySelector('origin-id').getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+
+ `</message>`
+ );
+
+ await u.waitUntil(() => stanza.querySelector('spoiler[xmlns="urn:xmpp:spoiler:0"]')?.textContent === 'This is the hint');
+
+ const spoiler = 'This is the spoiler'
+ const body_el = stanza.querySelector('body');
+ expect(body_el.textContent).toBe(spoiler);
+
+ expect(view.querySelector('.chat-msg__author').textContent.trim()).toBe('Romeo');
+
+ const message_content = view.querySelector('.chat-msg__text');
+ await u.waitUntil(() => message_content.textContent === spoiler);
+
+ const spoiler_msg_el = view.querySelector('.chat-msg__text.spoiler');
+ expect(Array.from(spoiler_msg_el.classList).includes('hidden')).toBeTruthy();
+
+ spoiler_toggle = view.querySelector('.spoiler-toggle');
+ expect(spoiler_toggle.textContent.trim()).toBe('Show more');
+ spoiler_toggle.click();
+ await u.waitUntil(() => !Array.from(spoiler_msg_el.classList).includes('hidden'));
+ expect(spoiler_toggle.textContent.trim()).toBe('Show less');
+ spoiler_toggle.click();
+ await u.waitUntil(() => Array.from(spoiler_msg_el.classList).includes('hidden'));
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/styling.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/styling.js
new file mode 100644
index 0000000..f6f5872
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/styling.js
@@ -0,0 +1,517 @@
+/*global mock, converse */
+
+const { u, $msg } = converse.env;
+
+describe("An incoming chat Message", function () {
+
+ it("can have styling disabled via an \"unstyled\" element",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ const include_nick = false;
+ await mock.waitForRoster(_converse, 'current', 2, include_nick);
+ await mock.openControlBox(_converse);
+
+ const msg_text = '> _ >';
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg = $msg({
+ 'from': sender_jid,
+ 'id': u.getUniqueId(),
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'xmlns': 'jabber:client'
+ }).c('body').t(msg_text).up()
+ .c('unstyled', {'xmlns': 'urn:xmpp:styling:0'}).tree();
+ await _converse.handleMessageStanza(msg);
+
+ const view = _converse.chatboxviews.get(sender_jid);
+ await u.waitUntil(() => view.model.messages.length);
+ expect(view.model.messages.models[0].get('is_unstyled')).toBe(true);
+
+ setTimeout(() => {
+ const msg_el = view.querySelector('converse-chat-message-body');
+ expect(msg_el.innerText).toBe(msg_text);
+ }, 500);
+ }));
+
+
+ it("can have styling disabled via the allow_message_styling setting",
+ mock.initConverse(['chatBoxesFetched'], {'allow_message_styling': false},
+ async function (_converse) {
+
+ const include_nick = false;
+ await mock.waitForRoster(_converse, 'current', 2, include_nick);
+ await mock.openControlBox(_converse);
+
+ const msg_text = '> _ >';
+ const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg = $msg({
+ 'from': sender_jid,
+ 'id': u.getUniqueId(),
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'xmlns': 'jabber:client'
+ }).c('body').t(msg_text).tree();
+ await _converse.handleMessageStanza(msg);
+
+ const view = _converse.chatboxviews.get(sender_jid);
+ await u.waitUntil(() => view.model.messages.length);
+ expect(view.model.messages.models[0].get('is_unstyled')).toBe(false);
+
+ setTimeout(() => {
+ const msg_el = view.querySelector('converse-chat-message-body');
+ expect(msg_el.innerText).toBe(msg_text);
+ }, 500);
+ }));
+
+ it("can be styled with span XEP-0393 message styling hints",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ let msg_text, msg, msg_el;
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ msg_text = "This *message _contains_* styling hints! \`Here's *some* code\`";
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ msg_el = view.querySelector('converse-chat-message-body');
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'This <span class="styling-directive">*</span>'+
+ '<b>message <span class="styling-directive">_</span><i>contains</i><span class="styling-directive">_</span></b>'+
+ '<span class="styling-directive">*</span>'+
+ ' styling hints! '+
+ '<span class="styling-directive">`</span><code>Here\'s *some* code</code><span class="styling-directive">`</span>'
+ );
+
+ msg_text = "Here's a ~strikethrough section~";
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'Here\'s a <span class="styling-directive">~</span><del>strikethrough section</del><span class="styling-directive">~</span>');
+
+ // Span directives containing hyperlinks
+ msg_text = "~Check out this site: https://conversejs.org~"
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<span class="styling-directive">~</span>'+
+ '<del>Check out this site: <a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a></del>'+
+ '<span class="styling-directive">~</span>');
+
+ // Images inside directives aren't shown inline
+ const base_url = 'https://conversejs.org';
+ msg_text = `*${base_url}/logo/conversejs-filled.svg*`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<span class="styling-directive">*</span>'+
+ '<b><a target="_blank" rel="noopener" href="https://conversejs.org/logo/conversejs-filled.svg">https://conversejs.org/logo/conversejs-filled.svg</a></b>'+
+ '<span class="styling-directive">*</span>');
+
+ // Emojis inside directives
+ msg_text = `~ Hello! :poop: ~`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 5);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<span class="styling-directive">~</span><del> Hello! <span title=":poop:">💩</span> </del><span class="styling-directive">~</span>');
+
+ // Span directives don't cross lines
+ msg_text = "This *is not a styling hint \n * _But this is_!";
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 6);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'This *is not a styling hint \n'+
+ ' * <span class="styling-directive">_</span><i>But this is</i><span class="styling-directive">_</span>!');
+
+ msg_text = `(There are three blocks in this body marked by parens,)\n (but there is no *formatting)\n (as spans* may not escape blocks.)\n ~strikethrough~`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 7);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '(There are three blocks in this body marked by parens,)\n'+
+ ' (but there is no *formatting)\n'+
+ ' (as spans* may not escape blocks.)\n'+
+ ' <span class="styling-directive">~</span><del>strikethrough</del><span class="styling-directive">~</span>');
+
+ // Some edge-case (unspecified) spans
+ msg_text = `__ hello world _`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 8);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '_<span class="styling-directive">_</span><i> hello world </i><span class="styling-directive">_</span>');
+
+ // Directives which are parts of words aren't matched
+ msg_text = `Go to ~https://conversejs.org~now _please_`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 9);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'Go to ~https://conversejs.org~now <span class="styling-directive">_</span><i>please</i><span class="styling-directive">_</span>');
+
+ msg_text = `Go to _https://converse_js.org_ _please_`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 10);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'Go to <span class="styling-directive">_</span>'+
+ '<i><a target="_blank" rel="noopener" href="https://converse_js.org/">https://converse_js.org</a></i>'+
+ '<span class="styling-directive">_</span> <span class="styling-directive">_</span><i>please</i><span class="styling-directive">_</span>');
+
+ }));
+
+ it("can be styled with block XEP-0393 message styling hints",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ let msg_text, msg, msg_el;
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ msg_text = `Here's a code block: \n\`\`\`\nInside the code-block, <code>hello</code> we don't enable *styling hints* like ~these~\n\`\`\``;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
+ 'Here\'s a code block: \n'+
+ '<div class="styling-directive">```</div><code class="block">Inside the code-block, &lt;code&gt;hello&lt;/code&gt; we don\'t enable *styling hints* like ~these~\n'+
+ '</code><div class="styling-directive">```</div>'
+ );
+
+ msg_text = "```\nignored\n(println \"Hello, world!\")\n```\nThis should show up as monospace, preformatted text ^";
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<div class="styling-directive">```</div>'+
+ '<code class="block">ignored\n(println "Hello, world!")\n</code>'+
+ '<div class="styling-directive">```</div>\n'+
+ 'This should show up as monospace, preformatted text ^');
+
+
+ msg_text = "```ignored\n (println \"Hello, world!\")\n ```\n\n This should not show up as monospace, *preformatted* text ^";
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerText).toBe(msg_text);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '```ignored\n (println "Hello, world!")\n ```\n\n'+
+ ' This should not show up as monospace, '+
+ '<span class="styling-directive">*</span><b>preformatted</b><span class="styling-directive">*</span> text ^');
+ }));
+
+ it("can be styled with quote XEP-0393 message styling hints",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ let msg_text, msg, msg_el;
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ msg_text = `> https://conversejs.org\n> https://conversejs.org`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
+ '<blockquote>'+
+ '<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>\n\u200B\u200B'+
+ '<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>'+
+ '</blockquote>');
+
+ msg_text = `> This is quoted text\n>This is also quoted\nThis is not quoted`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
+ '<blockquote>This is quoted text\n\u200BThis is also quoted</blockquote>\nThis is not quoted');
+
+ msg_text = `> This is *quoted* text\n>This is \`also _quoted_\`\nThis is not quoted`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
+ '<blockquote>This is <span class="styling-directive">*</span><b>quoted</b><span class="styling-directive">*</span> text\n\u200B'+
+ 'This is <span class="styling-directive">`</span><code>also _quoted_</code><span class="styling-directive">`</span></blockquote>\n'+
+ 'This is not quoted');
+
+ msg_text = `> > This is doubly quoted text`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe("<blockquote><blockquote>This is doubly quoted text</blockquote></blockquote>");
+
+ msg_text = `>> This is doubly quoted text`;
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 5);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe("<blockquote><blockquote>This is doubly quoted text</blockquote></blockquote>");
+
+ msg_text = ">```\n>ignored\n> <span></span> (println \"Hello, world!\")\n>```\n> This should show up as monospace, preformatted text ^";
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 6);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
+ '<blockquote>'+
+ '<div class="styling-directive">```</div>'+
+ '<code class="block">\u200Bignored\n\u200B\u200B&lt;span&gt;&lt;/span&gt; (println "Hello, world!")\n\u200B'+
+ '</code><div class="styling-directive">```</div>\n\u200B\u200B'+
+ 'This should show up as monospace, preformatted text ^'+
+ '</blockquote>');
+
+ msg_text = '> ```\n> (println "Hello, world!")\n\nThe entire blockquote is a preformatted text block, but this line is plaintext!';
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 7);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
+ '<blockquote>```\n\u200B\u200B(println "Hello, world!")</blockquote>\n\n'+
+ 'The entire blockquote is a preformatted text block, but this line is plaintext!');
+
+ msg_text = '> Also, icons.js is loaded from /dist, instead of dist.\nhttps://conversejs.org/docs/html/configuration.html#assets-path'
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 8);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<blockquote>Also, icons.js is loaded from /dist, instead of dist.</blockquote>\n'+
+ '<a target="_blank" rel="noopener" href="https://conversejs.org/docs/html/configuration.html#assets-path">https://conversejs.org/docs/html/configuration.html#assets-path</a>');
+
+ msg_text = '> Where is it located?\ngeo:37.786971,-122.399677';
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 9);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<blockquote>Where is it located?</blockquote>\n'+
+ '<a target="_blank" rel="noopener" '+
+ 'href="https://www.openstreetmap.org/?mlat=37.786971&amp;mlon=-122.399677#map=18/37.786971/-122.399677">https://www.openstreetmap.org/?mlat=37.786971&amp;mlon=-122.399677#map=18/37.786971/-122.399677</a>');
+
+ msg_text = '> What do you think of it?\n :poop:';
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 10);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<blockquote>What do you think of it?</blockquote>\n <span title=":poop:">💩</span>');
+
+ msg_text = '> What do you think of it?\n~hello~';
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 11);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<blockquote>What do you think of it?</blockquote>\n<span class="styling-directive">~</span><del>hello</del><span class="styling-directive">~</span>');
+
+ msg_text = 'hello world > this is not a quote';
+ msg = mock.createChatMessage(_converse, contact_jid, msg_text)
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 12);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === 'hello world &gt; this is not a quote');
+
+ msg_text = '> What do you think of it romeo?\n Did you see this romeo?';
+ msg = $msg({
+ from: contact_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: (new Date()).getTime()
+ }).c('body').t(msg_text).up()
+ .c('reference', {
+ 'xmlns':'urn:xmpp:reference:0',
+ 'begin':'26',
+ 'end':'31',
+ 'type':'mention',
+ 'uri': _converse.bare_jid
+ })
+ .c('reference', {
+ 'xmlns':'urn:xmpp:reference:0',
+ 'begin':'51',
+ 'end':'56',
+ 'type':'mention',
+ 'uri': _converse.bare_jid
+ }).nodeTree;
+ await _converse.handleMessageStanza(msg);
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 13);
+ msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ `<blockquote>What do you think of it <span class="mention" data-uri="romeo@montague.lit">romeo</span>?</blockquote>\n `+
+ `Did you see this <span class="mention" data-uri="romeo@montague.lit">romeo</span>?`);
+
+ expect(true).toBe(true);
+ }));
+
+ it("won't style invalid block quotes",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+ const msg_text = '```\ncode```';
+ const msg = $msg({
+ from: contact_jid,
+ to: _converse.connection.jid,
+ type: 'chat',
+ id: (new Date()).getTime()
+ }).c('body').t(msg_text).up()
+ .c('reference', {
+ 'xmlns':'urn:xmpp:reference:0',
+ 'begin':'26',
+ 'end':'31',
+ 'type':'mention',
+ 'uri': _converse.bare_jid
+ })
+ .c('reference', {
+ 'xmlns':'urn:xmpp:reference:0',
+ 'begin':'51',
+ 'end':'56',
+ 'type':'mention',
+ 'uri': _converse.bare_jid
+ }).nodeTree;
+ await _converse.handleMessageStanza(msg);
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+ const msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === '```\ncode```');
+ expect(true).toBe(true);
+ }));
+});
+
+
+describe("An XEP-0393 styled message ", function () {
+
+ it("can be replaced with a correction and will still render properly",
+ mock.initConverse(['chatBoxesFetched'], {},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid);
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ const msg_text = `https://conversejs.org\nhttps://opkode.com`;
+ const msg_id = u.getUniqueId();
+ _converse.handleMessageStanza($msg({
+ 'from': contact_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': msg_id,
+ }).c('body').t(msg_text).tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ expect(view.querySelector('.chat-msg__text').textContent)
+ .toBe('https://conversejs.org\nhttps://opkode.com');
+
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1);
+ const msg_el = view.querySelector('converse-chat-message-body');
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ '<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>\n'+
+ '<a target="_blank" rel="noopener" href="https://opkode.com/">https://opkode.com</a>'
+ );
+
+ _converse.handleMessageStanza($msg({
+ 'from': contact_jid,
+ 'to': _converse.connection.jid,
+ 'type': 'chat',
+ 'id': u.getUniqueId(),
+ }).c('body').t(`A\nhttps://conversejs.org\n\nhttps://opkode.com`).up()
+ .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree());
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ expect(view.querySelectorAll('.chat-msg__text').length).toBe(1);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'A\n<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>\n\n'+
+ '<a target="_blank" rel="noopener" href="https://opkode.com/">https://opkode.com</a>'
+ );
+ }));
+
+ it("can be sent as a correction by using the up arrow",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ await mock.openControlBox(_converse);
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+ const textarea = view.querySelector('textarea.chat-textarea');
+ const message_form = view.querySelector('converse-message-form');
+
+ textarea.value = `https://conversejs.org\nhttps://opkode.com`;
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+
+ expect(view.querySelectorAll('.chat-msg').length).toBe(1);
+ const msg_el = view.querySelector('converse-chat-message-body');
+ expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
+ '<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>\n'+
+ '<a target="_blank" rel="noopener" href="https://opkode.com/">https://opkode.com</a>'
+ );
+
+ expect(textarea.value).toBe('');
+ message_form.onKeyDown({
+ target: textarea,
+ keyCode: 38 // Up arrow
+ });
+
+ textarea.value = `A\nhttps://conversejs.org\n\nhttps://opkode.com`;
+ message_form.onKeyDown({
+ target: textarea,
+ preventDefault: function preventDefault () {},
+ keyCode: 13 // Enter
+ });
+ await new Promise(resolve => view.model.messages.once('rendered', resolve));
+
+ expect(view.querySelectorAll('.chat-msg__text').length).toBe(1);
+ await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
+ 'A\n<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>\n\n'+
+ '<a target="_blank" rel="noopener" href="https://opkode.com/">https://opkode.com</a>'
+ );
+ }));
+
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/unreads.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/unreads.js
new file mode 100644
index 0000000..40eb4ea
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/unreads.js
@@ -0,0 +1,156 @@
+/*global mock, converse */
+
+const { u } = converse.env;
+
+
+describe("A ChatBox's Unread Message Count", function () {
+
+ it("is incremented when the message is received and ChatBoxView is scrolled up",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit',
+ msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+
+ const view = await mock.openChatBoxFor(_converse, sender_jid)
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
+ view.model.ui.set('scrolled', true);
+ await _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => view.model.messages.length);
+ expect(view.model.get('num_unread')).toBe(1);
+ const msgid = view.model.messages.last().get('id');
+ expect(view.model.get('first_unread_id')).toBe(msgid);
+ await u.waitUntil(() => sent_stanzas.length);
+ expect(sent_stanzas[0].querySelector('received')).toBeDefined();
+ }));
+
+ it("is not incremented when the message is received and ChatBoxView is scrolled down",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msg = mock.createChatMessage(_converse, sender_jid, 'This message will be read');
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ await _converse.handleMessageStanza(msg);
+ expect(chatbox.get('num_unread')).toBe(0);
+ await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 2);
+ expect(sent_stanzas[1].querySelector('displayed')).toBeDefined();
+ }));
+
+ it("is incremented when message is received, chatbox is scrolled down and the window is not focused",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msgFactory = function () {
+ return mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+ };
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ _converse.windowState = 'hidden';
+ const msg = msgFactory();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => chatbox.messages.length);
+ expect(chatbox.get('num_unread')).toBe(1);
+ const msgid = chatbox.messages.last().get('id');
+ expect(chatbox.get('first_unread_id')).toBe(msgid);
+ await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length);
+ expect(sent_stanzas[0].querySelector('received')).toBeDefined();
+ }));
+
+ it("is incremented when message is received, chatbox is scrolled up and the window is not focused",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ chatbox.ui.set('scrolled', true);
+ _converse.windowState = 'hidden';
+ const msg = msgFactory();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => chatbox.messages.length);
+ expect(chatbox.get('num_unread')).toBe(1);
+ const msgid = chatbox.messages.last().get('id');
+ expect(chatbox.get('first_unread_id')).toBe(msgid);
+ await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 1);
+ expect(sent_stanzas[0].querySelector('received')).toBeDefined();
+ }));
+
+ it("is cleared when the chat was scrolled down and the window become focused",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ _converse.windowState = 'hidden';
+ const msg = msgFactory();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => chatbox.messages.length);
+ expect(chatbox.get('num_unread')).toBe(1);
+ const msgid = chatbox.messages.last().get('id');
+ expect(chatbox.get('first_unread_id')).toBe(msgid);
+ await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 1);
+ expect(sent_stanzas[0].querySelector('received')).toBeDefined();
+ u.saveWindowState({'type': 'focus'});
+ await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 2);
+ expect(sent_stanzas[1].querySelector('displayed')).toBeDefined();
+ expect(chatbox.get('num_unread')).toBe(0);
+ }));
+
+ it("is cleared when the chat was hidden in fullscreen mode and then becomes visible",
+ mock.initConverse(['chatBoxesFetched'], {'view_mode': 'fullscreen'},
+ async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ chatbox.save({'hidden': true});
+ _converse.handleMessageStanza(mock.createChatMessage(_converse, sender_jid, 'This message will be unread'));
+ await u.waitUntil(() => chatbox.messages.length);
+ expect(chatbox.get('num_unread')).toBe(1);
+ chatbox.save({'hidden': false});
+ await u.waitUntil(() => chatbox.get('num_unread') === 0);
+ chatbox.close();
+ }));
+
+ it("is not cleared when ChatBoxView was scrolled up and the windows become focused",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current', 1);
+ const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread');
+ await mock.openChatBoxFor(_converse, sender_jid);
+ const sent_stanzas = [];
+ spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s));
+ const chatbox = _converse.chatboxes.get(sender_jid);
+ chatbox.ui.set('scrolled', true);
+ _converse.windowState = 'hidden';
+ const msg = msgFactory();
+ _converse.handleMessageStanza(msg);
+ await u.waitUntil(() => chatbox.messages.length);
+ expect(chatbox.get('num_unread')).toBe(1);
+ const msgid = chatbox.messages.last().get('id');
+ expect(chatbox.get('first_unread_id')).toBe(msgid);
+ await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 1);
+ expect(sent_stanzas[0].querySelector('received')).toBeDefined();
+ u.saveWindowState({'type': 'focus'});
+ await u.waitUntil(() => chatbox.get('num_unread') === 1);
+ expect(chatbox.get('first_unread_id')).toBe(msgid);
+ expect(sent_stanzas[0].querySelector('received')).toBeDefined();
+ }));
+});
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/xss.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/xss.js
new file mode 100644
index 0000000..8e54382
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/xss.js
@@ -0,0 +1,254 @@
+/*global mock, converse */
+
+const sizzle = converse.env.sizzle;
+const u = converse.env.utils;
+
+describe("XSS", function () {
+ describe("A Chat Message", function () {
+
+ it("will escape IMG payload XSS attempts", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ spyOn(window, 'alert').and.callThrough();
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ let message = "<img src=x onerror=alert('XSS');>";
+ await mock.sendMessage(view, message);
+ let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&lt;img src=x onerror=alert('XSS');&gt;");
+ expect(window.alert).not.toHaveBeenCalled();
+
+ message = "<img src=x onerror=alert('XSS')//";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&lt;img src=x onerror=alert('XSS')//");
+
+ message = "<img src=x onerror=alert(String.fromCharCode(88,83,83));>";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&lt;img src=x onerror=alert(String.fromCharCode(88,83,83));&gt;");
+
+ message = "<img src=x oneonerrorrror=alert(String.fromCharCode(88,83,83));>";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&lt;img src=x oneonerrorrror=alert(String.fromCharCode(88,83,83));&gt;");
+
+ message = "<img src=x:alert(alt) onerror=eval(src) alt=xss>";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&lt;img src=x:alert(alt) onerror=eval(src) alt=xss&gt;");
+
+ message = "><img src=x onerror=alert('XSS');>";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&gt;&lt;img src=x onerror=alert('XSS');&gt;");
+
+ message = "><img src=x onerror=alert(String.fromCharCode(88,83,83));>";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&gt;&lt;img src=x onerror=alert(String.fromCharCode(88,83,83));&gt;");
+
+ expect(window.alert).not.toHaveBeenCalled();
+ }));
+
+ it("will escape SVG payload XSS attempts", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+ spyOn(window, 'alert').and.callThrough();
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ let message = "<svg onload=alert(1)>";
+ await mock.sendMessage(view, message);
+ let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual('&lt;svg onload=alert(1)&gt;');
+
+ message = "<svg/onload=alert('XSS')>";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&lt;svg/onload=alert('XSS')&gt;");
+
+ message = "<svg onload=alert(1)//";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&lt;svg onload=alert(1)//");
+
+ message = "<svg/onload=alert(String.fromCharCode(88,83,83))>";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&lt;svg/onload=alert(String.fromCharCode(88,83,83))&gt;");
+
+ message = "<svg id=alert(1) onload=eval(id)>";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("&lt;svg id=alert(1) onload=eval(id)&gt;");
+
+ message = '"><svg/onload=alert(String.fromCharCode(88,83,83))>';
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual('"&gt;&lt;svg/onload=alert(String.fromCharCode(88,83,83))&gt;');
+
+ message = '"><svg/onload=alert(/XSS/)';
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual('"&gt;&lt;svg/onload=alert(/XSS/)');
+
+ expect(window.alert).not.toHaveBeenCalled();
+ }));
+
+ it("will have properly escaped URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ let message = "http://www.opkode.com/'onmouseover='alert(1)'whatever";
+ await mock.sendMessage(view, message);
+
+ let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, ''))
+ .toEqual('http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever');
+
+
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
+ `<a target="_blank" rel="noopener" href="http://www.opkode.com/%27onmouseover=%27alert%281%29%27whatever">http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever</a>`);
+
+ message = 'http://www.opkode.com/"onmouseover="alert(1)"whatever';
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
+ `<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert%281%29%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>`);
+
+ message = "https://en.wikipedia.org/wiki/Ender's_Game";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
+ `<a target="_blank" rel="noopener" href="https://en.wikipedia.org/wiki/Ender%27s_Game">https://en.wikipedia.org/wiki/Ender's_Game</a>`);
+
+ message = "<https://bugs.documentfoundation.org/show_bug.cgi?id=123737>";
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
+ `&lt;<a target="_blank" rel="noopener" href="https://bugs.documentfoundation.org/show_bug.cgi?id=123737">https://bugs.documentfoundation.org/show_bug.cgi?id=123737</a>&gt;`);
+
+ message = '<http://www.opkode.com/"onmouseover="alert(1)"whatever>';
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
+ `&lt;<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert%281%29%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>&gt;`);
+
+ message = `https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2`
+ await mock.sendMessage(view, message);
+ msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(message);
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') ===
+ `<a target="_blank" rel="noopener" href="https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=%213m6%211e1%213m4%211sQ7SdHo_bPLPlLlU8GSGWaQ%212e0%217i13312%218i6656%214m5%213m4%211s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08%218m2%213d52.3773668%214d4.5489388%215m1%211e2">https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2</a>`);
+ }));
+
+ it("will avoid malformed and unsafe urls urls from rendering as anchors",
+ mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
+
+ await mock.waitForRoster(_converse, 'current');
+ await mock.openControlBox(_converse);
+
+ const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+ await mock.openChatBoxFor(_converse, contact_jid)
+ const view = _converse.chatboxviews.get(contact_jid);
+
+ const bad_urls =[
+ 'http://^$^(*^#$%^_1*(',
+ 'file://devili.sh'
+ ];
+
+ const good_urls =[{
+ entered: 'http://www.google.com',
+ href: 'http://www.google.com/'
+ }, {
+ entered: 'https://www.google.com/',
+ href: 'https://www.google.com/'
+ }, {
+ entered: 'www.url.com/something?else=1',
+ href: 'http://www.url.com/something?else=1',
+ }, {
+ entered: 'xmpp://anything/?join',
+ href: 'xmpp://anything/?join',
+ }, {
+ entered: 'WWW.SOMETHING.COM/?x=dKasdDAsd4JAsd3OAJSD23osajAidj',
+ href: 'http://WWW.SOMETHING.COM/?x=dKasdDAsd4JAsd3OAJSD23osajAidj',
+ }, {
+ entered: 'mailto:test@mail.org',
+ href: 'mailto:test@mail.org',
+ }];
+
+ function checkNonParsedURL (url) {
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(url);
+ expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual(url);
+ }
+
+ async function checkParsedURL ({ entered, href }) {
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent).toEqual(entered);
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') === `<a target="_blank" rel="noopener" href="${href}">${entered}</a>`);
+ }
+
+ async function checkParsedXMPPURL ({ entered, href }) {
+ const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop();
+ expect(msg.textContent.trim()).toEqual(entered);
+ await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '').trim() === `<a target="_blank" rel="noopener" href="${href}">${entered}</a>`);
+ }
+
+ await mock.sendMessage(view, bad_urls[0]);
+ checkNonParsedURL(bad_urls[0]);
+
+ await mock.sendMessage(view, bad_urls[1]);
+ checkNonParsedURL(bad_urls[1]);
+
+ await mock.sendMessage(view, good_urls[0].entered);
+ await checkParsedURL(good_urls[0]);
+
+ await mock.sendMessage(view, good_urls[1].entered);
+ await checkParsedURL(good_urls[1]);
+
+ await mock.sendMessage(view, good_urls[2].entered);
+ await checkParsedURL(good_urls[2]);
+
+ await mock.sendMessage(view, good_urls[3].entered);
+ await checkParsedXMPPURL(good_urls[3]);
+
+ await mock.sendMessage(view, good_urls[4].entered);
+ await checkParsedURL(good_urls[4]);
+
+ await mock.sendMessage(view, good_urls[5].entered);
+ await checkParsedURL(good_urls[5]);
+
+ }));
+ });
+});