diff options
Diffstat (limited to 'roles/reverseproxy/files/conversejs/src/plugins/chatview/tests')
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¶m2=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&param2=val2">`+ + `<img class="chat-image img-thumbnail" loading="lazy" src="${message.replace(/&/g, '&')}">`+ + `</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&name=small">https://pbs.twimg.com/media/string?format=jpg&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¶m2=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&'+ + 'mlon=-122.399677#map=18/37.786971/-122.399677">https://www.openstreetmap.org/?mlat=37.786971&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('<p>This message contains <em>some</em> <b>markup</b></p>'); + })); + + 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&utm_content=1&s=1">https://www.opkode.com/?id=0&utm_content=1&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, <code>hello</code> 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<span></span> (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&mlon=-122.399677#map=18/37.786971/-122.399677">https://www.openstreetmap.org/?mlat=37.786971&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 > 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("<img src=x onerror=alert('XSS');>"); + 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("<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("<img src=x onerror=alert(String.fromCharCode(88,83,83));>"); + + 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("<img src=x oneonerrorrror=alert(String.fromCharCode(88,83,83));>"); + + 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("<img src=x:alert(alt) onerror=eval(src) alt=xss>"); + + 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("><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("><img src=x onerror=alert(String.fromCharCode(88,83,83));>"); + + 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 = "<svgonload=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('<svgonload=alert(1)>'); + + 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("<svg/onload=alert('XSS')>"); + + 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("<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("<svg/onload=alert(String.fromCharCode(88,83,83))>"); + + 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("<svg id=alert(1) onload=eval(id)>"); + + 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('"><svg/onload=alert(String.fromCharCode(88,83,83))>'); + + 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('"><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, '') === + `<<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>>`); + + 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://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]); + + })); + }); +}); |