diff options
Diffstat (limited to 'roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests')
30 files changed, 10905 insertions, 0 deletions
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/autocomplete.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/autocomplete.js new file mode 100644 index 0000000..4994e52 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/autocomplete.js @@ -0,0 +1,367 @@ +/*global mock, converse */ + +const $pres = converse.env.$pres; +const $msg = converse.env.$msg; +const Strophe = converse.env.Strophe; +const u = converse.env.utils; + +describe("The nickname autocomplete feature", function () { + + it("shows all autocompletion options when the user presses @", + mock.initConverse(['chatBoxesFetched'], {}, + async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'tom'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + + // Nicknames from presences + ['dick', 'harry'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick}@montague.lit/resource`, + 'role': 'participant' + }))); + }); + + // Nicknames from messages + const msg = $msg({ + from: 'lounge@montague.lit/jane', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('Hello world').tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.model.messages.last()?.get('received')); + + // Test that pressing @ brings up all options + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + const at_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 50, + 'key': '@' + }; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(at_event); + textarea.value = '@'; + message_form.onKeyUp(at_event); + + await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4); + expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick'); + expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('harry'); + expect(view.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('jane'); + expect(view.querySelector('.suggestion-box__results li:nth-child(4)').textContent).toBe('tom'); + })); + + it("shows all autocompletion options when the user presses @ right after a new line", + mock.initConverse(['chatBoxesFetched'], {}, + async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'tom'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + + // Nicknames from presences + ['dick', 'harry'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick}@montague.lit/resource`, + 'role': 'participant' + }))); + }); + + // Nicknames from messages + const msg = $msg({ + from: 'lounge@montague.lit/jane', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('Hello world').tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.model.messages.last()?.get('received')); + + // Test that pressing @ brings up all options + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + const at_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 50, + 'key': '@' + }; + const message_form = view.querySelector('converse-muc-message-form'); + textarea.value = '\n' + message_form.onKeyDown(at_event); + textarea.value = '\n@'; + message_form.onKeyUp(at_event); + + await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4); + expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick'); + expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('harry'); + expect(view.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('jane'); + expect(view.querySelector('.suggestion-box__results li:nth-child(4)').textContent).toBe('tom'); + })); + + it("shows all autocompletion options when the user presses @ right after an allowed character", + mock.initConverse( + ['chatBoxesFetched'], {'opening_mention_characters':['(']}, + async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'tom'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + + // Nicknames from presences + ['dick', 'harry'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick}@montague.lit/resource`, + 'role': 'participant' + }))); + }); + + // Nicknames from messages + const msg = $msg({ + from: 'lounge@montague.lit/jane', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('Hello world').tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.model.messages.last()?.get('received')); + + // Test that pressing @ brings up all options + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + const at_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 50, + 'key': '@' + }; + textarea.value = '(' + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(at_event); + textarea.value = '(@'; + message_form.onKeyUp(at_event); + + await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4); + expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick'); + expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('harry'); + expect(view.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('jane'); + expect(view.querySelector('.suggestion-box__results li:nth-child(4)').textContent).toBe('tom'); + })); + + it("should order by query index position and length", mock.initConverse( + ['chatBoxesFetched'], {}, async function (_converse) { + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'tom'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + + // Nicknames from presences + ['bernard', 'naber', 'helberlo', 'john', 'jones'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', { xmlns: Strophe.NS.MUC_USER }) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick}@montague.lit/resource`, + 'role': 'participant' + }))); + }); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + const at_event = { + 'target': textarea, + 'preventDefault': function preventDefault() { }, + 'stopPropagation': function stopPropagation() { }, + 'keyCode': 50, + 'key': '@' + }; + + const message_form = view.querySelector('converse-muc-message-form'); + // Test that results are sorted by query index + message_form.onKeyDown(at_event); + textarea.value = '@ber'; + message_form.onKeyUp(at_event); + await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 3); + expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('bernard'); + expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('naber'); + expect(view.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('helberlo'); + + // Test that when the query index is equal, results should be sorted by length + textarea.value = '@jo'; + message_form.onKeyUp(at_event); + await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 2); + expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('john'); + expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('jones'); + })); + + it("autocompletes when the user presses tab", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + expect(view.model.occupants.length).toBe(1); + let presence = $pres({ + 'to': 'romeo@montague.lit/orchard', + 'from': 'lounge@montague.lit/some1' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'some1@montague.lit/resource', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + expect(view.model.occupants.length).toBe(2); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = "hello som"; + + // Press tab + const tab_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 9, + 'key': 'Tab' + } + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(tab_event); + message_form.onKeyUp(tab_event); + await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false); + expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(1); + expect(view.querySelector('.suggestion-box__results li').textContent).toBe('some1'); + + const backspace_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'keyCode': 8 + } + for (let i=0; i<3; i++) { + // Press backspace 3 times to remove "som" + message_form.onKeyDown(backspace_event); + textarea.value = textarea.value.slice(0, textarea.value.length-1) + message_form.onKeyUp(backspace_event); + } + await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === true); + + presence = $pres({ + 'to': 'romeo@montague.lit/orchard', + 'from': 'lounge@montague.lit/some2' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'some2@montague.lit/resource', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + textarea.value = "hello s s"; + message_form.onKeyDown(tab_event); + message_form.onKeyUp(tab_event); + await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false); + expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(2); + + const up_arrow_event = { + 'target': textarea, + 'preventDefault': () => (up_arrow_event.defaultPrevented = true), + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 38 + } + message_form.onKeyDown(up_arrow_event); + message_form.onKeyUp(up_arrow_event); + expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(2); + expect(view.querySelector('.suggestion-box__results li[aria-selected="false"]').textContent).toBe('some1'); + expect(view.querySelector('.suggestion-box__results li[aria-selected="true"]').textContent).toBe('some2'); + + message_form.onKeyDown({ + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 13 // Enter + }); + expect(textarea.value).toBe('hello s @some2 '); + + // Test that pressing tab twice selects + presence = $pres({ + 'to': 'romeo@montague.lit/orchard', + 'from': 'lounge@montague.lit/z3r0' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'z3r0@montague.lit/resource', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + textarea.value = "hello z"; + message_form.onKeyDown(tab_event); + message_form.onKeyUp(tab_event); + await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false); + + message_form.onKeyDown(tab_event); + message_form.onKeyUp(tab_event); + await u.waitUntil(() => textarea.value === 'hello @z3r0 '); + })); + + it("autocompletes when the user presses backspace", + mock.initConverse([], {}, async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + expect(view.model.occupants.length).toBe(1); + const presence = $pres({ + 'to': 'romeo@montague.lit/orchard', + 'from': 'lounge@montague.lit/some1' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'some1@montague.lit/resource', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + expect(view.model.occupants.length).toBe(2); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = "hello @some1 "; + + // Press backspace + const backspace_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 8, + 'key': 'Backspace' + } + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(backspace_event); + textarea.value = "hello @some1"; // Mimic backspace + message_form.onKeyUp(backspace_event); + await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false); + expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(1); + expect(view.querySelector('.suggestion-box__results li').textContent).toBe('some1'); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/component.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/component.js new file mode 100644 index 0000000..f8e1790 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/component.js @@ -0,0 +1,96 @@ +/*global mock, converse */ + +const u = converse.env.utils; + + +describe("The <converse-muc> component", function () { + + it("can be rendered as a standalone component", + mock.initConverse([], {'auto_insert': false}, async function (_converse) { + + const { api } = _converse; + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + const muc_creation_promise = await api.rooms.open(muc_jid, {nick, 'hidden': true}, false); + await mock.getRoomFeatures(_converse, muc_jid, []); + await mock.receiveOwnMUCPresence(_converse, muc_jid, nick); + await muc_creation_promise; + const model = _converse.chatboxes.get(muc_jid); + await u.waitUntil(() => (model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + + const span_el = document.createElement('span'); + span_el.classList.add('conversejs'); + span_el.classList.add('converse-embedded'); + + const muc_el = document.createElement('converse-muc'); + muc_el.classList.add('chatbox'); + muc_el.classList.add('chatroom'); + muc_el.setAttribute('jid', muc_jid); + span_el.appendChild(muc_el); + + const body = document.querySelector('body'); + body.appendChild(span_el); + await u.waitUntil(() => muc_el.querySelector('converse-muc-bottom-panel')); + body.removeChild(span_el); + expect(true).toBe(true); + })); + + it("will update correctly when the jid property changes", + mock.initConverse([], {'auto_insert': false}, async function (_converse) { + + const { api } = _converse; + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + + + const muc_creation_promise = api.rooms.open(muc_jid, {nick, 'hidden': true}, false); + await mock.getRoomFeatures(_converse, muc_jid, []); + await mock.receiveOwnMUCPresence(_converse, muc_jid, nick); + await muc_creation_promise; + const model = _converse.chatboxes.get(muc_jid); + await u.waitUntil(() => (model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + const affs = api.settings.get('muc_fetch_members'); + const all_affiliations = Array.isArray(affs) ? affs : (affs ? ['member', 'admin', 'owner'] : []); + await mock.returnMemberLists(_converse, muc_jid, [], all_affiliations); + await model.messages.fetched; + + model.sendMessage({'body': 'hello from the lounge!'}); + + const span_el = document.createElement('span'); + span_el.classList.add('conversejs'); + span_el.classList.add('converse-embedded'); + + + const muc_el = document.createElement('converse-muc'); + muc_el.classList.add('chatbox'); + muc_el.classList.add('chatroom'); + muc_el.setAttribute('jid', muc_jid); + span_el.appendChild(muc_el); + + const body = document.querySelector('body'); + body.appendChild(span_el); + await u.waitUntil(() => muc_el.querySelector('converse-muc-bottom-panel')); + muc_el.querySelector('.box-flyout').setAttribute('style', 'height: 80vh'); + + const message = await u.waitUntil(() => muc_el.querySelector('converse-chat-message')); + expect(message.model.get('body')).toBe('hello from the lounge!'); + + _converse.connection.sent_stanzas = []; + + const muc2_jid = 'bar@montague.lit'; + const muc2_creation_promise = api.rooms.open(muc2_jid, {nick, 'hidden': true}, false); + await mock.getRoomFeatures(_converse, muc2_jid, []); + await mock.receiveOwnMUCPresence(_converse, muc2_jid, nick); + await muc2_creation_promise; + const model2 = _converse.chatboxes.get(muc2_jid); + await u.waitUntil(() => (model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + await mock.returnMemberLists(_converse, muc2_jid, [], all_affiliations); + await model.messages.fetched; + + model2.sendMessage({'body': 'hello from the bar!'}); + muc_el.setAttribute('jid', muc2_jid); + + await u.waitUntil(() => muc_el.querySelector('converse-chat-message-body').textContent.trim() === 'hello from the bar!'); + body.removeChild(span_el); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/corrections.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/corrections.js new file mode 100644 index 0000000..bd2d401 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/corrections.js @@ -0,0 +1,427 @@ +/*global mock, converse */ + +const { $msg, $pres, Strophe, u, stx } = converse.env; + +describe("A Groupchat Message", function () { + + it("can be replaced with a correction", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const stanza = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }).tree(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + const msg_id = u.getUniqueId(); + await model.handleMessageStanza($msg({ + 'from': 'lounge@montague.lit/newguy', + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'id': msg_id, + }).c('body').t('But soft, what light through yonder airlock breaks?').tree()); + + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').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 view.model.handleMessageStanza($msg({ + 'from': 'lounge@montague.lit/newguy', + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'id': u.getUniqueId(), + }).c('body').t('But soft, what light through yonder chimney breaks?').up() + .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree()); + await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent === + 'But soft, what light through yonder chimney breaks?', 500); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + await u.waitUntil(() => view.querySelector('.chat-msg__content .fa-edit')); + + await view.model.handleMessageStanza($msg({ + 'from': 'lounge@montague.lit/newguy', + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'id': u.getUniqueId(), + }).c('body').t('But soft, what light through yonder window breaks?').up() + .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree()); + + await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent === + 'But soft, what light through yonder window breaks?', 500); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1); + const edit = await u.waitUntil(() => view.querySelector('.chat-msg__content .fa-edit')); + 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(older_msgs[1].textContent.includes('But soft, what light through yonder chimney breaks?')).toBe(true); + })); + + it("keeps the same position in history after a correction", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const stanza = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }).tree(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + const msg_id = u.getUniqueId(); + + // Receiving the first message + await view.model.handleMessageStanza($msg({ + 'from': 'lounge@montague.lit/newguy', + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'id': msg_id, + }).c('body').t('But soft, what light through yonder airlock breaks?').tree()); + + // Receiving own message to check order against + await view.model.handleMessageStanza($msg({ + 'from': 'lounge@montague.lit/romeo', + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'id': u.getUniqueId(), + }).c('body').t('But soft, what light through yonder airlock breaks?').tree()); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + expect(view.querySelectorAll('.chat-msg').length).toBe(2); + expect(view.querySelectorAll('.chat-msg__text')[0].textContent) + .toBe('But soft, what light through yonder airlock breaks?'); + expect(view.querySelectorAll('.chat-msg__text')[1].textContent) + .toBe('But soft, what light through yonder airlock breaks?'); + + // First message correction + await view.model.handleMessageStanza($msg({ + 'from': 'lounge@montague.lit/newguy', + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'id': u.getUniqueId(), + }).c('body').t('But soft, what light through yonder chimney breaks?').up() + .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree()); + + await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent === + 'But soft, what light through yonder chimney breaks?', 500); + expect(view.querySelectorAll('.chat-msg').length).toBe(2); + await u.waitUntil(() => view.querySelector('.chat-msg__content .fa-edit')); + + // Second message correction + await view.model.handleMessageStanza($msg({ + 'from': 'lounge@montague.lit/newguy', + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'id': u.getUniqueId(), + }).c('body').t('But soft, what light through yonder window breaks?').up() + .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree()); + + // Second own message + await view.model.handleMessageStanza($msg({ + 'from': 'lounge@montague.lit/romeo', + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'id': u.getUniqueId(), + }).c('body').t('But soft, what light through yonder window breaks?').tree()); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text')[0].textContent === + 'But soft, what light through yonder window breaks?', 500); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text')[2].textContent === + 'But soft, what light through yonder window breaks?', 500); + + expect(view.querySelectorAll('.chat-msg').length).toBe(3); + expect(view.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1); + const edit = await u.waitUntil(() => view.querySelector('.chat-msg__content .fa-edit')); + 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(older_msgs[1].textContent.includes('But soft, what light through yonder chimney breaks?')).toBe(true); + })); + + it("can be sent as a correction by using the up arrow", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea')); + expect(textarea.value).toBe(''); + const message_form = view.querySelector('converse-muc-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').length === 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'))); + + spyOn(_converse.connection, 'send'); + const 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(() => Array.from(view.querySelectorAll('.chat-msg__text')) + .filter(m => m.textContent.replace(/<!-.*?->/g, '') === new_text).length); + + 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="lounge@montague.lit" type="groupchat" `+ + `xmlns="jabber:client">`+ + `<body>But soft, what light through yonder window breaks?</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+ + `<origin-id id="${msg.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); + expect(u.hasClass('correcting', view.querySelector('.chat-msg'))).toBe(false); + + // Check that messages from other users are skipped + await view.model.handleMessageStanza($msg({ + 'from': muc_jid+'/someone-else', + 'id': u.getUniqueId(), + 'to': 'romeo@montague.lit', + 'type': 'groupchat' + }).c('body').t('Hello world').tree()); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); + expect(view.querySelectorAll('.chat-msg').length).toBe(2); + + // Test that pressing the down arrow cancels message correction + expect(textarea.value).toBe(''); + 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(2); + 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(2); + await u.waitUntil(() => !u.hasClass('correcting', view.querySelector('.chat-msg')), 500); + })); +}); + + +describe('A Groupchat Message XEP-0308 correction ', function () { + it( + "is ignored if it's from a different occupant-id", + mock.initConverse([], {}, async function (_converse) { + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.OCCUPANTID]; + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + + const msg_id = u.getUniqueId(); + await model.handleMessageStanza( + stx` + <message + xmlns="jabber:server" + from="lounge@montague.lit/newguy" + to="_converse.connection.jid" + type="groupchat" + id="${msg_id}"> + + <body>But soft, what light through yonder airlock breaks?</body> + <occupant-id xmlns="urn:xmpp:occupant-id:0" id="1"></occupant-id> + </message>` + ); + + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + expect(model.messages.at(0).get('body')).toBe('But soft, what light through yonder airlock breaks?'); + + await model.handleMessageStanza( + stx` + <message + xmlns="jabber:server" + from="lounge@montague.lit/newguy" + to="_converse.connection.jid" + type="groupchat" + id="${u.getUniqueId()}"> + + <body>But soft, what light through yonder chimney breaks?</body> + <occupant-id xmlns="urn:xmpp:occupant-id:0" id="2"></occupant-id> + <replace id="${msg_id}" xmlns="urn:xmpp:message-correct:0"></replace> + </message>` + ); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + expect(model.messages.length).toBe(2); + expect(model.messages.at(0).get('body')).toBe('But soft, what light through yonder airlock breaks?'); + expect(model.messages.at(0).get('edited')).toBeFalsy(); + + expect(model.messages.at(1).get('body')).toBe('But soft, what light through yonder chimney breaks?'); + expect(model.messages.at(1).get('edited')).toBeTruthy(); + + await model.handleMessageStanza( + stx` + <message + xmlns="jabber:server" + from="lounge@montague.lit/newguy" + to="_converse.connection.jid" + type="groupchat" + id="${u.getUniqueId()}"> + + <body>But soft, what light through yonder hatch breaks?</body> + <replace id="${msg_id}" xmlns="urn:xmpp:message-correct:0"></replace> + </message>` + ); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 3); + expect(model.messages.length).toBe(3); + expect(model.messages.at(0).get('body')).toBe('But soft, what light through yonder airlock breaks?'); + expect(model.messages.at(0).get('edited')).toBeFalsy(); + + expect(model.messages.at(1).get('body')).toBe('But soft, what light through yonder chimney breaks?'); + expect(model.messages.at(1).get('edited')).toBeTruthy(); + + expect(model.messages.at(2).get('body')).toBe('But soft, what light through yonder hatch breaks?'); + expect(model.messages.at(2).get('edited')).toBeTruthy(); + + const message_els = Array.from(view.querySelectorAll('.chat-msg')); + expect(message_els.reduce((acc, m) => acc && u.hasClass('chat-msg--followup', m), true)).toBe(false); + }) + ); + + it( + "cannot be edited if it's from a different occupant id", + mock.initConverse([], {}, async function (_converse) { + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.OCCUPANTID]; + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, features); + + expect(model.get('occupant_id')).toBe(model.occupants.at(0).get('occupant_id')); + + const msg_id = u.getUniqueId(); + await model.handleMessageStanza( + stx` + <message + xmlns="jabber:server" + from="lounge@montague.lit/${nick}" + to="_converse.connection.jid" + type="groupchat" + id="${msg_id}"> + + <body>But soft, what light through yonder airlock breaks?</body> + <occupant-id xmlns="urn:xmpp:occupant-id:0" id="${model.get('occupant_id')}"></occupant-id> + </message>` + ); + + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + expect(model.messages.at(0).get('body')).toBe('But soft, what light through yonder airlock breaks?'); + + await model.handleMessageStanza( + stx` + <message + xmlns="jabber:server" + from="lounge@montague.lit/${nick}" + to="_converse.connection.jid" + type="groupchat" + id="${u.getUniqueId()}"> + + <body>But soft, what light through yonder chimney breaks?</body> + <occupant-id xmlns="urn:xmpp:occupant-id:0" id="${model.get('occupant_id')}"></occupant-id> + <replace id="${msg_id}" xmlns="urn:xmpp:message-correct:0"></replace> + </message>` + ); + + expect(model.messages.at(0).get('body')).toBe('But soft, what light through yonder chimney breaks?'); + expect(model.messages.at(0).get('edited')).toBeTruthy(); + + await model.handleMessageStanza( + stx` + <message + xmlns="jabber:server" + from="lounge@montague.lit/${nick}" + to="_converse.connection.jid" + type="groupchat" + id="${u.getUniqueId()}"> + + <body>But soft, what light through yonder hatch breaks?</body> + <occupant-id xmlns="urn:xmpp:occupant-id:0" id="${u.getUniqueId()}"></occupant-id> + <replace id="${msg_id}" xmlns="urn:xmpp:message-correct:0"></replace> + </message>` + ); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + expect(model.messages.length).toBe(2); + expect(model.messages.at(0).get('body')).toBe('But soft, what light through yonder chimney breaks?'); + expect(model.messages.at(0).get('edited')).toBeTruthy(); + expect(model.messages.at(0).get('editable')).toBeTruthy(); + + expect(model.messages.at(1).get('body')).toBe('But soft, what light through yonder hatch breaks?'); + expect(model.messages.at(1).get('edited')).toBeTruthy(); + expect(model.messages.at(1).get('editable')).toBeFalsy(); + + const message_els = Array.from(view.querySelectorAll('.chat-msg')); + expect(message_els.reduce((acc, m) => acc && u.hasClass('chat-msg--followup', m), true)).toBe(false); + + // We can edit our own message, but not the other + expect(message_els[0].querySelector('converse-dropdown .chat-msg__action-edit')).toBeDefined(); + expect(message_els[1].querySelector('converse-dropdown .chat-msg__action-edit')).toBe(null); + }) + ); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/disco.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/disco.js new file mode 100644 index 0000000..13376b0 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/disco.js @@ -0,0 +1,66 @@ +/*global mock, converse */ + +describe("Service Discovery", function () { + + it("can be used to set the muc_domain", mock.initConverse( ['discoInitialized'], {}, async function (_converse) { + const { u, $iq } = converse.env; + const IQ_stanzas = _converse.connection.IQ_stanzas; + const IQ_ids = _converse.connection.IQ_ids; + const { api } = _converse; + + expect(api.settings.get('muc_domain')).toBe(undefined); + + await u.waitUntil(() => IQ_stanzas.filter( + (iq) => iq.querySelector(`iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]`)).length > 0 + ); + + 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('identity', { 'category': 'conference', 'name': 'Play-Specific Chatrooms'}).up() + .c('feature', { 'var': 'http://jabber.org/protocol/disco#info'}).up() + .c('feature', { 'var': 'http://jabber.org/protocol/disco#items'}).up(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + + stanza = await u.waitUntil(() => IQ_stanzas.filter( + iq => iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]')).pop() + ); + + _converse.connection._dataRecv(mock.createRequest($iq({ + 'type': 'result', + 'from': 'montague.lit', + 'to': 'romeo@montague.lit/orchard', + 'id': IQ_ids[IQ_stanzas.indexOf(stanza)] + }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'}) + .c('item', { 'jid': 'chat.shakespeare.lit', 'name': 'Chatroom Service'}))); + + stanza = await u.waitUntil(() => IQ_stanzas.filter( + iq => iq.querySelector('iq[to="chat.shakespeare.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]')).pop() + ); + _converse.connection._dataRecv(mock.createRequest($iq({ + 'type': 'result', + 'from': 'chat.shakespeare.lit', + 'to': 'romeo@montague.lit/orchard', + 'id': IQ_ids[IQ_stanzas.indexOf(stanza)] + }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', { 'category': 'conference', 'name': 'Play-Specific Chatrooms', 'type': 'text'}).up() + .c('feature', { 'var': 'http://jabber.org/protocol/muc'}).up())); + + const entities = await _converse.api.disco.entities.get(); + expect(entities.length).toBe(3); // We have an extra entity, which is the user's JID + expect(entities.get(_converse.domain).identities.length).toBe(2); + expect(entities.get('montague.lit').features.where( + {'var': 'http://jabber.org/protocol/disco#items'}).length).toBe(1); + expect(entities.get('montague.lit').features.where( + {'var': 'http://jabber.org/protocol/disco#info'}).length).toBe(1); + + await u.waitUntil(() => _converse.api.settings.get('muc_domain') === 'chat.shakespeare.lit'); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/emojis.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/emojis.js new file mode 100644 index 0000000..eb85da5 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/emojis.js @@ -0,0 +1,226 @@ +/*global mock, converse */ + +const { $pres, sizzle } = converse.env; +const u = converse.env.utils; + +describe("Emojis", function () { + + describe("The emoji picker", function () { + + it("is opened to autocomplete emojis in the textarea", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.querySelector('converse-emoji-picker')); + const textarea = view.querySelector('textarea.chat-textarea'); + textarea.value = ':gri'; + + // Press tab + const tab_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 9, + 'key': 'Tab' + } + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(tab_event); + await u.waitUntil(() => view.querySelector('converse-emoji-picker .emoji-search')?.value === ':gri'); + await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji', view).length === 3, 1000); + let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', view); + expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:'); + expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':grin:'); + expect(visible_emojis[2].getAttribute('data-emoji')).toBe(':grinning:'); + + const picker = view.querySelector('converse-emoji-picker'); + const input = picker.querySelector('.emoji-search'); + // Test that TAB autocompletes the to first match + input.dispatchEvent(new KeyboardEvent('keydown', tab_event)); + + await u.waitUntil(() => sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker).length === 1, 1000); + visible_emojis = sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker); + expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:'); + expect(input.value).toBe(':grimacing:'); + + // Check that ENTER now inserts the match + const enter_event = Object.assign({}, tab_event, {'keyCode': 13, 'key': 'Enter', 'target': input, 'bubbles': true}); + input.dispatchEvent(new KeyboardEvent('keydown', enter_event)); + + await u.waitUntil(() => input.value === ''); + await u.waitUntil(() => textarea.value === ':grimacing: '); + + // Test that username starting with : doesn't cause issues + const presence = $pres({ + 'from': `${muc_jid}/:username`, + 'id': '27C55F89-1C6A-459A-9EB5-77690145D624', + 'to': _converse.jid + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'some1@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + textarea.value = ':use'; + message_form.onKeyDown(tab_event); + await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists'))); + await u.waitUntil(() => input.value === ':use'); + visible_emojis = sizzle('.insert-emoji:not(.hidden)', picker); + expect(visible_emojis.length).toBe(0); + })); + + it("is focused to autocomplete emojis in the textarea", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.waitForRoster(_converse, 'current', 0); + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.querySelector('converse-emoji-picker')); + const textarea = view.querySelector('textarea.chat-textarea'); + textarea.value = ':'; + // Press tab + const tab_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 9, + 'key': 'Tab' + } + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(tab_event); + await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists'))); + + const picker = view.querySelector('converse-emoji-picker'); + const input = picker.querySelector('.emoji-search'); + expect(input.value).toBe(':'); + input.value = ':gri'; + const event = { + 'target': input, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {} + }; + input.dispatchEvent(new KeyboardEvent('keydown', event)); + await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji', view).length === 3, 1000); + let emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop(); + emoji.click(); + await u.waitUntil(() => textarea.value === ':grinning: '); + textarea.value = ':grinning: :'; + message_form.onKeyDown(tab_event); + + await u.waitUntil(() => input.value === ':'); + input.value = ':grimacing'; + input.dispatchEvent(new KeyboardEvent('keydown', event)); + await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji', view).length === 1, 1000); + emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop(); + emoji.click(); + await u.waitUntil(() => textarea.value === ':grinning: :grimacing: '); + })); + + + it("properly inserts emojis into the chat textarea", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.waitForRoster(_converse, 'current', 0); + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.querySelector('converse-emoji-picker')); + const textarea = view.querySelector('textarea.chat-textarea'); + textarea.value = ':gri'; + + // Press tab + const tab_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 9, + 'key': 'Tab' + } + textarea.value = ':'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(tab_event); + await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists'))); + const picker = view.querySelector('converse-emoji-picker'); + const input = picker.querySelector('.emoji-search'); + input.dispatchEvent(new KeyboardEvent('keydown', tab_event)); + await u.waitUntil(() => input.value === ':100:'); + const enter_event = Object.assign({}, tab_event, {'keyCode': 13, 'key': 'Enter', 'target': input, 'bubbles': true}); + input.dispatchEvent(new KeyboardEvent('keydown', enter_event)); + expect(textarea.value).toBe(':100: '); + + textarea.value = ':'; + message_form.onKeyDown(tab_event); + await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists'))); + await u.waitUntil(() => input.value === ':'); + input.dispatchEvent(new KeyboardEvent('keydown', tab_event)); + await u.waitUntil(() => input.value === ':100:'); + await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 1, 1000); + const emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop(); + emoji.click(); + expect(textarea.value).toBe(':100: '); + })); + + + it("allows you to search for particular emojis", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.waitForRoster(_converse, 'current', 0); + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.querySelector('converse-emoji-dropdown')); + const toolbar = view.querySelector('converse-chat-toolbar'); + toolbar.querySelector('.toggle-emojis').click(); + await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists'))); + await u.waitUntil(() => sizzle('converse-chat-toolbar .insert-emoji:not(.hidden)', view).length === 1589); + + const input = view.querySelector('.emoji-search'); + input.value = 'smiley'; + const event = { + 'target': input, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {} + }; + input.dispatchEvent(new KeyboardEvent('keydown', event)); + + await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 2, 1000); + let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view); + expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:'); + expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':smiley_cat:'); + + // Check that pressing enter without an unambiguous match does nothing + const enter_event = Object.assign({}, event, {'keyCode': 13, 'bubbles': true}); + input.dispatchEvent(new KeyboardEvent('keydown', enter_event)); + expect(input.value).toBe('smiley'); + + // Check that search results update when chars are deleted + input.value = 'sm'; + input.dispatchEvent(new KeyboardEvent('keydown', event)); + await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 25, 1000); + + input.value = 'smiley'; + input.dispatchEvent(new KeyboardEvent('keydown', event)); + await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 2, 1000); + + // Test that TAB autocompletes the to first match + const tab_event = Object.assign({}, event, {'keyCode': 9, 'key': 'Tab'}); + input.dispatchEvent(new KeyboardEvent('keydown', tab_event)); + + await u.waitUntil(() => input.value === ':smiley:'); + await u.waitUntil(() => sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", view).length === 1, 1000); + visible_emojis = sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", view); + expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:'); + + // Check that ENTER now inserts the match + input.dispatchEvent(new KeyboardEvent('keydown', enter_event)); + await u.waitUntil(() => input.value === ''); + expect(view.querySelector('textarea.chat-textarea').value).toBe(':smiley: '); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/hats.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/hats.js new file mode 100644 index 0000000..dd8a398 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/hats.js @@ -0,0 +1,74 @@ +/*global mock, converse */ + +const u = converse.env.utils; + +describe("A XEP-0317 MUC Hat", function () { + + it("can be included in a presence stanza", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const hat1_id = u.getUniqueId(); + const hat2_id = u.getUniqueId(); + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="member" role="participant"/> + </x> + <hats xmlns="xmpp:prosody.im/protocol/hats:1"> + <hat title="Teacher's Assistant" id="${hat1_id}"/> + <hat title="Dark Mage" id="${hat2_id}"/> + </hats> + </presence> + `))); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo and Terry have entered the groupchat"); + + let hats = view.model.getOccupant("Terry").get('hats'); + expect(hats.length).toBe(2); + expect(hats.map(h => h.title).join(' ')).toBe("Teacher's Assistant Dark Mage"); + + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + <message type="groupchat" from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}"> + <body>Hello world</body> + </message> + `))); + + const msg_el = await u.waitUntil(() => view.querySelector('.chat-msg')); + let badges = Array.from(msg_el.querySelectorAll('.badge')); + expect(badges.length).toBe(2); + expect(badges.map(b => b.textContent.trim()).join(' ' )).toBe("Teacher's Assistant Dark Mage"); + + const hat3_id = u.getUniqueId(); + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="member" role="participant"/> + </x> + <hats xmlns="xmpp:prosody.im/protocol/hats:1"> + <hat title="Teacher's Assistant" id="${hat1_id}"/> + <hat title="Dark Mage" id="${hat2_id}"/> + <hat title="Mad hatter" id="${hat3_id}"/> + </hats> + </presence> + `))); + + await u.waitUntil(() => view.model.getOccupant("Terry").get('hats').length === 3); + hats = view.model.getOccupant("Terry").get('hats'); + expect(hats.map(h => h.title).join(' ')).toBe("Teacher's Assistant Dark Mage Mad hatter"); + await u.waitUntil(() => view.querySelectorAll('.chat-msg .badge').length === 3, 1000); + badges = Array.from(view.querySelectorAll('.chat-msg .badge')); + expect(badges.map(b => b.textContent.trim()).join(' ' )).toBe("Teacher's Assistant Dark Mage Mad hatter"); + + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="member" role="participant"/> + </x> + </presence> + `))); + await u.waitUntil(() => view.model.getOccupant("Terry").get('hats').length === 0); + await u.waitUntil(() => view.querySelectorAll('.chat-msg .badge').length === 0); + })); +}) diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/http-file-upload.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/http-file-upload.js new file mode 100644 index 0000000..ddbc7ea --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/http-file-upload.js @@ -0,0 +1,152 @@ +/*global mock, converse */ + +const { Strophe, sizzle, u } = converse.env; + + +describe("XEP-0363: HTTP File Upload", function () { + + describe("When not supported", function () { + describe("A file upload toolbar button", function () { + + it("does not appear in MUC chats", mock.initConverse([], {}, async (_converse) => { + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + 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('lounge@montague.lit'); + await u.waitUntil(() => view.querySelector('.chat-toolbar .fileupload') === null); + expect(1).toBe(1); + })); + + }); + }); + + describe("When supported", function () { + + describe("A file upload toolbar button", function () { + + it("appears in MUC chats", mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => { + 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], []); + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + await u.waitUntil(() => _converse.chatboxviews.get('lounge@montague.lit').querySelector('.fileupload')); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + expect(view.querySelector('.chat-toolbar .fileupload')).not.toBe(null); + })); + + describe("when clicked and a file chosen", function () { + + it("is uploaded and sent out from a groupchat", 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.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + + // Wait until MAM query has been sent out + const sent_stanzas = _converse.connection.sent_stanzas; + await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`[xmlns="${Strophe.NS.MAM}"]`, s).length).pop()); + + const view = _converse.chatboxviews.get('lounge@montague.lit'); + 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="lounge@montague.lit" `+ + `type="groupchat" `+ + `xmlns="jabber:client">`+ + `<body>${message}</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<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>`); + + expect(view.querySelector('.chat-msg .chat-msg__media')).toBe(null); + XMLHttpRequest.prototype.send = send_backup; + })); + + + }); + }); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/info-messages.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/info-messages.js new file mode 100644 index 0000000..ad88e70 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/info-messages.js @@ -0,0 +1,72 @@ +/*global mock, converse */ + +const u = converse.env.utils; + +describe("an info message", function () { + + it("is not rendered as a followup message", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + let presence = u.toStanza(` + <presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <status code="201"/> + <item role="moderator" affiliation="owner" jid="${_converse.jid}"/> + <status code="110"/> + </x> + </presence> + `); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1); + + presence = u.toStanza(` + <presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo1"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <status code="210"/> + <item role="moderator" affiliation="owner" jid="${_converse.jid}"/> + <status code="110"/> + </x> + </presence> + `); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 2); + + const messages = view.querySelectorAll('.chat-info'); + expect(u.hasClass('chat-msg--followup', messages[0])).toBe(false); + expect(u.hasClass('chat-msg--followup', messages[1])).toBe(false); + })); + + it("is not shown if its a duplicate", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const presence = u.toStanza(` + <presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <status code="201"/> + <item role="moderator" affiliation="owner" jid="${_converse.jid}"/> + <status code="110"/> + </x> + </presence> + `); + // XXX: We wait for createInfoMessages to complete, if we don't + // we still get two info messages due to messages + // created from presences not being queued and run + // sequentially (i.e. by waiting for promises to resolve) + // like we do with message stanzas. + spyOn(view.model, 'createInfoMessages').and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.model.createInfoMessages.calls.count()); + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1); + + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.model.createInfoMessages.calls.count() === 2); + expect(view.querySelectorAll('.chat-info').length).toBe(1); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mam.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mam.js new file mode 100644 index 0000000..9f46e3e --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mam.js @@ -0,0 +1,193 @@ +/*global mock, converse */ + +const { Strophe, $msg, $pres } = converse.env; +const u = converse.env.utils; + +describe("A MAM archived message", function () { + + it("will appear in the correct order", + mock.initConverse([], {}, async function (_converse) { + + const nick = 'romeo'; + const muc_jid = 'room@muc.example.com'; + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + + const messages = [ + u.toStanza(` + <message to="${_converse.connection.jid}" from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="c03f0f53-8501-4ed9-9261-2eddd055486c" id="9fe1a9d9-c979-488c-93a4-8a3c4dcbc63e"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2021-10-13T17:51:20Z"/> + <message xmlns="jabber:client" xml:lang="en" from="${muc_jid}/dadmin" type="groupchat" id="bc4caee0-380a-4f08-b20b-9015177a95bb"> + <body>first message</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="bc4caee0-380a-4f08-b20b-9015177a95bb"/> + </message> + </forwarded> + </result> + </message>`), + + u.toStanza(` + <message to="${_converse.connection.jid}" from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="c03f0f53-8501-4ed9-9261-2eddd055486c" id="64f68d52-76e6-4fa6-93ef-9fbf96bb237b"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2021-10-13T17:51:25Z"/> + <message xmlns="jabber:client" xml:lang="en" from="${muc_jid}/dadmin" type="groupchat" id="7aae4842-6a8b-4a10-a9c4-47cc408650ef"> + <body>2nd message</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="7aae4842-6a8b-4a10-a9c4-47cc408650ef"/> + </message> + </forwarded> + </result> + </message>`), + + u.toStanza(` + <message to="${_converse.connection.jid}" from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="c03f0f53-8501-4ed9-9261-2eddd055486c" id="c2c07703-b285-4529-a4b4-12594f749c58"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2021-10-13T17:52:17Z"/> + <message xmlns="jabber:client" from="${muc_jid}" type="groupchat" id="hDs1J0QHfimjggw2"> + <store xmlns="urn:xmpp:hints"/> + <event xmlns="http://jabber.org/protocol/pubsub#event"> + <items node="urn:ietf:params:xml:ns:conference-info"> + <item id="wGkBOwEymL2l10Fj"> + <conference-info xmlns="urn:ietf:params:xml:ns:conference-info"> + <activity xmlns="http://jabber.org/protocol/activity"> + <other/> + <text id="activity-text">An anonymous user has tipped romeo 1 karma</text> + <reason>Thanks for your help the other day</reason> + </activity> + </conference-info> + </item> + </items> + </event> + </message> + </forwarded> + </result> + </message>`), + + u.toStanza(` + <message to="${_converse.connection.jid}" from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="c03f0f53-8501-4ed9-9261-2eddd055486c" id="c2b2b039-f808-4b4c-bfbd-607173e012f9"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2021-10-13T17:52:22Z"/> + <message xmlns="jabber:client" xml:lang="en" from="${muc_jid}/dadmin" type="groupchat" id="ae0ab34c-4ff1-45c0-ab56-5231cc220424"> + <body>4th message</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="ae0ab34c-4ff1-45c0-ab56-5231cc220424"/> + </message> + </forwarded> + </result> + </message>`) + ] + spyOn(model, 'updateMessage'); + _converse.handleMAMResult(model, { messages }); + + await u.waitUntil(() => model.messages.length === 4); + expect(model.messages.at(0).get('time')).toBe('2021-10-13T17:51:20.000Z'); + expect(model.messages.at(1).get('time')).toBe('2021-10-13T17:51:25.000Z'); + expect(model.messages.at(2).get('time')).toBe('2021-10-13T17:52:17.000Z'); + expect(model.messages.at(3).get('time')).toBe('2021-10-13T17:52:22.000Z'); + })); + + it("is ignored if it has the same archive-id of an already received one", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'room@muc.example.com'; + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + spyOn(model, 'getDuplicateMessage').and.callThrough(); + let stanza = u.toStanza(` + <message xmlns="jabber:client" + from="room@muc.example.com/some1" + to="${_converse.connection.jid}" + type="groupchat"> + <body>Typical body text</body> + <stanza-id xmlns="urn:xmpp:sid:0" + id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad" + by="room@muc.example.com"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => model.messages.length === 1); + await u.waitUntil(() => model.getDuplicateMessage.calls.count() === 1); + let result = await model.getDuplicateMessage.calls.all()[0].returnValue; + expect(result).toBe(undefined); + + stanza = u.toStanza(` + <message xmlns="jabber:client" + to="${_converse.connection.jid}" + from="room@muc.example.com"> + <result xmlns="urn:xmpp:mam:2" queryid="82d9db27-6cf8-4787-8c2c-5a560263d823" id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:17:23Z"/> + <message from="room@muc.example.com/some1" type="groupchat"> + <body>Typical body text</body> + </message> + </forwarded> + </result> + </message>`); + + spyOn(model, 'updateMessage'); + _converse.handleMAMResult(model, { 'messages': [stanza] }); + await u.waitUntil(() => model.getDuplicateMessage.calls.count() === 2); + result = await model.getDuplicateMessage.calls.all()[1].returnValue; + expect(result instanceof _converse.Message).toBe(true); + expect(model.messages.length).toBe(1); + await u.waitUntil(() => model.updateMessage.calls.count()); + })); + + 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); + const muc_jid = 'xsf@muc.xmpp.org'; + const sender_jid = `${muc_jid}/romeo`; + const impersonated_jid = `${muc_jid}/i_am_groot` + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const stanza = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: sender_jid + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'owner', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }).tree(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + /* + * <message to="romeo@montague.im/poezio" id="718d40df-3948-4798-a99b-35cc9f03cc4f-641" type="groupchat" from="xsf@muc.xmpp.org/romeo"> + * <received xmlns="urn:xmpp:carbons:2"> + * <forwarded xmlns="urn:xmpp:forward:0"> + * <message xmlns="jabber:client" to="xsf@muc.xmpp.org" type="groupchat" from="xsf@muc.xmpp.org/i_am_groot"> + * <body>I am groot.</body> + * </message> + * </forwarded> + * </received> + * </message> + */ + const msg = $msg({ + 'from': sender_jid, + 'id': _converse.connection.getUniqueId(), + 'to': _converse.connection.jid, + 'type': 'groupchat', + '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': muc_jid, + 'type': 'groupchat' + }).c('body').t('I am groot').tree(); + const view = _converse.chatboxviews.get(muc_jid); + spyOn(converse.env.log, 'error'); + await _converse.handleMAMResult(model, { 'messages': [msg] }); + await u.waitUntil(() => converse.env.log.error.calls.count()); + expect(converse.env.log.error).toHaveBeenCalledWith( + 'Invalid Stanza: MUC messages SHOULD NOT be XEP-0280 carbon copied' + ); + expect(view.querySelectorAll('.chat-msg').length).toBe(0); + expect(model.messages.length).toBe(0); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/markers.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/markers.js new file mode 100644 index 0000000..f432a2b --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/markers.js @@ -0,0 +1,68 @@ +/*global mock, converse */ + +const u = converse.env.utils; +// See: https://xmpp.org/rfcs/rfc3921.html + + +describe("A XEP-0333 Chat Marker", function () { + it("may be returned for a MUC message", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = 'But soft, what light through yonder airlock breaks?'; + const message_form = view.querySelector('converse-muc-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 .chat-msg__text').textContent.trim()) + .toBe("But soft, what light through yonder airlock breaks?"); + + const msg_obj = view.model.messages.at(0); + let stanza = u.toStanza(` + <message xml:lang="en" to="romeo@montague.lit/orchard" + from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client"> + <received xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0); + + stanza = u.toStanza(` + <message xml:lang="en" to="romeo@montague.lit/orchard" + from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client"> + <displayed xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0); + + stanza = u.toStanza(` + <message xml:lang="en" to="romeo@montague.lit/orchard" + from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client"> + <acknowledged xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0); + + stanza = u.toStanza(` + <message xml:lang="en" to="romeo@montague.lit/orchard" + from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client"> + <body>'tis I!</body> + <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/me-messages.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/me-messages.js new file mode 100644 index 0000000..b42e00c --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/me-messages.js @@ -0,0 +1,56 @@ +/*global mock, converse */ + +const { u, sizzle, $msg } = converse.env; + + +describe("A Groupchat Message", function () { + + it("supports the /me command", mock.initConverse([], {}, async function (_converse) { + await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']); + await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname')); + await mock.waitForRoster(_converse, 'current'); + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + if (!view.querySelectorAll('.chat-area').length) { + view.renderChatArea(); + } + let message = '/me is tired'; + const nick = mock.chatroom_names[0]; + let msg = $msg({ + 'from': 'lounge@montague.lit/'+nick, + 'id': u.getUniqueId(), + 'to': 'romeo@montague.lit', + 'type': 'groupchat' + }).c('body').t(message).tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => sizzle('.chat-msg:last .chat-msg__text', view).pop()); + await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.trim() === 'is tired'); + expect(view.querySelector('.chat-msg__author').textContent.includes('**Dyon van de Wege')).toBeTruthy(); + + message = '/me is as well'; + msg = $msg({ + from: 'lounge@montague.lit/Romeo Montague', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(message).tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text')).pop().textContent.trim() === 'is as well'); + expect(sizzle('.chat-msg__author:last', view).pop().textContent.includes('**Romeo Montague')).toBeTruthy(); + + // Check rendering of a mention inside a me message + const msg_text = "/me mentions romeo"; + msg = $msg({ + from: 'lounge@montague.lit/gibson', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(msg_text).up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'13', 'end':'19', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).nodeTree; + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3); + await u.waitUntil(() => sizzle('.chat-msg__text:last', view).pop().innerHTML.replace(/<!-.*?->/g, '') === + 'mentions <span class="mention mention--self badge badge-info" data-uri="xmpp:romeo@montague.lit">romeo</span>'); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/member-lists.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/member-lists.js new file mode 100644 index 0000000..fc0b9d1 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/member-lists.js @@ -0,0 +1,301 @@ +/*global mock, converse */ +const { $iq, Strophe, u } = converse.env; + +describe("A Groupchat", function () { + + describe("upon being entered", function () { + + it("will fetch the member list if muc_fetch_members is true", + mock.initConverse([], {'muc_fetch_members': true}, async function (_converse) { + + const { api } = _converse; + let sent_IQs = _converse.connection.IQ_stanzas; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + let view = _converse.chatboxviews.get(muc_jid); + expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(3); + + // Check in reverse order that we requested all three lists + const owner_iq = sent_IQs.pop(); + expect(Strophe.serialize(owner_iq)).toBe( + `<iq id="${owner_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="owner"/></query>`+ + `</iq>`); + + const admin_iq = sent_IQs.pop(); + expect(Strophe.serialize(admin_iq)).toBe( + `<iq id="${admin_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="admin"/></query>`+ + `</iq>`); + + const member_iq = sent_IQs.pop(); + expect(Strophe.serialize(member_iq)).toBe( + `<iq id="${member_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="member"/></query>`+ + `</iq>`); + view.close(); + + _converse.connection.IQ_stanzas = []; + sent_IQs = _converse.connection.IQ_stanzas; + api.settings.set('muc_fetch_members', false); + await mock.openAndEnterChatRoom(_converse, 'orchard@montague.lit', 'romeo'); + view = _converse.chatboxviews.get('orchard@montague.lit'); + expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(0); + await view.close(); + + _converse.connection.IQ_stanzas = []; + sent_IQs = _converse.connection.IQ_stanzas; + api.settings.set('muc_fetch_members', ['admin']); + await mock.openAndEnterChatRoom(_converse, 'courtyard@montague.lit', 'romeo'); + view = _converse.chatboxviews.get('courtyard@montague.lit'); + expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(1); + expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation="admin"]')).length).toBe(1); + view.close(); + + _converse.connection.IQ_stanzas = []; + sent_IQs = _converse.connection.IQ_stanzas; + api.settings.set('muc_fetch_members', ['owner']); + await mock.openAndEnterChatRoom(_converse, 'garden@montague.lit', 'romeo'); + view = _converse.chatboxviews.get('garden@montague.lit'); + expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(1); + expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation="owner"]')).length).toBe(1); + view.close(); + })); + + it("will not fetch the member list if the user is not affiliated", + mock.initConverse([], {'muc_fetch_members': true}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const sent_IQs = _converse.connection.IQ_stanzas; + spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough(); + // Join MUC without an affiliation + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], [], true, {}, 'none', 'participant'); + await u.waitUntil(() => model.occupants.fetchMembers.calls.count()); + expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(0); + })); + + describe("when fetching the member lists", function () { + + it("gracefully handles being forbidden from fetching the lists for certain affiliations", + mock.initConverse([], {'muc_fetch_members': true}, async function (_converse) { + + const sent_IQs = _converse.connection.IQ_stanzas; + const muc_jid = 'lounge@montague.lit'; + const features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + 'muc_hidden', + 'muc_membersonly', + 'muc_passwordprotected', + Strophe.NS.MAM, + Strophe.NS.SID + ]; + const nick = 'romeo'; + await _converse.api.rooms.open(muc_jid); + await mock.getRoomFeatures(_converse, muc_jid, features); + await mock.waitForReservedNick(_converse, muc_jid, nick); + mock.receiveOwnMUCPresence(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + + // Check in reverse order that we requested all three lists + const owner_iq = sent_IQs.pop(); + expect(Strophe.serialize(owner_iq)).toBe( + `<iq id="${owner_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="owner"/></query>`+ + `</iq>`); + const admin_iq = sent_IQs.pop(); + expect(Strophe.serialize(admin_iq)).toBe( + `<iq id="${admin_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="admin"/></query>`+ + `</iq>`); + const member_iq = sent_IQs.pop(); + expect(Strophe.serialize(member_iq)).toBe( + `<iq id="${member_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="member"/></query>`+ + `</iq>`); + + // It might be that the user is not allowed to fetch certain lists. + let err_stanza = u.toStanza( + `<iq xmlns="jabber:client" type="error" to="${_converse.jid}" from="${muc_jid}" id="${admin_iq.getAttribute('id')}"> + <error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(err_stanza)); + + err_stanza = u.toStanza( + `<iq xmlns="jabber:client" type="error" to="${_converse.jid}" from="${muc_jid}" id="${owner_iq.getAttribute('id')}"> + <error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(err_stanza)); + + // Now the service sends the member lists to the user + const member_list_stanza = $iq({ + 'from': muc_jid, + 'id': member_iq.getAttribute('id'), + 'to': 'romeo@montague.lit/orchard', + 'type': 'result' + }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN}) + .c('item', { + 'affiliation': 'member', + 'jid': 'hag66@shakespeare.lit', + 'nick': 'thirdwitch', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(member_list_stanza)); + + await u.waitUntil(() => view.model.occupants.length > 1); + expect(view.model.occupants.length).toBe(2); + // The existing owner occupant should not have their + // affiliation removed due to the owner list + // not being returned (forbidden err). + expect(view.model.occupants.findWhere({'jid': _converse.bare_jid}).get('affiliation')).toBe('owner'); + expect(view.model.occupants.findWhere({'jid': 'hag66@shakespeare.lit'}).get('affiliation')).toBe('member'); + })); + }); + }); +}); + +describe("Someone being invited to a groupchat", function () { + + it("will first be added to the member list if the groupchat is members only", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough(); + const sent_IQs = _converse.connection.IQ_stanzas; + const muc_jid = 'coven@chat.shakespeare.lit'; + const nick = 'romeo'; + const room_creation_promise = _converse.api.rooms.open(muc_jid, {nick}); + + // Check that the groupchat queried for the features. + let stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`)).pop()); + expect(Strophe.serialize(stanza)).toBe( + `<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute("id")}" to="${muc_jid}" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/disco#info"/>`+ + `</iq>`); + + // State that the chat is members-only via the features IQ + const view = _converse.chatboxviews.get(muc_jid); + const features_stanza = $iq({ + from: 'coven@chat.shakespeare.lit', + 'id': stanza.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result' + }) + .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', { + 'category': 'conference', + 'name': 'A Dark Cave', + 'type': 'text' + }).up() + .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up() + .c('feature', {'var': 'muc_hidden'}).up() + .c('feature', {'var': 'muc_temporary'}).up() + .c('feature', {'var': 'muc_membersonly'}).up(); + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + const sent_stanzas = _converse.connection.sent_stanzas; + await u.waitUntil(() => sent_stanzas.filter(s => s.matches(`presence[to="${muc_jid}/${nick}"]`)).pop()); + expect(view.model.features.get('membersonly')).toBeTruthy(); + + await room_creation_promise; + await mock.createContacts(_converse, 'current'); + + let sent_stanza, sent_id; + spyOn(_converse.connection, 'send').and.callFake(function (stanza) { + if (stanza.nodeName === 'message') { + sent_id = stanza.getAttribute('id'); + sent_stanza = stanza; + } + }); + const invitee_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const reason = "Please join this groupchat"; + view.model.directInvite(invitee_jid, reason); + + // Check in reverse order that we requested all three lists + const owner_iq = sent_IQs.pop(); + expect(Strophe.serialize(owner_iq)).toBe( + `<iq id="${owner_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="owner"/></query>`+ + `</iq>`); + + const admin_iq = sent_IQs.pop(); + expect(Strophe.serialize(admin_iq)).toBe( + `<iq id="${admin_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="admin"/></query>`+ + `</iq>`); + + const member_iq = sent_IQs.pop(); + expect(Strophe.serialize(member_iq)).toBe( + `<iq id="${member_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="member"/></query>`+ + `</iq>`); + + // Now the service sends the member lists to the user + const member_list_stanza = $iq({ + 'from': 'coven@chat.shakespeare.lit', + 'id': member_iq.getAttribute('id'), + 'to': 'romeo@montague.lit/orchard', + 'type': 'result' + }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN}) + .c('item', { + 'affiliation': 'member', + 'jid': 'hag66@shakespeare.lit', + 'nick': 'thirdwitch', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(member_list_stanza)); + + const admin_list_stanza = $iq({ + 'from': 'coven@chat.shakespeare.lit', + 'id': admin_iq.getAttribute('id'), + 'to': 'romeo@montague.lit/orchard', + 'type': 'result' + }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN}) + .c('item', { + 'affiliation': 'admin', + 'jid': 'wiccarocks@shakespeare.lit', + 'nick': 'secondwitch' + }); + _converse.connection._dataRecv(mock.createRequest(admin_list_stanza)); + + const owner_list_stanza = $iq({ + 'from': 'coven@chat.shakespeare.lit', + 'id': owner_iq.getAttribute('id'), + 'to': 'romeo@montague.lit/orchard', + 'type': 'result' + }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN}) + .c('item', { + 'affiliation': 'owner', + 'jid': 'crone1@shakespeare.lit', + }); + _converse.connection._dataRecv(mock.createRequest(owner_list_stanza)); + + // Converse puts the user on the member list + stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/muc#admin"]`)).pop()); + expect(stanza.outerHTML, + `<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item affiliation="member" jid="${invitee_jid}">`+ + `<reason>Please join this groupchat</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + const result = $iq({ + 'from': 'coven@chat.shakespeare.lit', + 'id': stanza.getAttribute('id'), + 'to': 'romeo@montague.lit/orchard', + 'type': 'result' + }); + _converse.connection._dataRecv(mock.createRequest(result)); + + await u.waitUntil(() => view.model.occupants.fetchMembers.calls.count()); + + // Finally check that the user gets invited. + expect(Strophe.serialize(sent_stanza)).toBe( // Strophe adds the xmlns attr (although not in spec) + `<message from="romeo@montague.lit/orchard" id="${sent_id}" to="${invitee_jid}" xmlns="jabber:client">`+ + `<x jid="coven@chat.shakespeare.lit" reason="Please join this groupchat" xmlns="jabber:x:conference"/>`+ + `</message>` + ); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mentions.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mentions.js new file mode 100644 index 0000000..72deb3f --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mentions.js @@ -0,0 +1,548 @@ +/*global mock, converse */ + +const { Strophe, $msg, $pres, sizzle } = converse.env; +const u = converse.env.utils; + + +describe("An incoming groupchat message", function () { + + it("is specially marked when you are mentioned in it", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + if (!view.querySelectorAll('.chat-area').length) { view.renderChatArea(); } + const message = 'romeo: Your attention is required'; + const nick = mock.chatroom_names[0], + msg = $msg({ + from: 'lounge@montague.lit/'+nick, + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(message).tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + expect(u.hasClass('mentioned', view.querySelector('.chat-msg'))).toBeTruthy(); + })); + + + it("highlights all users mentioned via XEP-0372 references", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom'); + const view = _converse.chatboxviews.get(muc_jid); + ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick}@montague.lit/resource`, + 'role': 'participant' + })) + ); + }); + let msg = $msg({ + from: 'lounge@montague.lit/gibson', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('hello z3r0 tom mr.robot, how are you?').up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'6', 'end':'10', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'11', 'end':'14', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'15', 'end':'23', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree; + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelector('.chat-msg__text')?.innerHTML.replace(/<!-.*?->/g, '') === + 'hello <span class="mention" data-uri="xmpp:z3r0@montague.lit">z3r0</span> '+ + '<span class="mention mention--self badge badge-info" data-uri="xmpp:romeo@montague.lit">tom</span> '+ + '<span class="mention" data-uri="xmpp:mr.robot@montague.lit">mr.robot</span>, how are you?'); + let message = view.querySelector('.chat-msg__text'); + expect(message.classList.length).toEqual(1); + + msg = $msg({ + from: 'lounge@montague.lit/sw0rdf1sh', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('@gibson').up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'1', 'end':'7', 'type':'mention', 'uri':'xmpp:gibson@montague.lit'}).nodeTree; + await view.model.handleMessageStanza(msg); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); + + message = sizzle('converse-chat-message:last .chat-msg__text', view).pop(); + expect(message.classList.length).toEqual(1); + expect(message.innerHTML.replace(/<!-.*?->/g, '')).toBe('@<span class="mention" data-uri="xmpp:gibson@montague.lit">gibson</span>'); + })); + + it("properly renders mentions that contain the pipe character", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'romeo@montague.lit/resource', + 'from': `lounge@montague.lit/ThUnD3r|Gr33n` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick}@montague.lit/resource`, + 'role': 'participant' + })) + ); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = 'hello @ThUnD3r|Gr33n' + const enter_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 13 // Enter + } + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter_event); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const sent_stanzas = _converse.connection.sent_stanzas; + const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop()); + expect(Strophe.serialize(msg)) + .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+ + `to="lounge@montague.lit" type="groupchat" `+ + `xmlns="jabber:client">`+ + `<body>hello ThUnD3r|Gr33n</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<reference begin="6" end="19" type="mention" uri="xmpp:lounge@montague.lit/ThUnD3r%7CGr33n" xmlns="urn:xmpp:reference:0"/>`+ + `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>`); + + const message = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(message.innerHTML.replace(/<!-.*?->/g, '')).toBe('hello <span class="mention" data-uri="xmpp:lounge@montague.lit/ThUnD3r%7CGr33n">ThUnD3r|Gr33n</span>'); + })); + + it("highlights all users mentioned via XEP-0372 references in a quoted message", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom'); + const view = _converse.chatboxviews.get(muc_jid); + ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick}@montague.lit/resource`, + 'role': 'participant' + })) + ); + }); + const msg = $msg({ + from: 'lounge@montague.lit/gibson', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('>hello z3r0 tom mr.robot, how are you?').up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'7', 'end':'11', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'12', 'end':'15', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'16', 'end':'24', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree; + + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelector('.chat-msg__text')?.innerHTML.replace(/<!-.*?->/g, '') === + '<blockquote>hello <span class="mention" data-uri="xmpp:z3r0@montague.lit">z3r0</span> '+ + '<span class="mention mention--self badge badge-info" data-uri="xmpp:romeo@montague.lit">tom</span> '+ + '<span class="mention" data-uri="xmpp:mr.robot@montague.lit">mr.robot</span>, how are you?</blockquote>'); + const message = view.querySelector('.chat-msg__text'); + expect(message.classList.length).toEqual(1); + })); +}); + + +describe("A sent groupchat message", function () { + + describe("in which someone is mentioned", function () { + + it("gets parsed for mentions which get turned into references", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + + // Making the MUC non-anonymous so that real JIDs are included + const features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + Strophe.NS.SID, + Strophe.NS.MAM, + 'muc_passwordprotected', + 'muc_hidden', + 'muc_temporary', + 'muc_open', + 'muc_unmoderated', + 'muc_nonanonymous' + ]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom', features); + const view = _converse.chatboxviews.get(muc_jid); + ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh', 'Link Mauve', 'robot'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick.replace(/\s/g, '-')}@montague.lit/resource`, + 'role': 'participant' + }))); + }); + + // Also check that nicks from received messages, (but for which we don't have occupant objects) can be mentioned. + const stanza = u.toStanza(` + <message xmlns="jabber:client" + from="${muc_jid}/gh0st" + to="${_converse.connection.bare_jid}" + type="groupchat"> + <body>Boo!</body> + </message>`); + await view.model.handleMessageStanza(stanza); + + // Run a few unit tests for the parseTextForReferences method + let [text, references] = view.model.parseTextForReferences('yo @robot') + expect(text).toBe('yo robot'); + expect(references) + .toEqual([{"begin":3,"end":8,"value":"robot","type":"mention","uri":"xmpp:robot@montague.lit"}]); + + [text, references] = view.model.parseTextForReferences('@@gh0st') + expect(text).toBe('@gh0st'); + expect(references.length).toBe(1); + expect(references) + .toEqual([{"begin":1,"end":6,"value":"gh0st","type":"mention","uri":"xmpp:lounge@montague.lit/gh0st"}]); + + [text, references] = view.model.parseTextForReferences('hello z3r0') + expect(references.length).toBe(0); + expect(text).toBe('hello z3r0'); + + [text, references] = view.model.parseTextForReferences('hello @z3r0') + expect(references.length).toBe(1); + expect(text).toBe('hello z3r0'); + expect(references) + .toEqual([{"begin":6,"end":10,"value":"z3r0","type":"mention","uri":"xmpp:z3r0@montague.lit"}]); + + [text, references] = view.model.parseTextForReferences('hello @some1 @z3r0 @gibson @mr.robot, how are you?') + expect(text).toBe('hello @some1 z3r0 gibson mr.robot, how are you?'); + expect(references) + .toEqual([{"begin":13,"end":17,"value":"z3r0","type":"mention","uri":"xmpp:z3r0@montague.lit"}, + {"begin":18,"end":24,"value":"gibson","type":"mention","uri":"xmpp:gibson@montague.lit"}, + {"begin":25,"end":33,"value":"mr.robot","type":"mention","uri":"xmpp:mr.robot@montague.lit"}]); + + [text, references] = view.model.parseTextForReferences('yo @gib') + expect(text).toBe('yo @gib'); + expect(references.length).toBe(0); + + [text, references] = view.model.parseTextForReferences('yo @gibsonian') + expect(text).toBe('yo @gibsonian'); + expect(references.length).toBe(0); + + [text, references] = view.model.parseTextForReferences('yo @GiBsOn') + expect(text).toBe('yo gibson'); + expect(references.length).toBe(1); + + [text, references] = view.model.parseTextForReferences('@gibson') + expect(text).toBe('gibson'); + expect(references.length).toBe(1); + expect(references) + .toEqual([{"begin":0,"end":6,"value":"gibson","type":"mention","uri":"xmpp:gibson@montague.lit"}]); + + [text, references] = view.model.parseTextForReferences('hi @Link Mauve how are you?') + expect(text).toBe('hi Link Mauve how are you?'); + expect(references.length).toBe(1); + expect(references) + .toEqual([{"begin":3,"end":13,"value":"Link Mauve","type":"mention","uri":"xmpp:Link-Mauve@montague.lit"}]); + + [text, references] = view.model.parseTextForReferences('https://example.org/@gibson') + expect(text).toBe('https://example.org/@gibson'); + expect(references.length).toBe(0); + expect(references).toEqual([]); + + [text, references] = view.model.parseTextForReferences('mail@gibson.com') + expect(text).toBe('mail@gibson.com'); + expect(references.length).toBe(0); + expect(references) + .toEqual([]); + + [text, references] = view.model.parseTextForReferences( + "Welcome @gibson 💩 We have a guide on how to do that here: https://conversejs.org/docs/html/index.html"); + expect(text).toBe("Welcome gibson 💩 We have a guide on how to do that here: https://conversejs.org/docs/html/index.html"); + expect(references.length).toBe(1); + expect(references).toEqual([{"begin":8,"end":14,"value":"gibson","type":"mention","uri":"xmpp:gibson@montague.lit"}]); + + [text, references] = view.model.parseTextForReferences( + 'https://linkmauve.fr@Link Mauve/ https://linkmauve.fr/@github/is_back gibson@gibson.com gibson@Link Mauve.fr') + expect(text).toBe( + 'https://linkmauve.fr@Link Mauve/ https://linkmauve.fr/@github/is_back gibson@gibson.com gibson@Link Mauve.fr'); + expect(references.length).toBe(0); + expect(references) + .toEqual([]); + + [text, references] = view.model.parseTextForReferences('@gh0st where are you?') + expect(text).toBe('gh0st where are you?'); + expect(references.length).toBe(1); + expect(references) + .toEqual([{"begin":0,"end":5,"value":"gh0st","type":"mention","uri":"xmpp:lounge@montague.lit/gh0st"}]); + })); + + it("gets parsed for mentions as indicated with an @ preceded by a space or at the start of the text", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom'); + const view = _converse.chatboxviews.get(muc_jid); + ['NotAnAdress', 'darnuria'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick.replace(/\s/g, '-')}@montague.lit/resource`, + 'role': 'participant' + }))); + }); + + // Test that we don't match @nick in email adresses. + let [text, references] = view.model.parseTextForReferences('contact contact@NotAnAdress.eu'); + expect(references.length).toBe(0); + expect(text).toBe('contact contact@NotAnAdress.eu'); + + // Test that we don't match @nick in url + [text, references] = view.model.parseTextForReferences('nice website https://darnuria.eu/@darnuria'); + expect(references.length).toBe(0); + expect(text).toBe('nice website https://darnuria.eu/@darnuria'); + })); + + it("properly encodes the URIs in sent out references", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom'); + const view = _converse.chatboxviews.get(muc_jid); + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/Link Mauve` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'role': 'participant' + }))); + await u.waitUntil(() => view.model.occupants.length === 2); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = 'hello @Link Mauve' + const enter_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 13 // Enter + } + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter_event); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const sent_stanzas = _converse.connection.sent_stanzas; + const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop()); + expect(Strophe.serialize(msg)) + .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+ + `to="lounge@montague.lit" type="groupchat" `+ + `xmlns="jabber:client">`+ + `<body>hello Link Mauve</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<reference begin="6" end="16" type="mention" uri="xmpp:lounge@montague.lit/Link%20Mauve" xmlns="urn:xmpp:reference:0"/>`+ + `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>`); + })); + + it("can get corrected and given new references", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + + // Making the MUC non-anonymous so that real JIDs are included + const features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + Strophe.NS.SID, + Strophe.NS.MAM, + 'muc_passwordprotected', + 'muc_hidden', + 'muc_temporary', + 'muc_open', + 'muc_unmoderated', + 'muc_nonanonymous' + ]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom', features); + const view = _converse.chatboxviews.get(muc_jid); + ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick}@montague.lit/resource`, + 'role': 'participant' + }))); + }); + await u.waitUntil(() => view.model.occupants.length === 5); + + const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea')); + textarea.value = 'hello @z3r0 @gibson @mr.robot, how are you?' + const enter_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 13 // Enter + } + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter_event); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + + const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text'; + await u.waitUntil(() => + view.querySelector(last_msg_sel).innerHTML.replace(/<!-.*?->/g, '') === + 'hello <span class="mention" data-uri="xmpp:z3r0@montague.lit">z3r0</span> '+ + '<span class="mention" data-uri="xmpp:gibson@montague.lit">gibson</span> '+ + '<span class="mention" data-uri="xmpp:mr.robot@montague.lit">mr.robot</span>, how are you?' + ); + + const sent_stanzas = _converse.connection.sent_stanzas; + const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop()); + expect(Strophe.serialize(msg)) + .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+ + `to="lounge@montague.lit" type="groupchat" `+ + `xmlns="jabber:client">`+ + `<body>hello z3r0 gibson mr.robot, how are you?</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+ + `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+ + `<reference begin="18" end="26" type="mention" uri="xmpp:mr.robot@montague.lit" xmlns="urn:xmpp:reference:0"/>`+ + `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>`); + + const action = await u.waitUntil(() => view.querySelector('.chat-msg .chat-msg__action')); + action.style.opacity = 1; + action.click(); + + expect(textarea.value).toBe('hello @z3r0 @gibson @mr.robot, how are you?'); + expect(view.model.messages.at(0).get('correcting')).toBe(true); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500); + + textarea.value = 'hello @z3r0 @gibson @sw0rdf1sh, how are you?'; + message_form.onKeyDown(enter_event); + await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent === + 'hello z3r0 gibson sw0rdf1sh, how are you?', 500); + + const correction = sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop(); + expect(Strophe.serialize(correction)) + .toBe(`<message from="romeo@montague.lit/orchard" id="${correction.getAttribute("id")}" `+ + `to="lounge@montague.lit" type="groupchat" `+ + `xmlns="jabber:client">`+ + `<body>hello z3r0 gibson sw0rdf1sh, how are you?</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+ + `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+ + `<reference begin="18" end="27" type="mention" uri="xmpp:sw0rdf1sh@montague.lit" xmlns="urn:xmpp:reference:0"/>`+ + `<replace id="${msg.getAttribute("id")}" xmlns="urn:xmpp:message-correct:0"/>`+ + `<origin-id id="${correction.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>`); + })); + + it("includes a XEP-0372 references to that person", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick}@montague.lit/resource`, + 'role': 'participant' + }))); + }); + await u.waitUntil(() => view.model.occupants.length === 5); + + spyOn(_converse.connection, 'send'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = 'hello @z3r0 @gibson @mr.robot, how are you?' + const enter_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 13 // Enter + } + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter_event); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + + const msg = _converse.connection.send.calls.all()[1].args[0]; + expect(Strophe.serialize(msg)) + .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+ + `to="lounge@montague.lit" type="groupchat" `+ + `xmlns="jabber:client">`+ + `<body>hello z3r0 gibson mr.robot, how are you?</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<reference begin="6" end="10" type="mention" uri="xmpp:${muc_jid}/z3r0" xmlns="urn:xmpp:reference:0"/>`+ + `<reference begin="11" end="17" type="mention" uri="xmpp:${muc_jid}/gibson" xmlns="urn:xmpp:reference:0"/>`+ + `<reference begin="18" end="26" type="mention" uri="xmpp:${muc_jid}/mr.robot" xmlns="urn:xmpp:reference:0"/>`+ + `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>`); + })); + }); + + it("highlights all users mentioned via XEP-0372 references in a quoted message", + mock.initConverse([], {}, async function (_converse) { + + const members = [{'jid': 'gibson@gibson.net', 'nick': 'gibson', 'affiliation': 'member'}]; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom', [], members); + const view = _converse.chatboxviews.get(muc_jid); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = "Welcome @gibson 💩 We have a guide on how to do that here: https://conversejs.org/docs/html/index.html"; + const enter_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 13 // Enter + } + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter_event); + const message = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(message.innerHTML.replace(/<!-.*?->/g, '')).toEqual( + `Welcome <span class="mention" data-uri="xmpp:${muc_jid}/gibson">gibson</span> <span title=":poop:">💩</span> `+ + `We have a guide on how to do that here: `+ + `<a target="_blank" rel="noopener" href="https://conversejs.org/docs/html/index.html">https://conversejs.org/docs/html/index.html</a>`); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mep.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mep.js new file mode 100644 index 0000000..cb638f1 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mep.js @@ -0,0 +1,247 @@ +/*global mock, converse */ + +const { u, Strophe } = converse.env; + +describe("A XEP-0316 MEP notification", function () { + + it("is rendered as an info message", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + let msg = 'An anonymous user has saluted romeo'; + let reason = 'Thank you for helping me yesterday'; + let message = u.toStanza(` + <message from='${muc_jid}' + to='${_converse.jid}' + type='headline' + id='zns61f38'> + <event xmlns='http://jabber.org/protocol/pubsub#event'> + <items node='urn:ietf:params:xml:ns:conference-info'> + <item id='ehs51f40'> + <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'> + <activity xmlns='http://jabber.org/protocol/activity'> + <other/> + <text id="activity-text" xml:lang="en">${msg}</text> + <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="30" end="35" type="mention" uri="xmpp:${_converse.bare_jid}"/> + <reason id="activity-reason">${reason}</reason> + </activity> + </conference-info> + </item> + </items> + </event> + </message>`); + + _converse.connection._dataRecv(mock.createRequest(message)); + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1); + expect(view.querySelector('.chat-info__message converse-rich-text').textContent.trim()).toBe(msg); + expect(view.querySelector('.reason').textContent.trim()).toBe(reason); + + // Check that duplicates aren't created + _converse.connection._dataRecv(mock.createRequest(message)); + let promise = u.getOpenPromise(); + setTimeout(() => { + expect(view.querySelectorAll('.chat-info').length).toBe(1); + promise.resolve(); + }, 250); + await promise; + + // Also check a MEP message of type "groupchat" + msg = 'An anonymous user has poked romeo'; + reason = 'Can you please help me with something else?'; + message = u.toStanza(` + <message from='${muc_jid}' + to='${_converse.jid}' + type='groupchat' + id='zns61f39'> + <event xmlns='http://jabber.org/protocol/pubsub#event'> + <items node='urn:ietf:params:xml:ns:conference-info'> + <item id='ehs51f40'> + <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'> + <activity xmlns='http://jabber.org/protocol/activity'> + <other/> + <text id="activity-text" xml:lang="en">${msg}</text> + <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="28" end="33" type="mention" uri="xmpp:${_converse.bare_jid}"/> + <reason id="activity-reason">${reason}</reason> + </activity> + </conference-info> + </item> + </items> + </event> + </message>`); + + _converse.connection._dataRecv(mock.createRequest(message)); + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 2); + expect(view.querySelector('converse-chat-message:last-child .chat-info__message converse-rich-text').textContent.trim()).toBe(msg); + expect(view.querySelector('converse-chat-message:last-child .reason').textContent.trim()).toBe(reason); + + // Check that duplicates aren't created + _converse.connection._dataRecv(mock.createRequest(message)); + promise = u.getOpenPromise(); + setTimeout(() => { + expect(view.querySelectorAll('.chat-info').length).toBe(2); + promise.resolve(); + }, 250); + return promise; + })); + + it("can trigger a notification if sent to a hidden MUC", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + // const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']); + // spyOn(window, 'Notification').and.returnValue(stub); + + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], [], true, {'hidden': true}); + const msg = 'An anonymous user has saluted romeo'; + const reason = 'Thank you for helping me yesterday'; + const message = u.toStanza(` + <message from='${muc_jid}' + to='${_converse.jid}' + type='headline' + id='zns61f38'> + <event xmlns='http://jabber.org/protocol/pubsub#event'> + <items node='urn:ietf:params:xml:ns:conference-info'> + <item id='ehs51f40'> + <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'> + <activity xmlns='http://jabber.org/protocol/activity'> + <other/> + <text id="activity-text" xml:lang="en">${msg}</text> + <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="30" end="35" type="mention" uri="xmpp:${_converse.bare_jid}"/> + <reason id="activity-reason">${reason}</reason> + </activity> + </conference-info> + </item> + </items> + </event> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + await u.waitUntil(() => model.messages.length === 1); + // expect(window.Notification.calls.count()).toBe(1); + + model.set('hidden', false); + + const view = await u.waitUntil(() => _converse.chatboxviews.get(muc_jid)); + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1, 1000); + expect(view.querySelector('.chat-info__message converse-rich-text').textContent.trim()).toBe(msg); + expect(view.querySelector('.reason').textContent.trim()).toBe(reason); + })); + + it("renders URLs as links", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], [], true); + const msg = 'An anonymous user has waved at romeo'; + const reason = 'Check out https://conversejs.org'; + const message = u.toStanza(` + <message from='${muc_jid}' + to='${_converse.jid}' + type='headline' + id='zns61f38'> + <event xmlns='http://jabber.org/protocol/pubsub#event'> + <items node='urn:ietf:params:xml:ns:conference-info'> + <item id='ehs51f40'> + <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'> + <activity xmlns='http://jabber.org/protocol/activity'> + <other/> + <text id="activity-text" xml:lang="en">${msg}</text> + <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="31" end="37" type="mention" uri="xmpp:${_converse.bare_jid}"/> + <reason id="activity-reason">${reason}</reason> + </activity> + </conference-info> + </item> + </items> + </event> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + await u.waitUntil(() => model.messages.length === 1); + + const view = await u.waitUntil(() => _converse.chatboxviews.get(muc_jid)); + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1, 1000); + expect(view.querySelector('.chat-info__message converse-rich-text').textContent.trim()).toBe(msg); + expect(view.querySelector('.reason converse-rich-text').innerHTML.replace(/<!-.*?->/g, '').trim()).toBe( + 'Check out <a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>'); + })); + + it("can be retracted by a moderator", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick, features); + const view = _converse.chatboxviews.get(muc_jid); + const msg = 'An anonymous user has saluted romeo'; + const reason = 'Thank you for helping me yesterday'; + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + <message from='${muc_jid}' + to='${_converse.jid}' + type='headline' + id='zns61f38'> + <event xmlns='http://jabber.org/protocol/pubsub#event'> + <items node='urn:ietf:params:xml:ns:conference-info'> + <item id='ehs51f40'> + <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'> + <activity xmlns='http://jabber.org/protocol/activity'> + <other/> + <text id="activity-text" xml:lang="en">${msg}</text> + <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="30" end="35" type="mention" uri="xmpp:${_converse.bare_jid}"/> + <reason id="activity-reason">${reason}</reason> + </activity> + </conference-info> + </item> + </items> + </event> + <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/> + </message>` + ))); + + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1); + expect(view.querySelector('.chat-info__message converse-rich-text').textContent.trim()).toBe(msg); + expect(view.querySelector('.reason').textContent.trim()).toBe(reason); + expect(view.querySelectorAll('converse-message-actions converse-dropdown .chat-msg__action').length).toBe(1); + const action = view.querySelector('converse-message-actions converse-dropdown .chat-msg__action'); + expect(action.textContent.trim()).toBe('Retract'); + action.click(); + await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal'))); + const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]'); + submit_button.click(); + + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop()); + const message = view.model.messages.at(0); + const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`); + + expect(Strophe.serialize(stanza)).toBe( + `<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+ + `<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">`+ + `<moderate xmlns="urn:xmpp:message-moderate:0">`+ + `<retract xmlns="urn:xmpp:message-retract:0"/>`+ + `<reason></reason>`+ + `</moderate>`+ + `</apply-to>`+ + `</iq>`); + + // The server responds with a retraction message + const retraction = u.toStanza(` + <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/${nick}"> + <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0"> + <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'> + <retract xmlns='urn:xmpp:message-retract:0' /> + <reason></reason> + </moderated> + </apply-to> + </message>`); + await view.model.handleMessageStanza(retraction); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.model.messages.at(0).get('moderation_reason')).toBe(''); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); + expect(view.model.messages.at(0).get('editable')).toBe(false); + const msg_el = view.querySelector('.chat-msg--retracted .chat-info__message div'); + expect(msg_el.textContent).toBe(`${nick} has removed this message`); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/modtools.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/modtools.js new file mode 100644 index 0000000..574755f --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/modtools.js @@ -0,0 +1,481 @@ +/*global mock, converse, _ */ + +const $iq = converse.env.$iq; +const $pres = converse.env.$pres; +const sizzle = converse.env.sizzle; +const Strophe = converse.env.Strophe; +const u = converse.env.utils; + + +async function openModtools (_converse, view) { + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/modtools'; + const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 }; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter); + const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal')); + await u.waitUntil(() => u.isVisible(modal), 1000); + return modal; +} + +describe("The groupchat moderator tool", function () { + + it("allows you to set affiliations and roles", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + + let members = [ + {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'}, + {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'}, + {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'admin'}, + {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'owner'}, + {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'}, + ]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.occupants.length === 5), 1000); + + const modal = await openModtools(_converse, view); + const tab = modal.querySelector('#affiliations-tab'); + // Clear so that we don't match older stanzas + _converse.connection.IQ_stanzas = []; + tab.click(); + let select = modal.querySelector('.select-affiliation'); + expect(select.value).toBe('owner'); + select.value = 'admin'; + let button = modal.querySelector('.btn-primary[name="users_with_affiliation"]'); + button.click(); + await u.waitUntil(() => !modal.loading_users_with_affiliation); + await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length); + let user_els = modal.querySelectorAll('.list-group--users > li'); + expect(user_els.length).toBe(1); + expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: wiccarocks@shakespeare.lit'); + expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: wiccan'); + expect(user_els[0].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: admin'); + + _converse.connection.IQ_stanzas = []; + select.value = 'owner'; + button.click(); + await u.waitUntil(() => !modal.loading_users_with_affiliation); + await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 2); + user_els = modal.querySelectorAll('.list-group--users > li'); + expect(user_els.length).toBe(2); + expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: romeo@montague.lit'); + expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: romeo'); + expect(user_els[0].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: owner'); + + expect(user_els[1].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: crone1@shakespeare.lit'); + expect(user_els[1].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: thirdwitch'); + expect(user_els[1].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: owner'); + + const toggle = user_els[1].querySelector('.list-group-item:nth-child(3n) .toggle-form'); + const component = user_els[1].querySelector('.list-group-item:nth-child(3n) converse-muc-affiliation-form'); + expect(u.hasClass('hidden', component)).toBeTruthy(); + toggle.click(); + expect(u.hasClass('hidden', component)).toBeFalsy(); + + const form = user_els[1].querySelector('.list-group-item:nth-child(3n) .affiliation-form'); + select = form.querySelector('.select-affiliation'); + expect(select.value).toBe('owner'); + select.value = 'admin'; + const input = form.querySelector('input[name="reason"]'); + input.value = "You're an admin now"; + const submit = form.querySelector('.btn-primary'); + submit.click(); + + spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough(); + const sent_IQ = _converse.connection.IQ_stanzas.pop(); + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${sent_IQ.getAttribute('id')}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item affiliation="admin" jid="crone1@shakespeare.lit">`+ + `<reason>You're an admin now</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + _converse.connection.IQ_stanzas = []; + const stanza = $iq({ + 'type': 'result', + 'id': sent_IQ.getAttribute('id'), + 'from': view.model.get('jid'), + 'to': _converse.connection.jid + }); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.model.occupants.fetchMembers.calls.count()); + + members = [ + {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'}, + {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'}, + {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'admin'}, + {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'admin'}, + {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'}, + ]; + await mock.returnMemberLists(_converse, muc_jid, members); + await u.waitUntil(() => view.model.occupants.pluck('affiliation').filter(o => o === 'owner').length === 1); + const alert = modal.querySelector('.alert-primary'); + expect(alert.textContent.trim()).toBe('Affiliation changed'); + + await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 1); + user_els = modal.querySelectorAll('.list-group--users > li'); + expect(user_els.length).toBe(1); + expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: romeo@montague.lit'); + expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: romeo'); + expect(user_els[0].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: owner'); + + modal.querySelector('#roles-tab').click(); + select = modal.querySelector('.select-role'); + await u.waitUntil(() => u.isVisible(select)); + + expect(select.value).toBe('moderator'); + button = modal.querySelector('.btn-primary[name="users_with_role"]'); + button.click(); + + const roles_panel = modal.querySelector('#roles-tabpanel'); + await u.waitUntil(() => roles_panel.querySelectorAll('.list-group--users > li').length === 1); + select.value = 'participant'; + button.click(); + await u.waitUntil(() => !modal.loading_users_with_affiliation); + await u.waitUntil(() => roles_panel.querySelectorAll('.list-group--users > li')[0]?.textContent.trim() === 'No users with that role found.'); + + })); + + it("allows you to filter affiliation search results", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const members = [ + {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'}, + {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'}, + {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'member'}, + {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'member'}, + {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'member'}, + {'jid': 'juliet@capulet.lit', 'nick': 'juliet', 'affiliation': 'member'}, + ]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.occupants.length === 6), 1000); + + // Clear so that we don't match older stanzas + _converse.connection.IQ_stanzas = []; + const modal = await openModtools(_converse, view); + const select = modal.querySelector('.select-affiliation'); + expect(select.value).toBe('owner'); + select.value = 'member'; + const button = modal.querySelector('.btn-primary[name="users_with_affiliation"]'); + button.click(); + await u.waitUntil(() => !modal.loading_users_with_affiliation); + await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 6); + + const nicks = Array.from(modal.querySelectorAll('.list-group--users > li')).map(el => el.getAttribute('data-nick')); + expect(nicks.join(' ')).toBe('gower juliet romeo thirdwitch wiccan witch'); + + const filter = modal.querySelector('[name="filter"]'); + expect(filter).not.toBe(null); + + filter.value = 'romeo'; + u.triggerEvent(filter, "keyup", "KeyboardEvent"); + await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1)); + + filter.value = 'r'; + u.triggerEvent(filter, "keyup", "KeyboardEvent"); + await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 3)); + + filter.value = 'gower'; + u.triggerEvent(filter, "keyup", "KeyboardEvent"); + await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1)); + + filter.value = 'RoMeO'; + u.triggerEvent(filter, "keyup", "KeyboardEvent"); + await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1)); + + })); + + it("allows you to filter role search results", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', []); + const view = _converse.chatboxviews.get(muc_jid); + _converse.connection._dataRecv(mock.createRequest( + $pres({to: _converse.jid, from: `${muc_jid}/nomorenicks`}) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `nomorenicks@montague.lit`, + 'role': 'participant' + }) + )); + _converse.connection._dataRecv(mock.createRequest( + $pres({to: _converse.jid, from: `${muc_jid}/newb`}) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `newb@montague.lit`, + 'role': 'participant' + }) + )); + _converse.connection._dataRecv(mock.createRequest( + $pres({to: _converse.jid, from: `${muc_jid}/some1`}) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `some1@montague.lit`, + 'role': 'participant' + }) + )); + _converse.connection._dataRecv(mock.createRequest( + $pres({to: _converse.jid, from: `${muc_jid}/oldhag`}) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `oldhag@montague.lit`, + 'role': 'participant' + }) + )); + _converse.connection._dataRecv(mock.createRequest( + $pres({to: _converse.jid, from: `${muc_jid}/crone`}) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `crone@montague.lit`, + 'role': 'participant' + }) + )); + _converse.connection._dataRecv(mock.createRequest( + $pres({to: _converse.jid, from: `${muc_jid}/tux`}) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `tux@montague.lit`, + 'role': 'participant' + }) + )); + await u.waitUntil(() => (view.model.occupants.length === 7), 1000); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/modtools'; + const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 }; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter); + + const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal')); + await u.waitUntil(() => u.isVisible(modal), 1000); + + const tab = modal.querySelector('#roles-tab'); + tab.click(); + + // Clear so that we don't match older stanzas + _converse.connection.IQ_stanzas = []; + + const select = modal.querySelector('.select-role'); + expect(select.value).toBe('moderator'); + select.value = 'participant'; + + const button = modal.querySelector('.btn-primary[name="users_with_role"]'); + button.click(); + await u.waitUntil(() => !modal.loading_users_with_role); + await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 6); + + const nicks = Array.from(modal.querySelectorAll('.list-group--users > li')).map(el => el.getAttribute('data-nick')); + expect(nicks.join(' ')).toBe('crone newb nomorenicks oldhag some1 tux'); + + const filter = modal.querySelector('[name="filter"]'); + expect(filter).not.toBe(null); + + filter.value = 'tux'; + u.triggerEvent(filter, "keyup", "KeyboardEvent"); + await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1)); + + filter.value = 'r'; + u.triggerEvent(filter, "keyup", "KeyboardEvent"); + await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 2)); + + filter.value = 'crone'; + u.triggerEvent(filter, "keyup", "KeyboardEvent"); + await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1)); + })); + + it("shows an error message if a particular affiliation list may not be retrieved", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const members = [ + {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'}, + {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'}, + {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'admin'}, + {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'owner'}, + {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'}, + ]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.occupants.length === 5)); + const modal = await openModtools(_converse, view); + const tab = modal.querySelector('#affiliations-tab'); + // Clear so that we don't match older stanzas + _converse.connection.IQ_stanzas = []; + const IQ_stanzas = _converse.connection.IQ_stanzas; + tab.click(); + const select = modal.querySelector('.select-affiliation'); + select.value = 'outcast'; + const button = modal.querySelector('.btn-primary[name="users_with_affiliation"]'); + button.click(); + + const iq_query = await u.waitUntil(() => _.filter( + IQ_stanzas, + s => sizzle(`iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_ADMIN}"] item[affiliation="outcast"]`, s).length + ).pop()); + + const error = u.toStanza( + `<iq from="${muc_jid}" + id="${iq_query.getAttribute('id')}" + type="error" + to="${_converse.jid}"> + + <error type="auth"> + <forbidden xmlns="${Strophe.NS.STANZAS}"/> + </error> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(error)); + await u.waitUntil(() => !modal.loading_users_with_affiliation); + + const alert = await u.waitUntil(() => modal.querySelector('.alert')); + expect(alert.textContent.trim()).toBe('Error: not allowed to fetch outcast list for MUC lounge@montague.lit'); + + const user_els = modal.querySelectorAll('.list-group--users > li'); + expect(user_els.length).toBe(1); + expect(user_els[0].textContent.trim()).toBe('No users with that affiliation found.'); + })); + + it("shows an error message if a particular affiliation may not be set", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const members = [ + {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'}, + {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'}, + ]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.occupants.length === 2)); + const modal = await openModtools(_converse, view); + // Clear so that we don't match older stanzas + _converse.connection.IQ_stanzas = []; + + const tab = modal.querySelector('#affiliations-tab'); + tab.click(); + const select = modal.querySelector('.select-affiliation'); + select.value = 'member'; + const button = modal.querySelector('.btn-primary[name="users_with_affiliation"]'); + button.click(); + await u.waitUntil(() => !modal.loading_users_with_affiliation); + await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 1); + + const user_els = modal.querySelectorAll('.list-group--users > li'); + const toggle = user_els[0].querySelector('.list-group-item:nth-child(3n) .toggle-form'); + const component = user_els[0].querySelector('.list-group-item:nth-child(3n) converse-muc-affiliation-form'); + expect(u.hasClass('hidden', component)).toBeTruthy(); + toggle.click(); + expect(u.hasClass('hidden', component)).toBeFalsy(); + + const form = user_els[0].querySelector('.list-group-item:nth-child(3n) .affiliation-form'); + const change_affiliation_dropdown = form.querySelector('.select-affiliation'); + expect(change_affiliation_dropdown.value).toBe('member'); + change_affiliation_dropdown.value = 'admin'; + const input = form.querySelector('input[name="reason"]'); + input.value = "You're an admin now"; + const submit = form.querySelector('.btn-primary'); + submit.click(); + + const sent_IQ = _converse.connection.IQ_stanzas.pop(); + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${sent_IQ.getAttribute('id')}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item affiliation="admin" jid="gower@shakespeare.lit">`+ + `<reason>You're an admin now</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + const error = u.toStanza( + `<iq from="${muc_jid}" + id="${sent_IQ.getAttribute('id')}" + type="error" + to="${_converse.jid}"> + + <error type="cancel"> + <not-allowed xmlns="${Strophe.NS.STANZAS}"/> + </error> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(error)); + + })); + + + it("doesn't allow admins to make more admins", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const members = [ + {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'}, + {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'}, + {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'admin'}, + ]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.occupants.length === 3)); + const modal = await openModtools(_converse, view); + const tab = modal.querySelector('#affiliations-tab'); + // Clear so that we don't match older stanzas + _converse.connection.IQ_stanzas = []; + tab.click(); + const show_affiliation_dropdown = modal.querySelector('.select-affiliation'); + show_affiliation_dropdown.value = 'member'; + const button = modal.querySelector('.btn-primary[name="users_with_affiliation"]'); + button.click(); + + await u.waitUntil(() => !modal.loading_users_with_affiliation); + await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 2); + + const user_els = modal.querySelectorAll('.list-group--users > li'); + let change_affiliation_dropdown = user_els[0].querySelector('.select-affiliation'); + expect(Array.from(change_affiliation_dropdown.options).map(o => o.value)).toEqual(['member', 'outcast', 'none']); + + change_affiliation_dropdown = user_els[1].querySelector('.select-affiliation'); + expect(Array.from(change_affiliation_dropdown.options).map(o => o.value)).toEqual(['member', 'outcast', 'none']); + })); + + it("lets the assignable affiliations and roles be configured via modtools_disable_assign", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const members = [{'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'}]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members); + const view = _converse.chatboxviews.get(muc_jid); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/modtools'; + const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 }; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter); + + await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal')); + const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid}); + + expect(_converse.getAssignableAffiliations(occupant)).toEqual(['owner', 'admin', 'member', 'outcast', 'none']); + + _converse.api.settings.set('modtools_disable_assign', ['owner']); + expect(_converse.getAssignableAffiliations(occupant)).toEqual(['admin', 'member', 'outcast', 'none']); + + _converse.api.settings.set('modtools_disable_assign', ['owner', 'admin']); + expect(_converse.getAssignableAffiliations(occupant)).toEqual(['member', 'outcast', 'none']); + + _converse.api.settings.set('modtools_disable_assign', ['owner', 'admin', 'outcast']); + expect(_converse.getAssignableAffiliations(occupant)).toEqual(['member', 'none']); + + expect(_converse.getAssignableRoles(occupant)).toEqual(['moderator', 'participant', 'visitor']); + + _converse.api.settings.set('modtools_disable_assign', ['admin', 'moderator']); + expect(_converse.getAssignableRoles(occupant)).toEqual(['participant', 'visitor']); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-add-modal.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-add-modal.js new file mode 100644 index 0000000..b5ae67f --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-add-modal.js @@ -0,0 +1,124 @@ +/*global mock, converse */ + +const { Promise, sizzle, u } = converse.env; + +describe('The "Groupchats" Add modal', function () { + + it('can be opened from a link in the "Groupchats" section of the controlbox', + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current', 0); + + const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list'); + roomspanel.querySelector('.show-add-muc-modal').click(); + mock.closeControlBox(_converse); + const modal = _converse.api.modal.get('converse-add-muc-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + + let label_name = modal.querySelector('label[for="chatroom"]'); + expect(label_name.textContent.trim()).toBe('Groupchat address:'); + const name_input = modal.querySelector('input[name="chatroom"]'); + expect(name_input.placeholder).toBe('name@conference.example.org'); + + const label_nick = modal.querySelector('label[for="nickname"]'); + expect(label_nick.textContent.trim()).toBe('Nickname:'); + const nick_input = modal.querySelector('input[name="nickname"]'); + expect(nick_input.value).toBe(''); + nick_input.value = 'romeo'; + + expect(modal.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat'); + spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve()); + modal.querySelector('input[name="chatroom"]').value = 'lounge@muc.montague.lit'; + modal.querySelector('form input[type="submit"]').click(); + await u.waitUntil(() => _converse.chatboxes.length); + await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1); + + roomspanel.model.set('muc_domain', 'muc.example.org'); + roomspanel.querySelector('.show-add-muc-modal').click(); + label_name = modal.querySelector('label[for="chatroom"]'); + expect(label_name.textContent.trim()).toBe('Groupchat name:'); + await u.waitUntil(() => modal.querySelector('input[name="chatroom"]')?.placeholder === 'name@muc.example.org'); + }) + ); + + it("doesn't require the domain when muc_domain is set", + mock.initConverse(['chatBoxesFetched'], { 'muc_domain': 'muc.example.org' }, async function (_converse) { + await mock.openControlBox(_converse); + const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list'); + roomspanel.querySelector('.show-add-muc-modal').click(); + const modal = _converse.api.modal.get('converse-add-muc-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + expect(modal.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat'); + spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve()); + const label_name = modal.querySelector('label[for="chatroom"]'); + expect(label_name.textContent.trim()).toBe('Groupchat name:'); + let name_input = modal.querySelector('input[name="chatroom"]'); + expect(name_input.placeholder).toBe('name@muc.example.org'); + name_input.value = 'lounge'; + let nick_input = modal.querySelector('input[name="nickname"]'); + nick_input.value = 'max'; + + modal.querySelector('form input[type="submit"]').click(); + await u.waitUntil(() => _converse.chatboxes.length); + await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1); + expect(_converse.chatboxes.models.map(m => m.get('id')).includes('lounge@muc.example.org')).toBe(true); + + // However, you can still open MUCs with different domains + roomspanel.querySelector('.show-add-muc-modal').click(); + await u.waitUntil(() => u.isVisible(modal), 1000); + name_input = modal.querySelector('input[name="chatroom"]'); + name_input.value = 'lounge@conference.example.org'; + nick_input = modal.querySelector('input[name="nickname"]'); + nick_input.value = 'max'; + modal.querySelector('form input[type="submit"]').click(); + await u.waitUntil(() => _converse.chatboxes.models.filter(c => c.get('type') === 'chatroom').length === 2); + await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 2); + expect(_converse.chatboxes.models.map(m => m.get('id')).includes('lounge@conference.example.org')).toBe( + true + ); + }) + ); + + it('only uses the muc_domain is locked_muc_domain is true', + mock.initConverse( + ['chatBoxesFetched'], + { 'muc_domain': 'muc.example.org', 'locked_muc_domain': true }, + async function (_converse) { + await mock.openControlBox(_converse); + const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list'); + roomspanel.querySelector('.show-add-muc-modal').click(); + const modal = _converse.api.modal.get('converse-add-muc-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + expect(modal.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat'); + spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve()); + const label_name = modal.querySelector('label[for="chatroom"]'); + expect(label_name.textContent.trim()).toBe('Groupchat name:'); + let name_input = modal.querySelector('input[name="chatroom"]'); + expect(name_input.placeholder).toBe(''); + name_input.value = 'lounge'; + let nick_input = modal.querySelector('input[name="nickname"]'); + nick_input.value = 'max'; + modal.querySelector('form input[type="submit"]').click(); + await u.waitUntil(() => _converse.chatboxes.length); + await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1); + expect(_converse.chatboxes.models.map(m => m.get('id')).includes('lounge@muc.example.org')).toBe(true); + + // However, you can still open MUCs with different domains + roomspanel.querySelector('.show-add-muc-modal').click(); + await u.waitUntil(() => u.isVisible(modal), 1000); + name_input = modal.querySelector('input[name="chatroom"]'); + name_input.value = 'lounge@conference'; + nick_input = modal.querySelector('input[name="nickname"]'); + nick_input.value = 'max'; + modal.querySelector('form input[type="submit"]').click(); + await u.waitUntil( + () => _converse.chatboxes.models.filter(c => c.get('type') === 'chatroom').length === 2 + ); + await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 2); + expect( + _converse.chatboxes.models.map(m => m.get('id')).includes('lounge\\40conference@muc.example.org') + ).toBe(true); + } + ) + ); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-api.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-api.js new file mode 100644 index 0000000..c3427d0 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-api.js @@ -0,0 +1,262 @@ +/*global mock, converse */ + +const Model = converse.env.Model; +const { $pres, $iq, Strophe, sizzle, u } = converse.env; + +describe("Groupchats", function () { + + describe("The \"rooms\" API", function () { + + it("has a method 'close' which closes rooms by JID or all rooms when called with no arguments", + mock.initConverse([], {}, async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + + _converse.connection.IQ_stanzas = []; + await mock.openAndEnterChatRoom(_converse, 'leisure@montague.lit', 'romeo'); + + _converse.connection.IQ_stanzas = []; + await mock.openAndEnterChatRoom(_converse, 'news@montague.lit', 'romeo'); + + expect(u.isVisible(_converse.chatboxviews.get('lounge@montague.lit'))).toBeTruthy(); + expect(u.isVisible(_converse.chatboxviews.get('leisure@montague.lit'))).toBeTruthy(); + expect(u.isVisible(_converse.chatboxviews.get('news@montague.lit'))).toBeTruthy(); + + _converse.chatboxviews.get('lounge@montague.lit').close(); + await u.waitUntil(() => !_converse.chatboxviews.get('lounge@montague.lit')); + expect(u.isVisible(_converse.chatboxviews.get('leisure@montague.lit'))).toBeTruthy(); + expect(u.isVisible(_converse.chatboxviews.get('news@montague.lit'))).toBeTruthy(); + + _converse.chatboxviews.get('leisure@montague.lit').close(); + await u.waitUntil(() => !_converse.chatboxviews.get('leisure@montague.lit')); + + _converse.chatboxviews.get('news@montague.lit').close(); + await u.waitUntil(() => !_converse.chatboxviews.get('news@montague.lit')); + + expect(_converse.chatboxviews.get('lounge@montague.lit')).toBeUndefined(); + expect(_converse.chatboxviews.get('leisure@montague.lit')).toBeUndefined(); + expect(_converse.chatboxviews.get('news@montague.lit')).toBeUndefined(); + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + await mock.openAndEnterChatRoom(_converse, 'leisure@montague.lit', 'romeo'); + expect(u.isVisible(_converse.chatboxviews.get('lounge@montague.lit'))).toBeTruthy(); + expect(u.isVisible(_converse.chatboxviews.get('leisure@montague.lit'))).toBeTruthy(); + + _converse.chatboxviews.get('leisure@montague.lit').close(); + await u.waitUntil(() => !_converse.chatboxviews.get('leisure@montague.lit')); + + _converse.chatboxviews.get('lounge@montague.lit').close(); + await u.waitUntil(() => !_converse.chatboxviews.get('lounge@montague.lit')); + + expect(_converse.chatboxviews.get('lounge@montague.lit')).toBeUndefined(); + expect(_converse.chatboxviews.get('leisure@montague.lit')).toBeUndefined(); + })); + + it("has a method 'get' which returns a wrapped groupchat (if it exists)", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group .group-toggle').length, 300); + let muc_jid = 'chillout@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + let room = await _converse.api.rooms.get(muc_jid); + expect(room instanceof Object).toBeTruthy(); + + let chatroomview = _converse.chatboxviews.get(muc_jid); + expect(chatroomview.is_chatroom).toBeTruthy(); + + expect(u.isVisible(chatroomview)).toBeTruthy(); + await chatroomview.close(); + + // Test with mixed case + muc_jid = 'Leisure@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + room = await _converse.api.rooms.get(muc_jid); + expect(room instanceof Object).toBeTruthy(); + chatroomview = _converse.chatboxviews.get(muc_jid.toLowerCase()); + expect(u.isVisible(chatroomview)).toBeTruthy(); + + muc_jid = 'leisure@montague.lit'; + room = await _converse.api.rooms.get(muc_jid); + expect(room instanceof Object).toBeTruthy(); + chatroomview = _converse.chatboxviews.get(muc_jid.toLowerCase()); + expect(u.isVisible(chatroomview)).toBeTruthy(); + + muc_jid = 'leiSure@montague.lit'; + room = await _converse.api.rooms.get(muc_jid); + expect(room instanceof Object).toBeTruthy(); + chatroomview = _converse.chatboxviews.get(muc_jid.toLowerCase()); + expect(u.isVisible(chatroomview)).toBeTruthy(); + chatroomview.close(); + + // Non-existing room + muc_jid = 'chillout2@montague.lit'; + room = await _converse.api.rooms.get(muc_jid); + expect(room).toBe(null); + })); + + it("has a method 'open' which opens (optionally configures) and returns a wrapped chat box", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const { api } = _converse; + // Mock 'getDiscoInfo', otherwise the room won't be + // displayed as it waits first for the features to be returned + // (when it's a new room being created). + spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve()); + + let jid = 'lounge@montague.lit'; + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current'); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group .group-toggle').length); + + let room = await _converse.api.rooms.open(jid); + // Test on groupchat that's not yet open + expect(room instanceof Model).toBeTruthy(); + let mucview = await u.waitUntil(() => _converse.chatboxviews.get(jid)); + expect(mucview.is_chatroom).toBeTruthy(); + await u.waitUntil(() => u.isVisible(mucview)); + + // Test again, now that the room exists. + room = await _converse.api.rooms.open(jid); + expect(room instanceof Model).toBeTruthy(); + mucview = await u.waitUntil(() => _converse.chatboxviews.get(jid)); + expect(mucview.is_chatroom).toBeTruthy(); + expect(u.isVisible(mucview)).toBeTruthy(); + await mucview.close(); + + // Test with mixed case in JID + jid = 'Leisure@montague.lit'; + room = await _converse.api.rooms.open(jid); + expect(room instanceof Model).toBeTruthy(); + mucview = await u.waitUntil(() => _converse.chatboxviews.get(jid.toLowerCase())); + await u.waitUntil(() => u.isVisible(mucview)); + + jid = 'leisure@montague.lit'; + room = await _converse.api.rooms.open(jid); + expect(room instanceof Model).toBeTruthy(); + mucview = await u.waitUntil(() => _converse.chatboxviews.get(jid.toLowerCase())); + await u.waitUntil(() => u.isVisible(mucview)); + + jid = 'leiSure@montague.lit'; + room = await _converse.api.rooms.open(jid); + expect(room instanceof Model).toBeTruthy(); + mucview = await u.waitUntil(() => _converse.chatboxviews.get(jid.toLowerCase())); + await u.waitUntil(() => u.isVisible(mucview)); + mucview.close(); + + api.settings.set('muc_instant_rooms', false); + // Test with configuration + room = await _converse.api.rooms.open('room@conference.example.org', { + 'nick': 'some1', + 'auto_configure': true, + 'roomconfig': { + 'getmemberlist': ['moderator', 'participant'], + 'changesubject': false, + 'membersonly': true, + 'persistentroom': true, + 'publicroom': true, + 'roomdesc': 'Welcome to this groupchat', + 'whois': 'anyone' + } + }); + expect(room instanceof Model).toBeTruthy(); + + const IQ_stanzas = _converse.connection.IQ_stanzas; + const selector = `iq[to="room@conference.example.org"] query[xmlns="http://jabber.org/protocol/disco#info"]`; + const features_query = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).pop()); + + // We pretend this is a new room, so no disco info is returned. + const features_stanza = $iq({ + from: 'room@conference.example.org', + 'id': features_query.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'error' + }).c('error', {'type': 'cancel'}) + .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"}); + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + /* <presence xmlns="jabber:client" to="romeo@montague.lit/pda" from="room@conference.example.org/yo"> + * <x xmlns="http://jabber.org/protocol/muc#user"> + * <item affiliation="owner" jid="romeo@montague.lit/pda" role="moderator"/> + * <status code="110"/> + * <status code="201"/> + * </x> + * </presence> + */ + const presence = $pres({ + from:'room@conference.example.org/some1', + to:'romeo@montague.lit/pda' + }) + .c('x', {xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item', { + affiliation: 'owner', + jid: 'romeo@montague.lit/pda', + role: 'moderator' + }).up() + .c('status', {code:'110'}).up() + .c('status', {code:'201'}); + _converse.connection._dataRecv(mock.createRequest(presence)); + + const iq = await u.waitUntil(() => IQ_stanzas.filter(s => s.querySelector(`query[xmlns="${Strophe.NS.MUC_OWNER}"]`)).pop()); + expect(Strophe.serialize(iq)).toBe( + `<iq id="${iq.getAttribute('id')}" to="room@conference.example.org" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#owner"/></iq>`); + + const node = u.toStanza(` + <iq xmlns="jabber:client" + type="result" + to="romeo@montague.lit/pda" + from="room@conference.example.org" id="${iq.getAttribute('id')}"> + <query xmlns="http://jabber.org/protocol/muc#owner"> + <x xmlns="jabber:x:data" type="form"> + <title>Configuration for room@conference.example.org</title> + <instructions>Complete and submit this form to configure the room.</instructions> + <field var="FORM_TYPE" type="hidden"> + <value>http://jabber.org/protocol/muc#roomconfig</value> + </field> + <field type="text-single" var="muc#roomconfig_roomname" label="Name"> + <value>Room</value> + </field> + <field type="text-single" var="muc#roomconfig_roomdesc" label="Description"><value/></field> + <field type="boolean" var="muc#roomconfig_persistentroom" label="Make Room Persistent?"/> + <field type="boolean" var="muc#roomconfig_publicroom" label="Make Room Publicly Searchable?"><value>1</value></field> + <field type="boolean" var="muc#roomconfig_changesubject" label="Allow Occupants to Change Subject?"/> + <field type="list-single" var="muc#roomconfig_whois" label="Who May Discover Real JIDs?"><option label="Moderators Only"> + <value>moderators</value></option><option label="Anyone"><value>anyone</value></option> + </field> + <field label="Roles and Affiliations that May Retrieve Member List" + type="list-multi" + var="muc#roomconfig_getmemberlist"> + <value>moderator</value> + <value>participant</value> + <value>visitor</value> + </field> + <field type="text-private" var="muc#roomconfig_roomsecret" label="Password"><value/></field> + <field type="boolean" var="muc#roomconfig_moderatedroom" label="Make Room Moderated?"/> + <field type="boolean" var="muc#roomconfig_membersonly" label="Make Room Members-Only?"/> + <field type="text-single" var="muc#roomconfig_historylength" label="Maximum Number of History Messages Returned by Room"> + <value>20</value></field> + </x> + </query> + </iq>`); + + mucview = _converse.chatboxviews.get('room@conference.example.org'); + spyOn(mucview.model, 'sendConfiguration').and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(node)); + await u.waitUntil(() => mucview.model.sendConfiguration.calls.count() === 1); + + const sent_stanza = IQ_stanzas.filter(s => s.getAttribute('type') === 'set').pop(); + expect(sizzle('field[var="muc#roomconfig_roomname"] value', sent_stanza).pop().textContent.trim()).toBe('Room'); + expect(sizzle('field[var="muc#roomconfig_roomdesc"] value', sent_stanza).pop().textContent.trim()).toBe('Welcome to this groupchat'); + expect(sizzle('field[var="muc#roomconfig_persistentroom"] value', sent_stanza).pop().textContent.trim()).toBe('1'); + expect(sizzle('field[var="muc#roomconfig_getmemberlist"] value', sent_stanza).map(e => e.textContent.trim()).join(' ')).toBe('moderator participant'); + expect(sizzle('field[var="muc#roomconfig_publicroom"] value ', sent_stanza).pop().textContent.trim()).toBe('1'); + expect(sizzle('field[var="muc#roomconfig_changesubject"] value', sent_stanza).pop().textContent.trim()).toBe('0'); + expect(sizzle('field[var="muc#roomconfig_whois"] value ', sent_stanza).pop().textContent.trim()).toBe('anyone'); + expect(sizzle('field[var="muc#roomconfig_membersonly"] value', sent_stanza).pop().textContent.trim()).toBe('1'); + expect(sizzle('field[var="muc#roomconfig_historylength"] value', sent_stanza).pop().textContent.trim()).toBe('20'); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-list-modal.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-list-modal.js new file mode 100644 index 0000000..f43a88c --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-list-modal.js @@ -0,0 +1,141 @@ +/*global mock, converse */ + +const { $iq, Strophe, Promise, sizzle, u } = converse.env; + +describe('The "Groupchats" List modal', function () { + + it('can be opened from a link in the "Groupchats" section of the controlbox', + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.openControlBox(_converse); + const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list'); + roomspanel.querySelector('.show-list-muc-modal').click(); + mock.closeControlBox(_converse); + const modal = _converse.api.modal.get('converse-muc-list-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve()); + + // See: https://xmpp.org/extensions/xep-0045.html#disco-rooms + expect(modal.querySelectorAll('.available-chatrooms li').length).toBe(0); + + const server_input = modal.querySelector('input[name="server"]'); + expect(server_input.placeholder).toBe('conference.example.org'); + server_input.value = 'chat.shakespeare.lit'; + modal.querySelector('input[type="submit"]').click(); + await u.waitUntil(() => _converse.chatboxes.length); + + const IQ_stanzas = _converse.connection.IQ_stanzas; + const sent_stanza = await u.waitUntil(() => + IQ_stanzas.filter(s => sizzle(`query[xmlns="${Strophe.NS.DISCO_ITEMS}"]`, s).length).pop() + ); + const id = sent_stanza.getAttribute('id'); + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq from="romeo@montague.lit/orchard" id="${id}" ` + + `to="chat.shakespeare.lit" ` + + `type="get" ` + + `xmlns="jabber:client">` + + `<query xmlns="http://jabber.org/protocol/disco#items"/>` + + `</iq>` + ); + const iq = $iq({ + 'from': 'muc.montague.lit', + 'to': 'romeo@montague.lit/pda', + 'id': id, + 'type': 'result', + }) + .c('query') + .c('item', { jid: 'heath@chat.shakespeare.lit', name: 'A Lonely Heath' }).up() + .c('item', { jid: 'coven@chat.shakespeare.lit', name: 'A Dark Cave' }).up() + .c('item', { jid: 'forres@chat.shakespeare.lit', name: 'The Palace' }).up() + .c('item', { jid: 'inverness@chat.shakespeare.lit', name: 'Macbeth's Castle' }).up() + .c('item', { jid: 'orchard@chat.shakespeare.lit', name: "Capulet's Orchard" }).up() + .c('item', { jid: 'friar@chat.shakespeare.lit', name: "Friar Laurence's cell" }).up() + .c('item', { jid: 'hall@chat.shakespeare.lit', name: "Hall in Capulet's house" }).up() + .c('item', { jid: 'chamber@chat.shakespeare.lit', name: "Juliet's chamber" }).up() + .c('item', { jid: 'public@chat.shakespeare.lit', name: 'A public place' }).up() + .c('item', { jid: 'street@chat.shakespeare.lit', name: 'A street' }).nodeTree; + _converse.connection._dataRecv(mock.createRequest(iq)); + + await u.waitUntil(() => modal.querySelectorAll('.available-chatrooms li').length === 11); + const rooms = modal.querySelectorAll('.available-chatrooms li'); + expect(rooms[0].textContent.trim()).toBe('Groupchats found'); + expect(rooms[1].textContent.trim()).toBe('A Lonely Heath'); + expect(rooms[2].textContent.trim()).toBe('A Dark Cave'); + expect(rooms[3].textContent.trim()).toBe('The Palace'); + expect(rooms[4].textContent.trim()).toBe("Macbeth's Castle"); + expect(rooms[5].textContent.trim()).toBe("Capulet's Orchard"); + expect(rooms[6].textContent.trim()).toBe("Friar Laurence's cell"); + expect(rooms[7].textContent.trim()).toBe("Hall in Capulet's house"); + expect(rooms[8].textContent.trim()).toBe("Juliet's chamber"); + expect(rooms[9].textContent.trim()).toBe('A public place'); + expect(rooms[10].textContent.trim()).toBe('A street'); + + rooms[4].querySelector('.open-room').click(); + await u.waitUntil(() => _converse.chatboxes.length > 1); + expect(sizzle('.chatroom', _converse.el).filter(u.isVisible).length).toBe(1); // There should now be an open chatroom + const view = _converse.chatboxviews.get('inverness@chat.shakespeare.lit'); + expect(view.querySelector('.chatbox-title__text').textContent.trim()).toBe("Macbeth's Castle"); + }) + ); + + it('is pre-filled with the muc_domain', + mock.initConverse(['chatBoxesFetched'], { 'muc_domain': 'muc.example.org' }, async function (_converse) { + await mock.openControlBox(_converse); + const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list'); + roomspanel.querySelector('.show-list-muc-modal').click(); + mock.closeControlBox(_converse); + const modal = _converse.api.modal.get('converse-muc-list-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + const server_input = modal.querySelector('input[name="server"]'); + expect(server_input.value).toBe('muc.example.org'); + }) + ); + + it("doesn't let you set the MUC domain if it's locked", + mock.initConverse( + ['chatBoxesFetched'], + { 'muc_domain': 'chat.shakespeare.lit', 'locked_muc_domain': true }, + async function (_converse) { + await mock.openControlBox(_converse); + const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list'); + roomspanel.querySelector('.show-list-muc-modal').click(); + mock.closeControlBox(_converse); + const modal = _converse.api.modal.get('converse-muc-list-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve()); + + expect(modal.querySelector('input[name="server"]')).toBe(null); + expect(modal.querySelector('input[type="submit"]')).toBe(null); + await u.waitUntil(() => _converse.chatboxes.length); + const sent_stanza = await u.waitUntil(() => + _converse.connection.sent_stanzas + .filter(s => sizzle(`query[xmlns="http://jabber.org/protocol/disco#items"]`, s).length) + .pop() + ); + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" ` + + `to="chat.shakespeare.lit" type="get" xmlns="jabber:client">` + + `<query xmlns="http://jabber.org/protocol/disco#items"/>` + + `</iq>` + ); + const iq = $iq({ + from: 'muc.montague.lit', + to: 'romeo@montague.lit/pda', + id: sent_stanza.getAttribute('id'), + type: 'result', + }) + .c('query') + .c('item', { jid: 'heath@chat.shakespeare.lit', name: 'A Lonely Heath' }).up() + .c('item', { jid: 'coven@chat.shakespeare.lit', name: 'A Dark Cave' }).up() + .c('item', { jid: 'forres@chat.shakespeare.lit', name: 'The Palace' }).up(); + _converse.connection._dataRecv(mock.createRequest(iq)); + + await u.waitUntil(() => modal.querySelectorAll('.available-chatrooms li').length === 4); + const rooms = modal.querySelectorAll('.available-chatrooms li'); + expect(rooms[0].textContent.trim()).toBe('Groupchats found'); + expect(rooms[1].textContent.trim()).toBe('A Lonely Heath'); + expect(rooms[2].textContent.trim()).toBe('A Dark Cave'); + expect(rooms[3].textContent.trim()).toBe('The Palace'); + } + ) + ); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-mentions.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-mentions.js new file mode 100644 index 0000000..8fab48a --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-mentions.js @@ -0,0 +1,86 @@ +/*global mock, converse */ + +const { dayjs } = converse.env; +const u = converse.env.utils; +// See: https://xmpp.org/rfcs/rfc3921.html + + +describe("MUC Mention Notfications", function () { + + it("may be received from a MUC in which the user is not currently present", + mock.initConverse([], { + 'allow_bookmarks': false, // Hack to get the rooms list to render + 'muc_subscribe_to_rai': true, + 'view_mode': 'fullscreen'}, + async function (_converse) { + + const { api } = _converse; + + expect(_converse.session.get('rai_enabled_domains')).toBe(undefined); + + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + const muc_creation_promise = await api.rooms.open(muc_jid, {nick, 'hidden': true}, false); + await mock.getRoomFeatures(_converse, muc_jid, []); + await mock.receiveOwnMUCPresence(_converse, muc_jid, nick); + await muc_creation_promise; + + const model = _converse.chatboxes.get(muc_jid); + await u.waitUntil(() => (model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + expect(model.get('hidden')).toBe(true); + await u.waitUntil(() => model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED); + + const room_el = await u.waitUntil(() => document.querySelector("converse-rooms-list .available-chatroom")); + expect(Array.from(room_el.classList).includes('unread-msgs')).toBeFalsy(); + + const base_time = new Date(); + let message = u.toStanza(` + <message from="${muc_jid}"> + <mentions xmlns='urn:xmpp:mmn:0'> + <forwarded xmlns='urn:xmpp:forward:0'> + <delay xmlns='urn:xmpp:delay' stamp='${dayjs(base_time).subtract(5, 'minutes').toISOString()}'/> + <message type='groupchat' id='${_converse.connection.getUniqueId()}' + to='${muc_jid}' + from='${muc_jid}/juliet' + xml:lang='en'> + <body>Romeo, wherefore art though Romeo</body> + <reference xmlns='urn:xmpp:reference:0' + type='mention' + begin='0' + uri='xmpp:${_converse.bare_jid}' + end='5'/> + </message> + </forwarded> + </mentions> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(message)); + + await u.waitUntil(() => Array.from(room_el.classList).includes('unread-msgs')); + expect(room_el.querySelector('.msgs-indicator')?.textContent.trim()).toBe('1'); + + message = u.toStanza(` + <message from="${muc_jid}"> + <mentions xmlns='urn:xmpp:mmn:0'> + <forwarded xmlns='urn:xmpp:forward:0'> + <delay xmlns='urn:xmpp:delay' stamp='${dayjs(base_time).subtract(4, 'minutes').toISOString()}'/> + <message type='groupchat' id='${_converse.connection.getUniqueId()}' + to='${muc_jid}' + from='${muc_jid}/juliet' + xml:lang='en'> + <body>Romeo, wherefore art though Romeo</body> + <reference xmlns='urn:xmpp:reference:0' + type='mention' + begin='0' + uri='xmpp:${_converse.bare_jid}' + end='5'/> + </message> + </forwarded> + </mentions> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(message)); + expect(Array.from(room_el.classList).includes('unread-msgs')).toBeTruthy(); + await u.waitUntil(() => room_el.querySelector('.msgs-indicator')?.textContent.trim() === '2'); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-messages.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-messages.js new file mode 100644 index 0000000..378cb59 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-messages.js @@ -0,0 +1,354 @@ +/*global mock, converse */ + + const { Promise, Strophe, $msg, $pres, sizzle,u } = converse.env; + const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + +describe("A Groupchat Message", function () { + + beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000)); + afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout)); + + describe("which is succeeded by an error message", function () { + + it("will have the error displayed below it", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_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-muc-message-form'); + message_form.onKeyDown(enter_event); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + const msg = view.model.messages.at(0); + const err_msg_text = "Message rejected because you're sending messages too quickly"; + const error = u.toStanza(` + <message xmlns="jabber:client" id="${msg.get('msgid')}" from="${muc_jid}" to="${_converse.jid}" type="error"> + <error type="wait"> + <policy-violation xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/> + <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">${err_msg_text}</text> + </error> + <body>hello world</body> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(error)); + expect(await u.waitUntil(() => view.querySelector('.chat-msg__error')?.textContent?.trim())).toBe(err_msg_text); + expect(view.model.messages.length).toBe(1); + const message = view.model.messages.at(0); + expect(message.get('received')).toBeUndefined(); + expect(message.get('body')).toBe('hello world'); + expect(message.get('error_text')).toBe(err_msg_text); + expect(message.get('editable')).toBe(false); + })); + }); + + it("can contain a chat state notification and will still be shown", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + if (!view.querySelectorAll('.chat-area').length) { view.renderChatArea(); } + const message = 'romeo: Your attention is required'; + const nick = mock.chatroom_names[0], + msg = $msg({ + from: 'lounge@montague.lit/'+nick, + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(message) + .c('active', {'xmlns': "http://jabber.org/protocol/chatstates"}) + .tree(); + await view.model.handleMessageStanza(msg); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe(message); + })); + + it("can not be expected to have a unique id attribute", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + if (!view.querySelectorAll('.chat-area').length) { view.renderChatArea(); } + const id = u.getUniqueId(); + let msg = $msg({ + from: 'lounge@montague.lit/some1', + id: id, + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('First message').tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + + msg = $msg({ + from: 'lounge@montague.lit/some2', + id: id, + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('Another message').tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + expect(view.model.messages.length).toBe(2); + })); + + it("is ignored if it has the same stanza-id of an already received one", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'room@muc.example.com'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + spyOn(view.model, 'getStanzaIdQueryAttrs').and.callThrough(); + let stanza = u.toStanza(` + <message xmlns="jabber:client" + from="room@muc.example.com/some1" + to="${_converse.connection.jid}" + type="groupchat"> + <body>Typical body text</body> + <stanza-id xmlns="urn:xmpp:sid:0" + id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad" + by="room@muc.example.com"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.model.messages.length === 1); + await u.waitUntil(() => view.model.getStanzaIdQueryAttrs.calls.count() === 1); + let result = await view.model.getStanzaIdQueryAttrs.calls.all()[0].returnValue; + expect(result instanceof Array).toBe(true); + expect(result[0] instanceof Object).toBe(true); + expect(result[0]['stanza_id room@muc.example.com']).toBe("5f3dbc5e-e1d3-4077-a492-693f3769c7ad"); + + stanza = u.toStanza(` + <message xmlns="jabber:client" + from="room@muc.example.com/some1" + to="${_converse.connection.jid}" + type="groupchat"> + <body>Typical body text</body> + <stanza-id xmlns="urn:xmpp:sid:0" + id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad" + by="room@muc.example.com"/> + </message>`); + spyOn(view.model, 'updateMessage'); + spyOn(view.model, 'getDuplicateMessage').and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.model.getDuplicateMessage.calls.count()); + result = await view.model.getDuplicateMessage.calls.all()[0].returnValue; + expect(result instanceof _converse.Message).toBe(true); + expect(view.model.messages.length).toBe(1); + await u.waitUntil(() => view.model.updateMessage.calls.count()); + })); + + it("keeps track of the sender's role and affiliation", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + let msg = $msg({ + from: 'lounge@montague.lit/romeo', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('I wrote this message!').tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + expect(view.model.messages.last().occupant.get('affiliation')).toBe('owner'); + expect(view.model.messages.last().occupant.get('role')).toBe('moderator'); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(sizzle('.chat-msg', view).pop().classList.value.trim()).toBe('message chat-msg groupchat chat-msg--with-avatar moderator owner'); + let presence = $pres({ + to:'romeo@montague.lit/orchard', + from:'lounge@montague.lit/romeo', + id: u.getUniqueId() + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'member', + jid: 'romeo@montague.lit/orchard', + role: 'participant' + }).up() + .c('status').attrs({code:'110'}).up() + .c('status').attrs({code:'210'}).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => view.model.messages.length === 4); + + msg = $msg({ + from: 'lounge@montague.lit/romeo', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('Another message!').tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + expect(view.model.messages.last().occupant.get('affiliation')).toBe('member'); + expect(view.model.messages.last().occupant.get('role')).toBe('participant'); + expect(sizzle('.chat-msg', view).pop().classList.value.trim()).toBe('message chat-msg groupchat chat-msg--with-avatar participant member'); + + presence = $pres({ + to:'romeo@montague.lit/orchard', + from:'lounge@montague.lit/romeo', + id: u.getUniqueId() + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'owner', + jid: 'romeo@montague.lit/orchard', + role: 'moderator' + }).up() + .c('status').attrs({code:'110'}).up() + .c('status').attrs({code:'210'}).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + + view.model.sendMessage({'body': 'hello world'}); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 3); + + const occupant = await u.waitUntil(() => view.model.messages.filter(m => m.get('type') === 'groupchat')[2].occupant); + expect(occupant.get('affiliation')).toBe('owner'); + expect(occupant.get('role')).toBe('moderator'); + expect(view.querySelectorAll('.chat-msg').length).toBe(3); + await u.waitUntil(() => sizzle('.chat-msg', view).pop().classList.value.trim() === 'message chat-msg groupchat chat-msg--with-avatar moderator owner'); + + msg = $msg({ + from: 'lounge@montague.lit/some1', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('Message from someone not in the MUC right now').tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 4); + + expect(view.model.messages.last().occupant.get('nick')).toBe('some1'); + expect(view.model.messages.last().occupant.get('jid')).toBe(undefined); + + // Check that the occupant gets added/removed to the message as it + // gets removed or added. + presence = $pres({ + to:'romeo@montague.lit/orchard', + from:'lounge@montague.lit/some1', + id: u.getUniqueId() + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({jid: 'some1@montague.lit/orchard'}); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.model.messages.last().occupant); + expect(view.model.messages.last().get('message')).toBe('Message from someone not in the MUC right now'); + expect(view.model.messages.last().occupant.get('nick')).toBe('some1'); + expect(view.model.messages.last().occupant.get('jid')).toBe('some1@montague.lit'); + + presence = $pres({ + to:'romeo@montague.lit/orchard', + type: 'unavailable', + from:'lounge@montague.lit/some1', + id: u.getUniqueId() + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({jid: 'some1@montague.lit/orchard'}); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => !view.model.messages.last().occupant); + expect(view.model.messages.last().get('message')).toBe('Message from someone not in the MUC right now'); + expect(view.model.messages.last().occupant).toBeUndefined(); + + presence = $pres({ + to:'romeo@montague.lit/orchard', + from:'lounge@montague.lit/some1', + id: u.getUniqueId() + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({jid: 'some1@montague.lit/orchard'}); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.model.messages.last().occupant); + expect(view.model.messages.last().get('message')).toBe('Message from someone not in the MUC right now'); + expect(view.model.messages.last().occupant.get('nick')).toBe('some1'); + })); + + it("will be shown as received upon MUC reflection", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.OCCUPANTID]; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick, features); + const view = _converse.chatboxviews.get(muc_jid); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = 'But soft, what light through yonder airlock breaks?'; + const message_form = view.querySelector('converse-muc-message-form'); + 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__body.chat-msg__body--received').length).toBe(0); + + const msg_obj = view.model.messages.at(0); + const stanza = u.toStanza(` + <message xmlns="jabber:client" + from="${msg_obj.get('from')}" + to="${_converse.connection.jid}" + type="groupchat"> + <body>${msg_obj.get('message')}</body> + <stanza-id xmlns="urn:xmpp:sid:0" + id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad" + by="lounge@montague.lit"/> + <occupant-id xmlns="urn:xmpp:occupant-id:0" id="dd72603deec90a38ba552f7c68cbcc61bca202cd" /> + <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/> + </message>`); + await view.model.handleMessageStanza(stanza); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500); + expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0); + expect(view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length).toBe(1); + expect(view.model.messages.length).toBe(1); + + const message = view.model.messages.at(0); + expect(message.get('stanza_id lounge@montague.lit')).toBe('5f3dbc5e-e1d3-4077-a492-693f3769c7ad'); + expect(message.get('origin_id')).toBe(msg_obj.get('origin_id')); + expect(message.get('occupant_id')).toBe('dd72603deec90a38ba552f7c68cbcc61bca202cd'); + })); + + it("can cause a delivery receipt to be returned", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = 'But soft, what light through yonder airlock breaks?'; + const message_form = view.querySelector('converse-muc-message-form'); + 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').length).toBe(1); + + const msg_obj = view.model.messages.at(0); + let stanza = u.toStanza(` + <message xmlns="jabber:client" + from="${msg_obj.get('from')}" + to="${_converse.connection.jid}" + type="groupchat"> + <body>${msg_obj.get('message')}</body> + <stanza-id xmlns="urn:xmpp:sid:0" + id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad" + by="lounge@montague.lit"/> + <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/> + </message>`); + await view.model.handleMessageStanza(stanza); + await u.waitUntil(() => view.model.messages.last().get('received')); + + stanza = u.toStanza(` + <message xml:lang="en" to="romeo@montague.lit/orchard" + from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client"> + <received xmlns="urn:xmpp:receipts" id="${msg_obj.get('msgid')}"/> + <origin-id xmlns="urn:xmpp:sid:0" id="CE08D448-5ED8-4B6A-BB5B-07ED9DFE4FF0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-registration.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-registration.js new file mode 100644 index 0000000..3fe0b26 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-registration.js @@ -0,0 +1,59 @@ +/*global mock, converse */ + +const { $iq, Strophe, sizzle, u } = converse.env; + +describe("Chatrooms", function () { + + describe("The /register commmand", function () { + + it("allows you to register your nickname in a room", + mock.initConverse(['chatBoxesFetched'], {'auto_register_muc_nickname': true}, + async function (_converse) { + + const muc_jid = 'coven@chat.shakespeare.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo') + const view = _converse.chatboxviews.get(muc_jid); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/register'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + let stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => sizzle(`iq[to="${muc_jid}"][type="get"] query[xmlns="jabber:iq:register"]`, iq).length + ).pop()); + expect(Strophe.serialize(stanza)) + .toBe(`<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" `+ + `type="get" xmlns="jabber:client">`+ + `<query xmlns="jabber:iq:register"/></iq>`); + const result = $iq({ + 'from': view.model.get('jid'), + 'id': stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('query', {'type': 'jabber:iq:register'}) + .c('x', {'xmlns': 'jabber:x:data', 'type': 'form'}) + .c('field', { + 'label': 'Desired Nickname', + 'type': 'text-single', + 'var': 'muc#register_roomnick' + }).c('required'); + _converse.connection._dataRecv(mock.createRequest(result)); + stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => sizzle(`iq[to="${muc_jid}"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length + ).pop()); + + expect(Strophe.serialize(stanza)).toBe( + `<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="jabber:iq:register">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field var="FORM_TYPE"><value>http://jabber.org/protocol/muc#register</value></field>`+ + `<field var="muc#register_roomnick"><value>romeo</value></field>`+ + `</x>`+ + `</query>`+ + `</iq>`); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc.js new file mode 100644 index 0000000..be3c9ec --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc.js @@ -0,0 +1,3878 @@ +/*global mock, converse */ + +const { $pres, $iq, $msg, Strophe, Promise, sizzle, u } = converse.env; + +describe("Groupchats", function () { + + describe("An instant groupchat", function () { + + it("will be created when muc_instant_rooms is set to true", + mock.initConverse(['chatBoxesFetched'], { vcard: { nickname: '' } }, async function (_converse) { + + let IQ_stanzas = _converse.connection.IQ_stanzas; + const muc_jid = 'lounge@montague.lit'; + const nick = 'nicky'; + await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo'); + + const disco_selector = `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`; + const stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(disco_selector)).pop()); + // We pretend this is a new room, so no disco info is returned. + const features_stanza = $iq({ + 'from': 'lounge@montague.lit', + 'id': stanza.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'error' + }).c('error', {'type': 'cancel'}) + .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"}); + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + const view = _converse.chatboxviews.get('lounge@montague.lit'); + spyOn(view.model, 'join').and.callThrough(); + await mock.waitForReservedNick(_converse, muc_jid, ''); + const input = await u.waitUntil(() => view.querySelector('input[name="nick"]'), 1000); + expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.NICKNAME_REQUIRED); + input.value = nick; + view.querySelector('input[type=submit]').click(); + expect(view.model.join).toHaveBeenCalled(); + + _converse.connection.IQ_stanzas = []; + await mock.getRoomFeatures(_converse, muc_jid); + await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING); + await mock.receiveOwnMUCPresence(_converse, muc_jid, nick); + + // The user has just entered the room (because join was called) + // and receives their own presence from the server. + // See example 24: + // https://xmpp.org/extensions/xep-0045.html#enter-pres + // + /* <presence xmlns="jabber:client" to="jordie.langen@chat.example.org/converse.js-11659299" from="myroom@conference.chat.example.org/jc"> + * <x xmlns="http://jabber.org/protocol/muc#user"> + * <item jid="jordie.langen@chat.example.org/converse.js-11659299" affiliation="owner" role="moderator"/> + * <status code="110"/> + * <status code="201"/> + * </x> + * </presence> + */ + const presence = $pres({ + to:'romeo@montague.lit/orchard', + from:'lounge@montague.lit/nicky', + id:'5025e055-036c-4bc5-a227-706e7e352053' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'owner', + jid: 'romeo@montague.lit/orchard', + role: 'moderator' + }).up() + .c('status').attrs({code:'110'}).up() + .c('status').attrs({code:'201'}).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED); + await mock.returnMemberLists(_converse, muc_jid); + const num_info_msgs = await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-info').length); + expect(num_info_msgs).toBe(1); + + const info_texts = Array.from(view.querySelectorAll('.chat-content .chat-info')).map(e => e.textContent.trim()); + expect(info_texts[0]).toBe('A new groupchat has been created'); + + const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent); + expect(csntext.trim()).toEqual("nicky has entered the groupchat"); + + // An instant room is created by saving the default configuratoin. + // + /* <iq to="myroom@conference.chat.example.org" type="set" xmlns="jabber:client" id="5025e055-036c-4bc5-a227-706e7e352053:sendIQ"> + * <query xmlns="http://jabber.org/protocol/muc#owner"><x xmlns="jabber:x:data" type="submit"/></query> + * </iq> + */ + const selector = `query[xmlns="${Strophe.NS.MUC_OWNER}"]`; + IQ_stanzas = _converse.connection.IQ_stanzas; + const iq = await u.waitUntil(() => IQ_stanzas.filter(s => s.querySelector(selector)).pop()); + expect(Strophe.serialize(iq)).toBe( + `<iq id="${iq.getAttribute('id')}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#owner"><x type="submit" xmlns="jabber:x:data"/>`+ + `</query></iq>`); + })); + }); + + describe("A Groupchat", function () { + + it("will be visible when opened as the first chat in fullscreen-view", + mock.initConverse(['discoInitialized'], + { 'view_mode': 'fullscreen', 'auto_join_rooms': ['orchard@chat.shakespeare.lit']}, + async function (_converse) { + + const { api } = _converse; + await api.waitUntil('roomsAutoJoined'); + const room = await api.rooms.get('orchard@chat.shakespeare.lit'); + expect(room.get('hidden')).toBe(false); + })); + + it("Can be configured to show cached messages before being joined", + mock.initConverse(['discoInitialized'], + { + muc_show_logs_before_join: true, + archived_messages_page_size: 2, + muc_nickname_from_jid: false, + muc_clear_messages_on_leave: false, + vcard: { nickname: '' }, + }, async function (_converse) { + + const { api } = _converse; + const muc_jid = 'orchard@chat.shakespeare.lit'; + const nick = 'romeo'; + api.rooms.open(muc_jid); + await mock.getRoomFeatures(_converse, muc_jid); + await mock.waitForReservedNick(_converse, muc_jid); + const view = _converse.chatboxviews.get(muc_jid); + await view.model.messages.fetched; + + view.model.messages.create({ + 'type': 'groupchat', + 'to': muc_jid, + 'from': `${_converse.bare_jid}/orchard`, + 'body': 'Hello world', + 'message': 'Hello world', + 'time': '2021-02-02T12:00:00Z' + }); + expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.NICKNAME_REQUIRED); + await u.waitUntil(() => view.querySelectorAll('converse-chat-message').length === 1); + + const sel = 'converse-message-history converse-chat-message .chat-msg__text'; + await u.waitUntil(() => view.querySelector(sel)?.textContent.trim()); + expect(view.querySelector(sel).textContent.trim()).toBe('Hello world') + + const nick_input = await u.waitUntil(() => view.querySelector('[name="nick"]')); + nick_input.value = nick; + view.querySelector('.muc-nickname-form input[type="submit"]').click(); + _converse.connection.IQ_stanzas = []; + await mock.getRoomFeatures(_converse, muc_jid); + await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING); + await mock.receiveOwnMUCPresence(_converse, muc_jid, nick); + })); + + it("maintains its state across reloads", + mock.initConverse([], { + 'clear_messages_on_reconnection': true, + 'enable_smacks': false + }, async function (_converse) { + + const { api } = _converse; + const nick = 'romeo'; + const sent_IQs = _converse.connection.IQ_stanzas; + const muc_jid = 'lounge@montague.lit' + await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], []); + const view = _converse.chatboxviews.get(muc_jid); + let iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + const first_msg_id = _converse.connection.getUniqueId(); + const last_msg_id = _converse.connection.getUniqueId(); + let message = u.toStanza( + `<message xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:15:23Z"/> + <message from="${muc_jid}/some1" type="groupchat"> + <body>1st Message</body> + </message> + </forwarded> + </result> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + message = u.toStanza( + `<message xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${last_msg_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:16:23Z"/> + <message from="${muc_jid}/some1" type="groupchat"> + <body>2nd Message</body> + </message> + </forwarded> + </result> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + const result = u.toStanza( + `<iq type='result' id='${iq_get.getAttribute('id')}'> + <fin xmlns='urn:xmpp:mam:2'> + <set xmlns='http://jabber.org/protocol/rsm'> + <first index='0'>${first_msg_id}</first> + <last>${last_msg_id}</last> + <count>2</count> + </set> + </fin> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(result)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); + + while (sent_IQs.length) { sent_IQs.pop(); } // Clear so that we don't match the older query + await _converse.api.connection.reconnect(); + await mock.getRoomFeatures(_converse, muc_jid, []); + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING)); + + // The user has just entered the room (because join was called) + // and receives their own presence from the server. + // See example 24: https://xmpp.org/extensions/xep-0045.html#enter-pres + await mock.receiveOwnMUCPresence(_converse, muc_jid, nick); + + message = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" id="918172de-d5c5-4da4-b388-446ef4a05bec" to="${_converse.jid}" xml:lang="en" from="${muc_jid}/juliet"> + <body>Wherefore art though?</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="918172de-d5c5-4da4-b388-446ef4a05bec"/> + <stanza-id xmlns="urn:xmpp:sid:0" id="88cc9c93-a8f4-4dd5-b02a-d19855eb6303" by="${muc_jid}"/> + <delay xmlns="urn:xmpp:delay" stamp="2020-07-14T17:46:47Z" from="juliet@shakespeare.lit"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + message = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" id="awQo6a-mi-Wa6NYh" to="${_converse.jid}" from="${muc_jid}/ews000" xml:lang="en"> + <composing xmlns="http://jabber.org/protocol/chatstates"/> + <no-store xmlns="urn:xmpp:hints"/> + <no-permanent-store xmlns="urn:xmpp:hints"/> + <delay xmlns="urn:xmpp:delay" stamp="2020-07-14T17:46:54Z" from="juliet@shakespeare.lit"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + const affs = api.settings.get('muc_fetch_members'); + const all_affiliations = Array.isArray(affs) ? affs : (affs ? ['member', 'admin', 'owner'] : []); + await mock.returnMemberLists(_converse, muc_jid, [], all_affiliations); + + iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + expect(Strophe.serialize(iq_get)).toBe( + `<iq id="${iq_get.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+ + `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="${Strophe.NS.MAM}">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+ + `</x>`+ + `<set xmlns="http://jabber.org/protocol/rsm"><before></before><max>50</max></set>`+ + `</query>`+ + `</iq>`); + })); + + it("shows a new messages indicator when you're scrolled up", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const message = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" id="918172de-d5c5-4da4-b388-446ef4a05bec" to="${_converse.jid}" xml:lang="en" from="${muc_jid}/juliet"> + <body>Wherefore art though?</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="918172de-d5c5-4da4-b388-446ef4a05bec"/> + <stanza-id xmlns="urn:xmpp:sid:0" id="88cc9c93-a8f4-4dd5-b02a-d19855eb6303" by="${muc_jid}"/> + <delay xmlns="urn:xmpp:delay" stamp="2020-07-14T17:46:47Z" from="juliet@shakespeare.lit"/> + </message>`); + + view.model.ui.set('scrolled', true); // hack + _converse.connection._dataRecv(mock.createRequest(message)); + + await u.waitUntil(() => view.model.messages.length); + const chat_new_msgs_indicator = await u.waitUntil(() => view.querySelector('.new-msgs-indicator')); + chat_new_msgs_indicator.click(); + expect(view.model.ui.get('scrolled')).toBeFalsy(); + await u.waitUntil(() => !u.isVisible(chat_new_msgs_indicator)); + })); + + + describe("topic", function () { + + it("is shown the header", mock.initConverse([], {}, async function (_converse) { + await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc'); + const text = 'Jabber/XMPP Development | RFCs and Extensions: https://xmpp.org/ | Protocol and XSF discussions: xsf@muc.xmpp.org'; + let stanza = u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm"> + <subject>${text}</subject> + <delay xmlns="urn:xmpp:delay" stamp="2014-02-04T09:35:39Z" from="jdev@conference.jabber.org"/> + <x xmlns="jabber:x:delay" stamp="20140204T09:35:39" from="jdev@conference.jabber.org"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + const view = _converse.chatboxviews.get('jdev@conference.jabber.org'); + await new Promise(resolve => view.model.once('change:subject', resolve)); + const head_desc = await u.waitUntil(() => view.querySelector('.chat-head__desc'), 1000); + expect(head_desc?.textContent.trim()).toBe(text); + + stanza = u.toStanza( + `<message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm"> + <subject>This is a message subject</subject> + <body>This is a message</body> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + expect(sizzle('.chat-msg__subject', view).length).toBe(1); + expect(sizzle('.chat-msg__subject', view).pop().textContent.trim()).toBe('This is a message subject'); + expect(sizzle('.chat-msg__text').length).toBe(1); + expect(sizzle('.chat-msg__text').pop().textContent.trim()).toBe('This is a message'); + expect(view.querySelector('.chat-head__desc').textContent.trim()).toBe(text); + })); + + it("can be toggled by the user", mock.initConverse([], {}, async function (_converse) { + await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc'); + const text = 'Jabber/XMPP Development | RFCs and Extensions: https://xmpp.org/ | Protocol and XSF discussions: xsf@muc.xmpp.org'; + let stanza = u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm"> + <subject>${text}</subject> + <delay xmlns="urn:xmpp:delay" stamp="2014-02-04T09:35:39Z" from="jdev@conference.jabber.org"/> + <x xmlns="jabber:x:delay" stamp="20140204T09:35:39" from="jdev@conference.jabber.org"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + const view = _converse.chatboxviews.get('jdev@conference.jabber.org'); + await new Promise(resolve => view.model.once('change:subject', resolve)); + + const head_desc = await u.waitUntil(() => view.querySelector('.chat-head__desc')); + expect(head_desc?.textContent.trim()).toBe(text); + + stanza = u.toStanza( + `<message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm"> + <subject>This is a message subject</subject> + <body>This is a message</body> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + expect(sizzle('.chat-msg__subject', view).length).toBe(1); + expect(sizzle('.chat-msg__subject', view).pop().textContent.trim()).toBe('This is a message subject'); + expect(sizzle('.chat-msg__text').length).toBe(1); + expect(sizzle('.chat-msg__text').pop().textContent.trim()).toBe('This is a message'); + const topic_el = view.querySelector('.chat-head__desc'); + expect(topic_el.textContent.trim()).toBe(text); + expect(u.isVisible(topic_el)).toBe(true); + + await u.waitUntil(() => view.querySelector('.hide-topic').textContent.trim() === 'Hide topic'); + const toggle = view.querySelector('.hide-topic'); + expect(toggle.textContent.trim()).toBe('Hide topic'); + toggle.click(); + await u.waitUntil(() => view.querySelector('.hide-topic').textContent.trim() === 'Show topic'); + })); + + it("will always be shown when it's new", mock.initConverse([], {}, async function (_converse) { + await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc'); + const text = 'Jabber/XMPP Development | RFCs and Extensions: https://xmpp.org/ | Protocol and XSF discussions: xsf@muc.xmpp.org'; + let stanza = u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm"> + <subject>${text}</subject> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + const view = _converse.chatboxviews.get('jdev@conference.jabber.org'); + await new Promise(resolve => view.model.once('change:subject', resolve)); + + const head_desc = await u.waitUntil(() => view.querySelector('.chat-head__desc')); + expect(head_desc?.textContent.trim()).toBe(text); + + let topic_el = view.querySelector('.chat-head__desc'); + expect(topic_el.textContent.trim()).toBe(text); + expect(u.isVisible(topic_el)).toBe(true); + + const toggle = view.querySelector('.hide-topic'); + expect(toggle.textContent.trim()).toBe('Hide topic'); + toggle.click(); + await u.waitUntil(() => !u.isVisible(topic_el)); + + stanza = u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm"> + <subject>Another topic</subject> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => u.isVisible(view.querySelector('.chat-head__desc'))); + topic_el = view.querySelector('.chat-head__desc'); + expect(topic_el.textContent.trim()).toBe('Another topic'); + })); + + + it("causes an info message to be shown when received in real-time", mock.initConverse([], {}, async function (_converse) { + spyOn(_converse.ChatRoom.prototype, 'handleSubjectChange').and.callThrough(); + await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'romeo'); + const view = _converse.chatboxviews.get('jdev@conference.jabber.org'); + + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm"> + <subject>This is an older topic</subject> + <delay xmlns="urn:xmpp:delay" stamp="2014-02-04T09:35:39Z" from="jdev@conference.jabber.org"/> + <x xmlns="jabber:x:delay" stamp="20140204T09:35:39" from="jdev@conference.jabber.org"/> + </message>`))); + await u.waitUntil(() => view.model.handleSubjectChange.calls.count()); + expect(sizzle('.chat-info__message', view).length).toBe(0); + + const desc = await u.waitUntil(() => view.querySelector('.chat-head__desc')); + expect(desc.textContent.trim()).toBe('This is an older topic'); + + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm"> + <subject>This is a new topic</subject> + </message>`))); + await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 2); + + await u.waitUntil(() => sizzle('.chat-info__message', view).pop()?.textContent.trim() === 'Topic set by ralphm'); + await u.waitUntil(() => desc.textContent.trim() === 'This is a new topic'); + + // Doesn't show multiple subsequent topic change notifications + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm"> + <subject>Yet another topic</subject> + </message>`))); + await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 3); + await u.waitUntil(() => desc.textContent.trim() === 'Yet another topic'); + expect(sizzle('.chat-info__message', view).length).toBe(1); + + // Sow multiple subsequent topic change notification from someone else + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/some1"> + <subject>Some1's topic</subject> + </message>`))); + await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 4); + await u.waitUntil(() => desc.textContent.trim() === "Some1's topic"); + expect(sizzle('.chat-info__message', view).length).toBe(2); + const el = sizzle('.chat-info__message', view).pop(); + expect(el.textContent.trim()).toBe('Topic set by some1'); + + // Removes current topic + const stanza = u.toStanza( + `<message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/some1"> + <subject/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 5); + await u.waitUntil(() => view.querySelector('.chat-head__desc') === null); + await u.waitUntil(() => view.querySelector('converse-chat-message:last-child .chat-info').textContent.trim() === "Topic cleared by some1"); + })); + }); + + it("restores cached messages when it reconnects and clear_messages_on_reconnection and muc_clear_messages_on_leave are false", + mock.initConverse([], { + 'clear_messages_on_reconnection': false, + 'muc_clear_messages_on_leave': false + }, + async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid , 'romeo'); + const model = _converse.chatboxes.get(muc_jid); + const message = 'Hello world', + nick = mock.chatroom_names[0], + msg = $msg({ + 'from': 'lounge@montague.lit/'+nick, + 'id': u.getUniqueId(), + 'to': 'romeo@montague.lit', + 'type': 'groupchat' + }).c('body').t(message).tree(); + + await model.handleMessageStanza(msg); + await u.waitUntil(() => document.querySelector('converse-chat-message')); + await model.close(); + await u.waitUntil(() => !document.querySelector('converse-chat-message')); + + _converse.connection.IQ_stanzas = []; + await mock.openAndEnterChatRoom(_converse, muc_jid , 'romeo'); + await u.waitUntil(() => document.querySelector('converse-chat-message')); + expect(model.messages.length).toBe(1); + expect(document.querySelectorAll('converse-chat-message').length).toBe(1); + })); + + it("clears cached messages when it reconnects and clear_messages_on_reconnection is true", + mock.initConverse([], {'clear_messages_on_reconnection': true}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid , 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const message = 'Hello world', + nick = mock.chatroom_names[0], + msg = $msg({ + 'from': 'lounge@montague.lit/'+nick, + 'id': u.getUniqueId(), + 'to': 'romeo@montague.lit', + 'type': 'groupchat' + }).c('body').t(message).tree(); + + await view.model.handleMessageStanza(msg); + await view.model.close(); + + _converse.connection.IQ_stanzas = []; + await mock.openAndEnterChatRoom(_converse, muc_jid , 'romeo'); + expect(view.model.messages.length).toBe(0); + expect(view.querySelector('converse-chat-history')).toBe(null); + })); + + it("is opened when an xmpp: URI is clicked inside another groupchat", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + if (!view.querySelectorAll('.chat-area').length) { + view.renderChatArea(); + } + expect(_converse.chatboxes.length).toEqual(2); + const message = 'Please go to xmpp:coven@chat.shakespeare.lit?join', + nick = mock.chatroom_names[0], + msg = $msg({ + 'from': 'lounge@montague.lit/'+nick, + 'id': u.getUniqueId(), + 'to': 'romeo@montague.lit', + 'type': 'groupchat' + }).c('body').t(message).tree(); + + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelector('.chat-msg__text a')); + view.querySelector('.chat-msg__text a').click(); + await u.waitUntil(() => _converse.chatboxes.length === 3) + expect(_converse.chatboxes.pluck('id').includes('coven@chat.shakespeare.lit')).toBe(true); + })); + + it("shows a notification if it's not anonymous", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'coven@chat.shakespeare.lit'; + const nick = 'romeo'; + await _converse.api.rooms.open(muc_jid); + await mock.getRoomFeatures(_converse, muc_jid); + await mock.waitForReservedNick(_converse, muc_jid, nick); + + const view = _converse.chatboxviews.get(muc_jid); + /* <presence to="romeo@montague.lit/_converse.js-29092160" + * from="coven@chat.shakespeare.lit/some1"> + * <x xmlns="http://jabber.org/protocol/muc#user"> + * <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/> + * <status code="110"/> + * <status code="100"/> + * </x> + * </presence></body> + */ + const presence = $pres({ + to: 'romeo@montague.lit/orchard', + from: 'coven@chat.shakespeare.lit/some1' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'owner', + 'jid': 'romeo@montague.lit/_converse.js-29092160', + 'role': 'moderator' + }).up() + .c('status', {code: '110'}).up() + .c('status', {code: '100'}); + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + await mock.returnMemberLists(_converse, muc_jid, [], ['member', 'admin', 'owner']); + + const num_info_msgs = await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-info').length); + expect(num_info_msgs).toBe(1); + expect(sizzle('div.chat-info', view).pop().textContent.trim()).toBe("This groupchat is not anonymous"); + + const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent); + expect(csntext.trim()).toEqual("some1 has entered the groupchat"); + })); + + + it("shows join/leave messages when users enter or exit a groupchat", + mock.initConverse(['chatBoxesFetched'], {'muc_fetch_members': false}, async function (_converse) { + + const muc_jid = 'coven@chat.shakespeare.lit'; + const nick = 'some1'; + const room_creation_promise = await _converse.api.rooms.open(muc_jid, {nick}); + await mock.getRoomFeatures(_converse, muc_jid); + const sent_stanzas = _converse.connection.sent_stanzas; + await u.waitUntil(() => sent_stanzas.filter(iq => sizzle('presence history', iq).length).pop()); + + const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit'); + await _converse.api.waitUntil('chatRoomViewInitialized'); + + /* We don't show join/leave messages for existing occupants. We + * know about them because we receive their presences before we + * receive our own. + */ + let presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/oldguy' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'oldguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + /* <presence to="romeo@montague.lit/_converse.js-29092160" + * from="coven@chat.shakespeare.lit/some1"> + * <x xmlns="http://jabber.org/protocol/muc#user"> + * <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/> + * <status code="110"/> + * </x> + * </presence></body> + */ + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/some1' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'owner', + 'jid': 'romeo@montague.lit/_converse.js-29092160', + 'role': 'moderator' + }).up() + .c('status', {code: '110'}); + _converse.connection._dataRecv(mock.createRequest(presence)); + + const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications')?.textContent); + expect(csntext.trim()).toEqual("some1 has entered the groupchat"); + + await room_creation_promise; + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + await view.model.messages.fetched; + + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1 and newguy have entered the groupchat"); + + const msg = $msg({ + 'from': 'coven@chat.shakespeare.lit/some1', + 'id': u.getUniqueId(), + 'to': 'romeo@montague.lit', + 'type': 'groupchat' + }).c('body').t('hello world').tree(); + _converse.connection._dataRecv(mock.createRequest(msg)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + + // Add another entrant, otherwise the above message will be + // collapsed if "newguy" leaves immediately again + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/newgirl' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newgirl@montague.lit/_converse.js-213098781', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1, newguy and newgirl have entered the groupchat"); + + // Don't show duplicate join messages + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-290918392', + from: 'coven@chat.shakespeare.lit/newguy' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + /* <presence + * from='coven@chat.shakespeare.lit/thirdwitch' + * to='crone1@shakespeare.lit/desktop' + * type='unavailable'> + * <status>Disconnected: Replaced by new connection</status> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' + * jid='hag66@shakespeare.lit/pda' + * role='none'/> + * </x> + * </presence> + */ + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + type: 'unavailable', + from: 'coven@chat.shakespeare.lit/newguy' + }) + .c('status', 'Disconnected: Replaced by new connection').up() + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'none' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1 and newgirl have entered the groupchat\nnewguy has left the groupchat"); + + // When the user immediately joins again, we collapse the + // multiple join/leave messages. + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/newguy' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1, newgirl and newguy have entered the groupchat"); + + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + type: 'unavailable', + from: 'coven@chat.shakespeare.lit/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'none' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1 and newgirl have entered the groupchat\nnewguy has left the groupchat"); + + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/nomorenicks' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'nomorenicks@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1, newgirl and nomorenicks have entered the groupchat\nnewguy has left the groupchat"); + + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-290918392', + type: 'unavailable', + from: 'coven@chat.shakespeare.lit/nomorenicks' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'nomorenicks@montague.lit/_converse.js-290929789', + 'role': 'none' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1 and newgirl have entered the groupchat\nnewguy and nomorenicks have left the groupchat"); + + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/nomorenicks' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'nomorenicks@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1, newgirl and nomorenicks have entered the groupchat\nnewguy has left the groupchat"); + + // Test a member joining and leaving + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-290918392', + from: 'coven@chat.shakespeare.lit/insider' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'member', + 'jid': 'insider@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + /* <presence + * from='coven@chat.shakespeare.lit/thirdwitch' + * to='crone1@shakespeare.lit/desktop' + * type='unavailable'> + * <status>Disconnected: Replaced by new connection</status> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' + * jid='hag66@shakespeare.lit/pda' + * role='none'/> + * </x> + * </presence> + */ + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + type: 'unavailable', + from: 'coven@chat.shakespeare.lit/insider' + }) + .c('status', 'Disconnected: Replaced by new connection').up() + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'member', + 'jid': 'insider@montague.lit/_converse.js-290929789', + 'role': 'none' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1, newgirl and nomorenicks have entered the groupchat\nnewguy and insider have left the groupchat"); + + expect(view.model.occupants.length).toBe(5); + expect(view.model.occupants.findWhere({'jid': 'insider@montague.lit'}).get('show')).toBe('offline'); + + // New girl leaves + presence = $pres({ + 'to': 'romeo@montague.lit/_converse.js-29092160', + 'type': 'unavailable', + 'from': 'coven@chat.shakespeare.lit/newgirl' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newgirl@montague.lit/_converse.js-213098781', + 'role': 'none' + }); + + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1 and nomorenicks have entered the groupchat\nnewguy, insider and newgirl have left the groupchat"); + expect(view.model.occupants.length).toBe(4); + })); + + it("combines subsequent join/leave messages when users enter or exit a groupchat", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'romeo') + const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit'); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === "romeo has entered the groupchat"); + + let presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fabio"> + <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="INI3xjRUioclBTP/aACfWi5m9UY=" hash="sha-1"/> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="participant"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === "romeo and fabio have entered the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/Dele Olajide"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-39320524" role="participant"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === "romeo, fabio and Dele Olajide have entered the groupchat"); + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/jcbrand"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="owner" jid="jc@opkode.com/converse.js-30645022" role="moderator"/> + <status code="110"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === "romeo, fabio and others have entered the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/Dele Olajide"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-39320524" role="none"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo, fabio and jcbrand have entered the groupchat\nDele Olajide has left the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/Dele Olajide"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-74567907" role="participant"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo, fabio and others have entered the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fuvuv" xml:lang="en"> + <c xmlns="http://jabber.org/protocol/caps" node="http://jabber.pix-art.de" ver="5tOurnuFnp2h50hKafeUyeN4Yl8=" hash="sha-1"/> + <x xmlns="vcard-temp:x:update"/> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="fuvuv@blabber.im/Pix-Art Messenger.8zoB" role="participant"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo, fabio and others have entered the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/fuvuv"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="fuvuv@blabber.im/Pix-Art Messenger.8zoB" role="none"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo, fabio and others have entered the groupchat\nfuvuv has left the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/fabio"> + <status>Disconnected: Replaced by new connection</status> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="none"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo, jcbrand and Dele Olajide have entered the groupchat\nfuvuv and fabio have left the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fabio"> + <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="INI3xjRUioclBTP/aACfWi5m9UY=" hash="sha-1"/> + <status>Ready for a new day</status> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="participant"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo, jcbrand and others have entered the groupchat\nfuvuv has left the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/fabio"> + <status>Disconnected: closed</status> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="none"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo, jcbrand and Dele Olajide have entered the groupchat\nfuvuv and fabio have left the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/Dele Olajide"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-74567907" role="none"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo and jcbrand have entered the groupchat\nfuvuv, fabio and Dele Olajide have left the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fabio"> + <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="INI3xjRUioclBTP/aACfWi5m9UY=" hash="sha-1"/> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="participant"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo, jcbrand and fabio have entered the groupchat\nfuvuv and Dele Olajide have left the groupchat"); + + expect(1).toBe(1); + })); + + it("doesn't show the disconnection messages when join_leave_events is not in muc_show_info_messages setting", + mock.initConverse(['chatBoxesFetched'], {'muc_show_info_messages': []}, async function (_converse) { + + spyOn(_converse.ChatRoom.prototype, 'onOccupantAdded').and.callThrough(); + spyOn(_converse.ChatRoom.prototype, 'onOccupantRemoved').and.callThrough(); + await mock.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'some1'); + const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit'); + let presence = $pres({ + to: 'romeo@montague.lit/orchard', + from: 'coven@chat.shakespeare.lit/newguy' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.model.onOccupantAdded.calls.count() === 2); + expect(view.model.notifications.get('entered')).toBeFalsy(); + expect(view.querySelector('.chat-content__notifications').textContent.trim()).toBe(''); + await mock.sendMessage(view, 'hello world'); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/newguy"> + <status>Gotta go!</status> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="newguy@montague.lit/_converse.js-290929789" role="none"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => view.model.onOccupantRemoved.calls.count()); + expect(view.model.onOccupantRemoved.calls.count()).toBe(1); + expect(view.model.notifications.get('entered')).toBeFalsy(); + await mock.sendMessage(view, 'hello world'); + expect(view.querySelector('.chat-content__notifications').textContent.trim()).toBe(''); + })); + + it("role-change messages that follow a MUC leave are left out", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + // See https://github.com/conversejs/converse.js/issues/1259 + + await mock.openAndEnterChatRoom(_converse, 'conversations@conference.siacs.eu', 'romeo'); + + const presence = $pres({ + to: 'romeo@montague.lit/orchard', + from: 'conversations@conference.siacs.eu/Guus' + }).c('x', { + 'xmlns': Strophe.NS.MUC_USER + }).c('item', { + 'affiliation': 'none', + 'jid': 'Guus@montague.lit/xxx', + 'role': 'visitor' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + const view = _converse.chatboxviews.get('conversations@conference.siacs.eu'); + const msg = $msg({ + 'from': 'conversations@conference.siacs.eu/romeo', + 'id': u.getUniqueId(), + 'to': 'romeo@montague.lit', + 'type': 'groupchat' + }).c('body').t('Some message').tree(); + + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => sizzle('.chat-msg:last .chat-msg__text', view).pop()); + + let stanza = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="conversations@conference.siacs.eu/Guus"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" role="none"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + stanza = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="conversations@conference.siacs.eu/Guus"> + <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="ISg6+9AoK1/cwhbNEDviSvjdPzI=" hash="sha-1"/> + <x xmlns="vcard-temp:x:update"> + <photo>bf987c486c51fbc05a6a4a9f20dd19b5efba3758</photo> + </x> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" role="visitor"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() + === "romeo and Guus have entered the groupchat"); + expect(1).toBe(1); + })); + + it("can be configured if you're its owner", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + let sent_IQ, IQ_id; + const sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_IQ = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + + await _converse.api.rooms.open('coven@chat.shakespeare.lit', {'nick': 'some1'}); + const view = await u.waitUntil(() => _converse.chatboxviews.get('coven@chat.shakespeare.lit')); + await u.waitUntil(() => u.isVisible(view)); + // We pretend this is a new room, so no disco info is returned. + const features_stanza = $iq({ + from: 'coven@chat.shakespeare.lit', + 'id': IQ_id, + 'to': 'romeo@montague.lit/desktop', + 'type': 'error' + }).c('error', {'type': 'cancel'}) + .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"}); + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + /* <presence to="romeo@montague.lit/_converse.js-29092160" + * from="coven@chat.shakespeare.lit/some1"> + * <x xmlns="http://jabber.org/protocol/muc#user"> + * <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/> + * <status code="110"/> + * </x> + * </presence></body> + */ + const presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/some1' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'owner', + 'jid': 'romeo@montague.lit/_converse.js-29092160', + 'role': 'moderator' + }).up() + .c('status', {code: '110'}); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.configure-chatroom-button') !== null); + + const own_occupant = view.model.getOwnOccupant(); + await u.waitUntil(() => own_occupant.get('affiliation') === 'owner'); + + view.querySelector('.configure-chatroom-button').click(); + + const sent_IQs = _converse.connection.IQ_stanzas; + const sel = 'iq query[xmlns="http://jabber.org/protocol/muc#owner"]'; + const iq = await u.waitUntil(() => sent_IQs.filter(iq => sizzle(sel, iq).length).pop()); + + /* Check that an IQ is sent out, asking for the + * configuration form. + * See: // https://xmpp.org/extensions/xep-0045.html#example-163 + * + * <iq from='crone1@shakespeare.lit/desktop' + * id='config1' + * to='coven@chat.shakespeare.lit' + * type='get'> + * <query xmlns='http://jabber.org/protocol/muc#owner'/> + * </iq> + */ + expect(Strophe.serialize(iq)).toBe( + `<iq id="${iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#owner"/>`+ + `</iq>`); + + /* Server responds with the configuration form. + * See: // https://xmpp.org/extensions/xep-0045.html#example-165 + */ + const config_stanza = $iq({from: 'coven@chat.shakespeare.lit', + 'id': iq.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result'}) + .c('query', { 'xmlns': 'http://jabber.org/protocol/muc#owner'}) + .c('x', { 'xmlns': 'jabber:x:data', 'type': 'form'}) + .c('title').t('Configuration for "coven" Room').up() + .c('instructions').t('Complete this form to modify the configuration of your room.').up() + .c('field', {'type': 'hidden', 'var': 'FORM_TYPE'}) + .c('value').t('http://jabber.org/protocol/muc#roomconfig').up().up() + .c('field', { + 'label': 'Natural-Language Room Name', + 'type': 'text-single', + 'var': 'muc#roomconfig_roomname'}) + .c('value').t('A Dark Cave').up().up() + .c('field', { + 'label': 'Short Description of Room', + 'type': 'text-single', + 'var': 'muc#roomconfig_roomdesc'}) + .c('value').t('The place for all good witches!').up().up() + .c('field', { + 'label': 'Enable Public Logging?', + 'type': 'boolean', + 'var': 'muc#roomconfig_enablelogging'}) + .c('value').t(0).up().up() + .c('field', { + 'label': 'Allow Occupants to Change Subject?', + 'type': 'boolean', + 'var': 'muc#roomconfig_changesubject'}) + .c('value').t(0).up().up() + .c('field', { + 'label': 'Allow Occupants to Invite Others?', + 'type': 'boolean', + 'var': 'muc#roomconfig_allowinvites'}) + .c('value').t(0).up().up() + .c('field', { + 'label': 'Who Can Send Private Messages?', + 'type': 'list-single', + 'var': 'muc#roomconfig_allowpm'}) + .c('value').t('anyone').up() + .c('option', {'label': 'Anyone'}) + .c('value').t('anyone').up().up() + .c('option', {'label': 'Anyone with Voice'}) + .c('value').t('participants').up().up() + .c('option', {'label': 'Moderators Only'}) + .c('value').t('moderators').up().up() + .c('option', {'label': 'Nobody'}) + .c('value').t('none').up().up().up() + .c('field', { + 'label': 'Roles for which Presence is Broadcasted', + 'type': 'list-multi', + 'var': 'muc#roomconfig_presencebroadcast'}) + .c('value').t('moderator').up() + .c('value').t('participant').up() + .c('value').t('visitor').up() + .c('option', {'label': 'Moderator'}) + .c('value').t('moderator').up().up() + .c('option', {'label': 'Participant'}) + .c('value').t('participant').up().up() + .c('option', {'label': 'Visitor'}) + .c('value').t('visitor').up().up().up() + .c('field', { + 'label': 'Roles and Affiliations that May Retrieve Member List', + 'type': 'list-multi', + 'var': 'muc#roomconfig_getmemberlist'}) + .c('value').t('moderator').up() + .c('value').t('participant').up() + .c('value').t('visitor').up() + .c('option', {'label': 'Moderator'}) + .c('value').t('moderator').up().up() + .c('option', {'label': 'Participant'}) + .c('value').t('participant').up().up() + .c('option', {'label': 'Visitor'}) + .c('value').t('visitor').up().up().up() + .c('field', { + 'label': 'Make Room Publicly Searchable?', + 'type': 'boolean', + 'var': 'muc#roomconfig_publicroom'}) + .c('value').t(0).up().up() + .c('field', { + 'label': 'Make Room Publicly Searchable?', + 'type': 'boolean', + 'var': 'muc#roomconfig_publicroom'}) + .c('value').t(0).up().up() + .c('field', { + 'label': 'Make Room Persistent?', + 'type': 'boolean', + 'var': 'muc#roomconfig_persistentroom'}) + .c('value').t(0).up().up() + .c('field', { + 'label': 'Make Room Moderated?', + 'type': 'boolean', + 'var': 'muc#roomconfig_moderatedroom'}) + .c('value').t(0).up().up() + .c('field', { + 'label': 'Make Room Members Only?', + 'type': 'boolean', + 'var': 'muc#roomconfig_membersonly'}) + .c('value').t(0).up().up() + .c('field', { + 'label': 'Password Required for Entry?', + 'type': 'boolean', + 'var': 'muc#roomconfig_passwordprotectedroom'}) + .c('value').t(1).up().up() + .c('field', {'type': 'fixed'}) + .c('value').t( + 'If a password is required to enter this groupchat, you must specify the password below.' + ).up().up() + .c('field', { + 'label': 'Password', + 'type': 'text-private', + 'var': 'muc#roomconfig_roomsecret'}) + .c('value').t('cauldronburn'); + _converse.connection._dataRecv(mock.createRequest(config_stanza)); + + const membersonly = await u.waitUntil(() => view.querySelector('input[name="muc#roomconfig_membersonly"]')); + expect(membersonly.getAttribute('type')).toBe('checkbox'); + membersonly.checked = true; + + const moderated = view.querySelectorAll('input[name="muc#roomconfig_moderatedroom"]'); + expect(moderated.length).toBe(1); + expect(moderated[0].getAttribute('type')).toBe('checkbox'); + moderated[0].checked = true; + + const password = view.querySelectorAll('input[name="muc#roomconfig_roomsecret"]'); + expect(password.length).toBe(1); + expect(password[0].getAttribute('type')).toBe('password'); + + const allowpm = view.querySelectorAll('select[name="muc#roomconfig_allowpm"]'); + expect(allowpm.length).toBe(1); + allowpm[0].value = 'moderators'; + + const presencebroadcast = view.querySelectorAll('select[name="muc#roomconfig_presencebroadcast"]'); + expect(presencebroadcast.length).toBe(1); + presencebroadcast[0].value = ['moderator']; + + view.querySelector('.chatroom-form input[type="submit"]').click(); + + expect(sent_IQ.querySelector('field[var="muc#roomconfig_membersonly"] value').textContent.trim()).toBe('1'); + expect(sent_IQ.querySelector('field[var="muc#roomconfig_moderatedroom"] value').textContent.trim()).toBe('1'); + expect(sent_IQ.querySelector('field[var="muc#roomconfig_allowpm"] value').textContent.trim()).toBe('moderators'); + expect(sent_IQ.querySelector('field[var="muc#roomconfig_presencebroadcast"] value').textContent.trim()).toBe('moderator'); + })); + + + it("properly handles notification that a room has been destroyed", + mock.initConverse([], {}, async function (_converse) { + + await mock.openChatRoomViaModal(_converse, 'problematic@muc.montague.lit', 'romeo') + const presence = $pres().attrs({ + from:'problematic@muc.montague.lit', + id:'n13mt3l', + to:'romeo@montague.lit/pda', + type:'error'}) + .c('error').attrs({'type':'cancel'}) + .c('gone').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'}) + .t('xmpp:other-room@chat.jabberfr.org?join').up() + .c('text').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'}) + .t("We didn't like the name").nodeTree; + + const view = _converse.chatboxviews.get('problematic@muc.montague.lit'); + _converse.connection._dataRecv(mock.createRequest(presence)); + const msg = await u.waitUntil(() => view.querySelector('.chatroom-body .disconnect-msg')); + expect(msg.textContent.trim()).toBe('This groupchat no longer exists'); + expect(view.querySelector('.chatroom-body .destroyed-reason').textContent.trim()) + .toBe(`The following reason was given: "We didn't like the name"`); + expect(view.querySelector('.chatroom-body .moved-label').textContent.trim()) + .toBe('The conversation has moved to a new address. Click the link below to enter.'); + expect(view.querySelector('.chatroom-body .moved-link').textContent.trim()) + .toBe(`other-room@chat.jabberfr.org`); + })); + + it("allows the user to invite their roster contacts to enter the groupchat", + mock.initConverse(['chatBoxesFetched'], {'view_mode': 'fullscreen'}, async function (_converse) { + + // We need roster contacts, so that we have someone to invite + await mock.waitForRoster(_converse, 'current'); + const features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + 'muc_passwordprotected', + 'muc_hidden', + 'muc_temporary', + 'muc_membersonly', + 'muc_unmoderated', + 'muc_anonymous' + ] + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo', features); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + expect(view.model.getOwnAffiliation()).toBe('owner'); + expect(view.model.features.get('open')).toBe(false); + await u.waitUntil(() => view.querySelector('.open-invite-modal')); + + // Members can't invite if the room isn't open + view.model.getOwnOccupant().set('affiliation', 'member'); + + await u.waitUntil(() => view.querySelector('.open-invite-modal') === null); + + view.model.features.set('open', 'true'); + await u.waitUntil(() => view.querySelector('.open-invite-modal')); + + view.querySelector('.open-invite-modal').click(); + const modal = _converse.api.modal.get('converse-muc-invite-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000) + + expect(modal.querySelectorAll('#invitee_jids').length).toBe(1); + expect(modal.querySelectorAll('textarea').length).toBe(1); + + spyOn(view.model, 'directInvite').and.callThrough(); + + const input = modal.querySelector('#invitee_jids input'); + input.value = "Balt"; + modal.querySelector('input[type="submit"]').click(); + + await u.waitUntil(() => modal.querySelector('.error')); + + const error = modal.querySelector('.error'); + expect(error.textContent).toBe('Please enter a valid XMPP address'); + + let evt = new Event('input'); + input.dispatchEvent(evt); + + let sent_stanza; + spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza)); + const hint = await u.waitUntil(() => modal.querySelector('.suggestion-box__results li')); + expect(input.value).toBe('Balt'); + expect(hint.textContent.trim()).toBe('Balthasar'); + + evt = new Event('mousedown', {'bubbles': true}); + evt.button = 0; + hint.dispatchEvent(evt); + + const textarea = modal.querySelector('textarea'); + textarea.value = "Please join!"; + modal.querySelector('input[type="submit"]').click(); + + expect(view.model.directInvite).toHaveBeenCalled(); + expect(Strophe.serialize(sent_stanza)).toBe( + `<message from="romeo@montague.lit/orchard" `+ + `id="${sent_stanza.getAttribute("id")}" `+ + `to="balthasar@montague.lit" `+ + `xmlns="jabber:client">`+ + `<x jid="lounge@montague.lit" reason="Please join!" xmlns="jabber:x:conference"/>`+ + `</message>` + ); + })); + + it("can be joined automatically, based upon a received invite", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); // We need roster contacts, who can invite us + const name = mock.cur_names[0]; + const from_jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await u.waitUntil(() => _converse.roster.get(from_jid).vcard.get('fullname')); + + spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true)); + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + await view.close(); // Hack, otherwise we have to mock stanzas. + + const muc_jid = 'lounge@montague.lit'; + const reason = "Please join this groupchat"; + + expect(_converse.chatboxes.models.length).toBe(1); + expect(_converse.chatboxes.models[0].id).toBe("controlbox"); + + const stanza = u.toStanza(` + <message xmlns="jabber:client" to="${_converse.bare_jid}" from="${from_jid}" id="9bceb415-f34b-4fa4-80d5-c0d076a24231"> + <x xmlns="jabber:x:conference" jid="${muc_jid}" reason="${reason}"/> + </message>`); + await _converse.onDirectMUCInvitation(stanza); + + expect(_converse.api.confirm).toHaveBeenCalledWith( + name + ' has invited you to join a groupchat: '+ muc_jid + + ', and left the following reason: "'+reason+'"'); + expect(_converse.chatboxes.models.length).toBe(2); + expect(_converse.chatboxes.models[0].id).toBe('controlbox'); + expect(_converse.chatboxes.models[1].id).toBe(muc_jid); + })); + + it("shows received groupchat messages", + mock.initConverse([], {}, async function (_converse) { + + const text = 'This is a received message'; + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + spyOn(_converse.api, "trigger").and.callThrough(); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + const nick = mock.chatroom_names[0]; + view.model.occupants.create({ + 'nick': nick, + 'muc_jid': `${view.model.get('jid')}/${nick}` + }); + + const message = $msg({ + from: 'lounge@montague.lit/'+nick, + id: '1', + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(text); + await view.model.handleMessageStanza(message.nodeTree); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelector('.chat-msg__text').textContent.trim()).toBe(text); + expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object)); + })); + + it("shows sent groupchat messages", mock.initConverse([], {}, async function (_converse) { + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + spyOn(_converse.api, "trigger").and.callThrough(); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + const text = 'This is a sent message'; + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = text; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + + expect(_converse.api.trigger).toHaveBeenCalledWith('sendMessage', jasmine.any(Object)); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + + // Let's check that if we receive the same message again, it's + // not shown. + const stanza = u.toStanza(` + <message xmlns="jabber:client" + from="lounge@montague.lit/romeo" + to="${_converse.connection.jid}" + type="groupchat"> + <body>${text}</body> + <stanza-id xmlns="urn:xmpp:sid:0" + id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad" + by="lounge@montague.lit"/> + <origin-id xmlns="urn:xmpp:sid:0" id="${view.model.messages.at(0).get('origin_id')}"/> + </message>`); + await view.model.handleMessageStanza(stanza); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(sizzle('.chat-msg__text:last').pop().textContent.trim()).toBe(text); + expect(view.model.messages.length).toBe(1); + // We don't emit an event if it's our own message + expect(_converse.api.trigger.calls.count(), 1); + })); + + it("will cause the chat area to be scrolled down only if it was at the bottom already", + mock.initConverse([], {}, async function (_converse) { + + const message = 'This message is received while the chat area is scrolled up'; + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + // Create enough messages so that there's a scrollbar. + const promises = []; + for (let i=0; i<20; i++) { + promises.push( + view.model.handleMessageStanza( + $msg({ + from: 'lounge@montague.lit/someone', + to: 'romeo@montague.lit.com', + type: 'groupchat', + id: u.getUniqueId(), + }).c('body').t('Message: '+i).tree()) + ); + } + await Promise.all(promises); + const promise = u.getOpenPromise(); + + // Give enough time for `markScrolled` to have been called + setTimeout(async () => { + const content = view.querySelector('.chat-content'); + content.scrollTop = 0; + await view.model.handleMessageStanza( + $msg({ + from: 'lounge@montague.lit/someone', + to: 'romeo@montague.lit.com', + type: 'groupchat', + id: u.getUniqueId(), + }).c('body').t(message).tree()); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 21); + // Now check that the message appears inside the chatbox in the DOM + const msg_txt = sizzle('.chat-msg:last .chat-msg__text', content).pop().textContent; + expect(msg_txt).toEqual(message); + expect(content.scrollTop).toBe(0); + promise.resolve(); + }, 500); + + return promise; + })); + + + it("informs users if the room configuration has changed", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'coven@chat.shakespeare.lit'; + await mock.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED); + + const stanza = u.toStanza(` + <message from='${muc_jid}' + id='80349046-F26A-44F3-A7A6-54825064DD9E' + to='${_converse.jid}' + type='groupchat'> + <x xmlns='http://jabber.org/protocol/muc#user'> + <status code='170'/> + </x> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-info').length); + const info_messages = view.querySelectorAll('.chat-content .chat-info'); + expect(info_messages[0].textContent.trim()).toBe('Groupchat logging is now enabled'); + })); + + it("queries for the groupchat information before attempting to join the user", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const nick = "some1"; + const IQ_stanzas = _converse.connection.IQ_stanzas; + const muc_jid = 'coven@chat.shakespeare.lit'; + + await _converse.api.rooms.open(muc_jid, { nick }); + const stanza = await u.waitUntil(() => IQ_stanzas.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop()); + + // Check that the groupchat queried for the feautures. + expect(Strophe.serialize(stanza)).toBe( + `<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute("id")}" to="${muc_jid}" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/disco#info"/>`+ + `</iq>`); + + /* <iq from='coven@chat.shakespeare.lit' + * id='ik3vs715' + * to='hag66@shakespeare.lit/pda' + * type='result'> + * <query xmlns='http://jabber.org/protocol/disco#info'> + * <identity + * category='conference' + * name='A Dark Cave' + * type='text'/> + * <feature var='http://jabber.org/protocol/muc'/> + * <feature var='muc_passwordprotected'/> + * <feature var='muc_hidden'/> + * <feature var='muc_temporary'/> + * <feature var='muc_open'/> + * <feature var='muc_unmoderated'/> + * <feature var='muc_nonanonymous'/> + * </query> + * </iq> + */ + const features_stanza = $iq({ + 'from': muc_jid, + 'id': stanza.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result' + }) + .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', { + 'category': 'conference', + 'name': 'A Dark Cave', + 'type': 'text' + }).up() + .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up() + .c('feature', {'var': 'muc_passwordprotected'}).up() + .c('feature', {'var': 'muc_hidden'}).up() + .c('feature', {'var': 'muc_temporary'}).up() + .c('feature', {'var': 'muc_open'}).up() + .c('feature', {'var': 'muc_unmoderated'}).up() + .c('feature', {'var': 'muc_nonanonymous'}); + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + let view = _converse.chatboxviews.get('coven@chat.shakespeare.lit'); + + const sent_stanzas = _converse.connection.sent_stanzas; + await u.waitUntil(() => sent_stanzas.filter(s => s.matches(`presence[to="${muc_jid}/${nick}"]`)).pop()); + view = _converse.chatboxviews.get('coven@chat.shakespeare.lit'); + expect(view.model.features.get('fetched')).toBeTruthy(); + expect(view.model.features.get('passwordprotected')).toBe(true); + expect(view.model.features.get('hidden')).toBe(true); + expect(view.model.features.get('temporary')).toBe(true); + expect(view.model.features.get('open')).toBe(true); + expect(view.model.features.get('unmoderated')).toBe(true); + expect(view.model.features.get('nonanonymous')).toBe(true); + })); + + it("updates the shown features when the groupchat configuration has changed", + mock.initConverse([], {'view_mode': 'fullscreen'}, async function (_converse) { + + let features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + 'muc_passwordprotected', + 'muc_publicroom', + 'muc_temporary', + 'muc_open', + 'muc_unmoderated', + 'muc_nonanonymous' + ]; + const muc_jid = 'room@conference.example.org'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.chatboxviews.get(muc_jid); + + const info_el = view.querySelector(".show-muc-details-modal"); + info_el.click(); + let modal = _converse.api.modal.get('converse-muc-details-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + + let features_list = modal.querySelector('.features-list'); + let features_shown = features_list.textContent.split('\n').map(s => s.trim()).filter(s => s); + + expect(features_shown.join(' ')).toBe( + 'Password protected - This groupchat requires a password before entry '+ + 'Open - Anyone can join this groupchat '+ + 'Temporary - This groupchat will disappear once the last person leaves '+ + 'Not anonymous - All other groupchat participants can see your XMPP address '+ + 'Not moderated - Participants entering this groupchat can write right away'); + expect(view.model.features.get('hidden')).toBe(false); + expect(view.model.features.get('mam_enabled')).toBe(false); + expect(view.model.features.get('membersonly')).toBe(false); + expect(view.model.features.get('moderated')).toBe(false); + expect(view.model.features.get('nonanonymous')).toBe(true); + expect(view.model.features.get('open')).toBe(true); + expect(view.model.features.get('passwordprotected')).toBe(true); + expect(view.model.features.get('persistent')).toBe(false); + expect(view.model.features.get('publicroom')).toBe(true); + expect(view.model.features.get('semianonymous')).toBe(false); + expect(view.model.features.get('temporary')).toBe(true); + expect(view.model.features.get('unmoderated')).toBe(true); + expect(view.model.features.get('unsecured')).toBe(false); + await u.waitUntil(() => view.querySelector('.chatbox-title__text').textContent.trim() === 'Room'); + + modal.querySelector('.close').click(); + view.querySelector('.configure-chatroom-button').click(); + + const IQs = _converse.connection.IQ_stanzas; + const s = `iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_OWNER}"]`; + let iq = await u.waitUntil(() => IQs.filter(iq => iq.querySelector(s)).pop()); + + const response_el = u.toStanza( + `<iq xmlns="jabber:client" + type="result" + to="romeo@montague.lit/pda" + from="room@conference.example.org" id="${iq.getAttribute('id')}"> + <query xmlns="http://jabber.org/protocol/muc#owner"> + <x xmlns="jabber:x:data" type="form"> + <title>Configuration for room@conference.example.org</title> + <instructions>Complete and submit this form to configure the room.</instructions> + <field var="FORM_TYPE" type="hidden"> + <value>http://jabber.org/protocol/muc#roomconfig</value> + </field> + <field type="fixed"> + <value>Room information</value> + </field> + <field var="muc#roomconfig_roomname" type="text-single" label="Title"> + <value>Room</value> + </field> + <field var="muc#roomconfig_roomdesc" type="text-single" label="Description"> + <desc>A brief description of the room</desc> + <value>This room is used in tests</value> + </field> + <field var="muc#roomconfig_lang" type="text-single" label="Language tag for room (e.g. 'en', 'de', 'fr' etc.)"> + <desc>Indicate the primary language spoken in this room</desc> + <value>en</value> + </field> + <field var="muc#roomconfig_persistentroom" type="boolean" label="Persistent (room should remain even when it is empty)"> + <desc>Rooms are automatically deleted when they are empty, unless this option is enabled</desc> + <value>1</value> + </field> + <field var="muc#roomconfig_publicroom" type="boolean" label="Include room information in public lists"> + <desc>Enable this to allow people to find the room</desc> + <value>1</value> + </field> + <field type="fixed"><value>Access to the room</value></field> + <field var="muc#roomconfig_roomsecret" type="text-private" label="Password"><value/></field> + <field var="muc#roomconfig_membersonly" type="boolean" label="Only allow members to join"> + <desc>Enable this to only allow access for room owners, admins and members</desc> + </field> + <field var="{http://prosody.im/protocol/muc}roomconfig_allowmemberinvites" type="boolean" label="Allow members to invite new members"/> + <field type="fixed"><value>Permissions in the room</value> + </field> + <field var="muc#roomconfig_changesubject" type="boolean" label="Allow anyone to set the room's subject"> + <desc>Choose whether anyone, or only moderators, may set the room's subject</desc> + </field> + <field var="muc#roomconfig_moderatedroom" type="boolean" label="Moderated (require permission to speak)"> + <desc>In moderated rooms occupants must be given permission to speak by a room moderator</desc> + </field> + <field var="muc#roomconfig_whois" type="list-single" label="Addresses (JIDs) of room occupants may be viewed by:"> + <option label="Moderators only"><value>moderators</value></option> + <option label="Anyone"><value>anyone</value></option> + <value>anyone</value> + </field> + <field type="fixed"><value>Other options</value></field> + <field var="muc#roomconfig_historylength" type="text-single" label="Maximum number of history messages returned by room"> + <desc>Specify the maximum number of previous messages that should be sent to users when they join the room</desc> + <value>50</value> + </field> + <field var="muc#roomconfig_defaulthistorymessages" type="text-single" label="Default number of history messages returned by room"> + <desc>Specify the number of previous messages sent to new users when they join the room</desc> + <value>20</value> + </field> + </x> + </query> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(response_el)); + await u.waitUntil(() => document.querySelector('.chatroom-form input')); + expect(view.querySelector('.chatroom-form legend').textContent.trim()).toBe("Configuration for room@conference.example.org"); + sizzle('[name="muc#roomconfig_membersonly"]', view).pop().click(); + sizzle('[name="muc#roomconfig_roomname"]', view).pop().value = "New room name" + view.querySelector('.chatroom-form input[type="submit"]').click(); + + iq = await u.waitUntil(() => IQs.filter(iq => u.matchesSelector(iq, `iq[to="${muc_jid}"][type="set"]`)).pop()); + const result = $iq({ + "xmlns": "jabber:client", + "type": "result", + "to": "romeo@montague.lit/orchard", + "from": "lounge@muc.montague.lit", + "id": iq.getAttribute('id') + }); + + IQs.length = 0; // Empty the array + _converse.connection._dataRecv(mock.createRequest(result)); + + iq = await u.waitUntil(() => IQs.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop()); + + const features_stanza = $iq({ + 'from': muc_jid, + 'id': iq.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result' + }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', { + 'category': 'conference', + 'name': 'New room name', + 'type': 'text' + }).up(); + features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + 'muc_passwordprotected', + 'muc_hidden', + 'muc_temporary', + 'muc_membersonly', + 'muc_unmoderated', + 'muc_nonanonymous' + ]; + features.forEach(f => features_stanza.c('feature', {'var': f}).up()); + features_stanza.c('x', { 'xmlns':'jabber:x:data', 'type':'result'}) + .c('field', {'var':'FORM_TYPE', 'type':'hidden'}) + .c('value').t('http://jabber.org/protocol/muc#roominfo').up().up() + .c('field', {'type':'text-single', 'var':'muc#roominfo_description', 'label':'Description'}) + .c('value').t('This is the description').up().up() + .c('field', {'type':'text-single', 'var':'muc#roominfo_occupants', 'label':'Number of occupants'}) + .c('value').t(0); + + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + await u.waitUntil(() => new Promise(success => view.model.features.on('change', success))); + + info_el.click(); + modal = _converse.api.modal.get('converse-muc-details-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + + features_list = modal.querySelector('.features-list'); + features_shown = features_list.textContent.split('\n').map(s => s.trim()).filter(s => s); + expect(features_shown.join(' ')).toBe( + 'Password protected - This groupchat requires a password before entry '+ + 'Hidden - This groupchat is not publicly searchable '+ + 'Members only - This groupchat is restricted to members only '+ + 'Temporary - This groupchat will disappear once the last person leaves '+ + 'Not anonymous - All other groupchat participants can see your XMPP address '+ + 'Not moderated - Participants entering this groupchat can write right away'); + expect(view.model.features.get('hidden')).toBe(true); + expect(view.model.features.get('mam_enabled')).toBe(false); + expect(view.model.features.get('membersonly')).toBe(true); + expect(view.model.features.get('moderated')).toBe(false); + expect(view.model.features.get('nonanonymous')).toBe(true); + expect(view.model.features.get('open')).toBe(false); + expect(view.model.features.get('passwordprotected')).toBe(true); + expect(view.model.features.get('persistent')).toBe(false); + expect(view.model.features.get('publicroom')).toBe(false); + expect(view.model.features.get('semianonymous')).toBe(false); + expect(view.model.features.get('temporary')).toBe(true); + expect(view.model.features.get('unmoderated')).toBe(true); + expect(view.model.features.get('unsecured')).toBe(false); + await u.waitUntil(() => view.querySelector('.chatbox-title__text')?.textContent.trim() === 'New room name'); + })); + + it("indicates when a room is no longer anonymous", + mock.initConverse([], {}, async function (_converse) { + + let IQ_id; + const sendIQ = _converse.connection.sendIQ; + + await mock.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'some1'); + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + + // We pretend this is a new room, so no disco info is returned. + const features_stanza = $iq({ + from: 'coven@chat.shakespeare.lit', + 'id': IQ_id, + 'to': 'romeo@montague.lit/desktop', + 'type': 'error' + }).c('error', {'type': 'cancel'}) + .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"}); + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit'); + /* <message xmlns="jabber:client" + * type="groupchat" + * to="romeo@montague.lit/_converse.js-27854181" + * from="coven@chat.shakespeare.lit"> + * <x xmlns="http://jabber.org/protocol/muc#user"> + * <status code="104"/> + * <status code="172"/> + * </x> + * </message> + */ + const message = $msg({ + type:'groupchat', + to: 'romeo@montague.lit/_converse.js-27854181', + from: 'coven@chat.shakespeare.lit' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('status', {code: '104'}).up() + .c('status', {code: '172'}); + _converse.connection._dataRecv(mock.createRequest(message)); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-info').length); + const chat_body = view.querySelector('.chatroom-body'); + expect(sizzle('.message:last', chat_body).pop().textContent.trim()) + .toBe('This groupchat is now no longer anonymous'); + })); + + it("informs users if they have been kicked out of the groupchat", + mock.initConverse([], {}, async function (_converse) { + + /* <presence + * from='harfleur@chat.shakespeare.lit/pistol' + * to='pistol@shakespeare.lit/harfleur' + * type='unavailable'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='none' role='none'> + * <actor nick='Fluellen'/> + * <reason>Avaunt, you cullion!</reason> + * </item> + * <status code='110'/> + * <status code='307'/> + * </x> + * </presence> + */ + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED); + + const presence = $pres().attrs({ + from:'lounge@montague.lit/romeo', + to:'romeo@montague.lit/pda', + type:'unavailable' + }) + .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'none', + jid: 'romeo@montague.lit/pda', + role: 'none' + }) + .c('actor').attrs({nick: 'Fluellen'}).up() + .c('reason').t('Avaunt, you cullion!').up() + .up() + .c('status').attrs({code:'110'}).up() + .c('status').attrs({code:'307'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => !u.isVisible(view.querySelector('.chat-area'))); + expect(u.isVisible(view.querySelector('.occupants'))).toBeFalsy(); + const chat_body = view.querySelector('.chatroom-body'); + expect(chat_body.querySelectorAll('.disconnect-msg').length).toBe(3); + expect(chat_body.querySelector('.disconnect-msg:first-child').textContent.trim()).toBe( + 'You have been kicked from this groupchat'); + expect(chat_body.querySelector('.disconnect-msg:nth-child(2)').textContent.trim()).toBe( + 'This action was done by Fluellen.'); + expect(chat_body.querySelector('.disconnect-msg:nth-child(3)').textContent.trim()).toBe( + 'The reason given is: "Avaunt, you cullion!".'); + + expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.DISCONNECTED); + })); + + it("informs users if they have exited the groupchat due to a technical reason", + mock.initConverse([], {}, async function (_converse) { + + /* <presence + * from='harfleur@chat.shakespeare.lit/pistol' + * to='pistol@shakespeare.lit/harfleur' + * type='unavailable'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='none' role='none'> + * <actor nick='Fluellen'/> + * <reason>Avaunt, you cullion!</reason> + * </item> + * <status code='110'/> + * <status code='307'/> + * </x> + * </presence> + */ + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const presence = $pres().attrs({ + from:'lounge@montague.lit/romeo', + to:'romeo@montague.lit/pda', + type:'unavailable' + }) + .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'none', + jid: 'romeo@montague.lit/pda', + role: 'none' + }) + .c('reason').t('Flux capacitor overload!').up() + .up() + .c('status').attrs({code:'110'}).up() + .c('status').attrs({code:'333'}).up() + .c('status').attrs({code:'307'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + + const view = _converse.chatboxviews.get('lounge@montague.lit'); + await u.waitUntil(() => !u.isVisible(view.querySelector('.chat-area'))); + expect(u.isVisible(view.querySelector('.occupants'))).toBeFalsy(); + const chat_body = view.querySelector('.chatroom-body'); + expect(chat_body.querySelectorAll('.disconnect-msg').length).toBe(2); + expect(chat_body.querySelector('.disconnect-msg:first-child').textContent.trim()).toBe( + 'You have exited this groupchat due to a technical problem'); + expect(chat_body.querySelector('.disconnect-msg:nth-child(2)').textContent.trim()).toBe( + 'The reason given is: "Flux capacitor overload!".'); + })); + + + it("can be saved to, and retrieved from, browserStorage", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo'); + // We instantiate a new ChatBoxes collection, which by default + // will be empty. + await mock.openControlBox(_converse); + 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.once('chatBoxesFetched', resolve)); + + expect(newchatboxes.length).toEqual(2); + // Check that the chatrooms 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[attrs[i]]); + old_attrs = _converse.chatboxes.models.map(m => m.attributes[attrs[i]]); + expect(new_attrs.sort()).toEqual(old_attrs.sort()); + } + })); + + it("can be closed again by clicking a DOM element with class 'close-chatbox-button'", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const model = await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo'); + spyOn(model, 'close').and.callThrough(); + spyOn(_converse.api, "trigger").and.callThrough(); + spyOn(model, 'leave'); + spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true)); + const view = await u.waitUntil(() => _converse.chatboxviews.get('lounge@montague.lit')); + const button = await u.waitUntil(() => view.querySelector('.close-chatbox-button')); + button.click(); + await u.waitUntil(() => model.close.calls.count()); + expect(model.leave).toHaveBeenCalled(); + await u.waitUntil(() => _converse.api.trigger.calls.count()); + expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object)); + })); + + it("informs users of role and affiliation changes", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + let presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'id':'27C55F89-1C6A-459A-9EB5-77690145D624', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo and annoyingGuy have entered the groupchat"); + + presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'member', + 'role': 'visitor' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo has entered the groupchat\nannoyingGuy has been muted"); + + presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo has entered the groupchat\nannoyingGuy has been given a voice"); + + // Check that we don't see an info message concerning the role, + // if the affiliation has changed. + presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'none', + 'role': 'visitor' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => + Array.from(view.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() === + "annoyingGuy is no longer a member of this groupchat" + ); + expect(1).toBe(1); + })); + + it("notifies users of role and affiliation changes for members not currently in the groupchat", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + + let message = $msg({ + from: 'lounge@montague.lit', + id: '2CF9013B-E8A8-42A1-9633-85AD7CA12F40', + to: 'romeo@montague.lit' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'absentguy@montague.lit', + 'affiliation': 'member', + 'role': 'none' + }); + _converse.connection._dataRecv(mock.createRequest(message)); + await u.waitUntil(() => view.model.occupants.length > 1); + expect(view.model.occupants.length).toBe(2); + expect(view.model.occupants.findWhere({'jid': 'absentguy@montague.lit'}).get('affiliation')).toBe('member'); + + message = $msg({ + from: 'lounge@montague.lit', + id: '2CF9013B-E8A8-42A1-9633-85AD7CA12F41', + to: 'romeo@montague.lit' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'absentguy@montague.lit', + 'affiliation': 'none', + 'role': 'none' + }); + _converse.connection._dataRecv(mock.createRequest(message)); + expect(view.model.occupants.length).toBe(2); + expect(view.model.occupants.findWhere({'jid': 'absentguy@montague.lit'}).get('affiliation')).toBe('none'); + + })); + }); + + + describe("Each chat groupchat can take special commands", function () { + + it("takes /help to show the available commands", + mock.initConverse([], {}, async function (_converse) { + + spyOn(window, 'confirm').and.callFake(() => true); + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 }; + textarea.value = '/help'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter); + + await u.waitUntil(() => sizzle('converse-chat-help .chat-info', view).length); + let chat_help_el = view.querySelector('converse-chat-help'); + let info_messages = sizzle('.chat-info', chat_help_el); + expect(info_messages.length).toBe(19); + expect(info_messages.pop().textContent.trim()).toBe('/voice: Allow muted user to post messages'); + expect(info_messages.pop().textContent.trim()).toBe('/topic: Set groupchat subject (alias for /subject)'); + expect(info_messages.pop().textContent.trim()).toBe('/subject: Set groupchat subject'); + expect(info_messages.pop().textContent.trim()).toBe('/revoke: Revoke the user\'s current affiliation'); + expect(info_messages.pop().textContent.trim()).toBe('/register: Register your nickname'); + expect(info_messages.pop().textContent.trim()).toBe('/owner: Grant ownership of this groupchat'); + expect(info_messages.pop().textContent.trim()).toBe('/op: Grant moderator role to user'); + expect(info_messages.pop().textContent.trim()).toBe('/nick: Change your nickname'); + expect(info_messages.pop().textContent.trim()).toBe('/mute: Remove user\'s ability to post messages'); + expect(info_messages.pop().textContent.trim()).toBe('/modtools: Opens up the moderator tools GUI'); + expect(info_messages.pop().textContent.trim()).toBe('/member: Grant membership to a user'); + expect(info_messages.pop().textContent.trim()).toBe('/me: Write in 3rd person'); + expect(info_messages.pop().textContent.trim()).toBe('/kick: Kick user from groupchat'); + expect(info_messages.pop().textContent.trim()).toBe('/help: Show this menu'); + expect(info_messages.pop().textContent.trim()).toBe('/destroy: Remove this groupchat'); + expect(info_messages.pop().textContent.trim()).toBe('/deop: Change user role to participant'); + expect(info_messages.pop().textContent.trim()).toBe('/clear: Clear the chat area'); + expect(info_messages.pop().textContent.trim()).toBe('/ban: Ban user by changing their affiliation to outcast'); + expect(info_messages.pop().textContent.trim()).toBe('/admin: Change user\'s affiliation to admin'); + + const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid}); + occupant.set('affiliation', 'admin'); + + view.querySelector('.close-chat-help').click(); + expect(view.model.get('show_help_messages')).toBe(false); + await u.waitUntil(() => view.querySelector('converse-chat-help') === null); + + textarea.value = '/help'; + message_form.onKeyDown(enter); + chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help')); + info_messages = sizzle('.chat-info', chat_help_el); + expect(info_messages.length).toBe(18); + let commands = info_messages.map(m => m.textContent.replace(/:.*$/, '')); + expect(commands).toEqual([ + "/admin", "/ban", "/clear", "/deop", "/destroy", + "/help", "/kick", "/me", "/member", "/modtools", "/mute", "/nick", + "/op", "/register", "/revoke", "/subject", "/topic", "/voice" + ]); + occupant.set('affiliation', 'member'); + view.querySelector('.close-chat-help').click(); + await u.waitUntil(() => view.querySelector('converse-chat-help') === null); + + textarea.value = '/help'; + message_form.onKeyDown(enter); + chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help')); + info_messages = sizzle('.chat-info', chat_help_el); + expect(info_messages.length).toBe(9); + commands = info_messages.map(m => m.textContent.replace(/:.*$/, '')); + expect(commands).toEqual(["/clear", "/help", "/kick", "/me", "/modtools", "/mute", "/nick", "/register", "/voice"]); + + view.querySelector('.close-chat-help').click(); + await u.waitUntil(() => view.querySelector('converse-chat-help') === null); + expect(view.model.get('show_help_messages')).toBe(false); + + occupant.set('role', 'participant'); + // Role changes causes rerender, so we need to get the new textarea + + textarea.value = '/help'; + message_form.onKeyDown(enter); + await u.waitUntil(() => view.model.get('show_help_messages')); + chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help')); + info_messages = sizzle('.chat-info', chat_help_el); + expect(info_messages.length).toBe(5); + commands = info_messages.map(m => m.textContent.replace(/:.*$/, '')); + expect(commands).toEqual(["/clear", "/help", "/me", "/nick", "/register"]); + + // Test that /topic is available if all users may change the subject + // Note: we're making a shortcut here, this value should never be set manually + view.model.config.set('changesubject', true); + view.querySelector('.close-chat-help').click(); + await u.waitUntil(() => view.querySelector('converse-chat-help') === null); + + textarea.value = '/help'; + message_form.onKeyDown(enter); + chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help')); + info_messages = sizzle('.chat-info', chat_help_el); + expect(info_messages.length).toBe(7); + commands = info_messages.map(m => m.textContent.replace(/:.*$/, '')); + expect(commands).toEqual(["/clear", "/help", "/me", "/nick", "/register", "/subject", "/topic"]); + })); + + it("takes /help to show the available commands and commands can be disabled by config", + mock.initConverse([], {muc_disable_slash_commands: ['mute', 'voice']}, async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + const enter = { 'target': textarea, 'preventDefault': function () {}, 'keyCode': 13 }; + spyOn(window, 'confirm').and.callFake(() => true); + textarea.value = '/clear'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter); + textarea.value = '/help'; + message_form.onKeyDown(enter); + + await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view).length); + const info_messages = sizzle('.chat-info:not(.chat-event)', view); + expect(info_messages.length).toBe(17); + expect(info_messages.pop().textContent.trim()).toBe('/topic: Set groupchat subject (alias for /subject)'); + expect(info_messages.pop().textContent.trim()).toBe('/subject: Set groupchat subject'); + expect(info_messages.pop().textContent.trim()).toBe('/revoke: Revoke the user\'s current affiliation'); + expect(info_messages.pop().textContent.trim()).toBe('/register: Register your nickname'); + expect(info_messages.pop().textContent.trim()).toBe('/owner: Grant ownership of this groupchat'); + expect(info_messages.pop().textContent.trim()).toBe('/op: Grant moderator role to user'); + expect(info_messages.pop().textContent.trim()).toBe('/nick: Change your nickname'); + expect(info_messages.pop().textContent.trim()).toBe('/modtools: Opens up the moderator tools GUI'); + expect(info_messages.pop().textContent.trim()).toBe('/member: Grant membership to a user'); + expect(info_messages.pop().textContent.trim()).toBe('/me: Write in 3rd person'); + expect(info_messages.pop().textContent.trim()).toBe('/kick: Kick user from groupchat'); + expect(info_messages.pop().textContent.trim()).toBe('/help: Show this menu'); + expect(info_messages.pop().textContent.trim()).toBe('/destroy: Remove this groupchat'); + expect(info_messages.pop().textContent.trim()).toBe('/deop: Change user role to participant'); + expect(info_messages.pop().textContent.trim()).toBe('/clear: Clear the chat area'); + expect(info_messages.pop().textContent.trim()).toBe('/ban: Ban user by changing their affiliation to outcast'); + expect(info_messages.pop().textContent.trim()).toBe('/admin: Change user\'s affiliation to admin'); + })); + + it("takes /member to make an occupant a member", + mock.initConverse([], {}, async function (_converse) { + + let iq_stanza; + await mock.openAndEnterChatRoom(_converse, 'lounge@muc.montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@muc.montague.lit'); + /* We don't show join/leave messages for existing occupants. We + * know about them because we receive their presences before we + * receive our own. + */ + const presence = $pres({ + to: 'romeo@montague.lit/orchard', + from: 'lounge@muc.montague.lit/marc' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'marc@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + expect(view.model.occupants.length).toBe(2); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + let sent_stanza; + spyOn(_converse.connection, 'send').and.callFake((stanza) => { + sent_stanza = stanza; + }); + + // First check that an error message appears when a + // non-existent nick is used. + textarea.value = '/member chris Welcome to the club!'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + expect(_converse.connection.send).not.toHaveBeenCalled(); + await u.waitUntil(() => view.querySelectorAll('.chat-error').length); + expect(view.querySelector('.chat-error').textContent.trim()) + .toBe('Error: couldn\'t find a groupchat participant based on your arguments'); + + // Now test with an existing nick + textarea.value = '/member marc Welcome to the club!'; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + await u.waitUntil(() => Strophe.serialize(sent_stanza) === + `<iq id="${sent_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item affiliation="member" jid="marc@montague.lit">`+ + `<reason>Welcome to the club!</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + let result = $iq({ + "xmlns": "jabber:client", + "type": "result", + "to": "romeo@montague.lit/orchard", + "from": "lounge@muc.montague.lit", + "id": sent_stanza.getAttribute('id') + }); + _converse.connection.IQ_stanzas = []; + _converse.connection._dataRecv(mock.createRequest(result)); + iq_stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="member"]')).pop() + ); + + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq id="${iq_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item affiliation="member"/>`+ + `</query>`+ + `</iq>`) + expect(view.model.occupants.length).toBe(2); + + result = $iq({ + "xmlns": "jabber:client", + "type": "result", + "to": "romeo@montague.lit/orchard", + "from": "lounge@muc.montague.lit", + "id": iq_stanza.getAttribute("id") + }).c("query", {"xmlns": "http://jabber.org/protocol/muc#admin"}) + .c("item", {"jid": "marc", "affiliation": "member"}); + _converse.connection._dataRecv(mock.createRequest(result)); + + expect(view.model.occupants.length).toBe(2); + iq_stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="owner"]')).pop() + ); + + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq id="${iq_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item affiliation="owner"/>`+ + `</query>`+ + `</iq>`) + expect(view.model.occupants.length).toBe(2); + + result = $iq({ + "xmlns": "jabber:client", + "type": "result", + "to": "romeo@montague.lit/orchard", + "from": "lounge@muc.montague.lit", + "id": iq_stanza.getAttribute("id") + }).c("query", {"xmlns": "http://jabber.org/protocol/muc#admin"}) + .c("item", {"jid": "romeo@montague.lit", "affiliation": "owner"}); + _converse.connection._dataRecv(mock.createRequest(result)); + + expect(view.model.occupants.length).toBe(2); + iq_stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="admin"]')).pop() + ); + + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq id="${iq_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item affiliation="admin"/>`+ + `</query>`+ + `</iq>`) + expect(view.model.occupants.length).toBe(2); + + result = $iq({ + "xmlns": "jabber:client", + "type": "result", + "to": "romeo@montague.lit/orchard", + "from": "lounge@muc.montague.lit", + "id": iq_stanza.getAttribute("id") + }).c("query", {"xmlns": "http://jabber.org/protocol/muc#admin"}) + _converse.connection._dataRecv(mock.createRequest(result)); + await u.waitUntil(() => view.querySelectorAll('.occupant').length, 500); + await u.waitUntil(() => view.querySelectorAll('.badge').length > 1); + expect(view.model.occupants.length).toBe(2); + expect(view.querySelectorAll('.occupant').length).toBe(2); + })); + + it("takes /topic to set the groupchat topic", mock.initConverse([], {}, async function (_converse) { + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + // Check the alias /topic + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/topic This is the groupchat subject'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + const { sent_stanzas } = _converse.connection; + await u.waitUntil(() => sent_stanzas.filter(s => s.textContent.trim() === 'This is the groupchat subject')); + + // Check /subject + textarea.value = '/subject This is a new subject'; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + + let sent_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.textContent.trim() === 'This is a new subject').pop()); + expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe( + '<message from="romeo@montague.lit/orchard" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">'+ + '<subject xmlns="jabber:client">This is a new subject</subject>'+ + '</message>'); + + // Check case insensitivity + textarea.value = '/Subject This is yet another subject'; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + sent_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.textContent.trim() === 'This is yet another subject').pop()); + expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe( + '<message from="romeo@montague.lit/orchard" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">'+ + '<subject xmlns="jabber:client">This is yet another subject</subject>'+ + '</message>'); + + while (sent_stanzas.length) { + sent_stanzas.pop(); + } + // Check unsetting the topic + textarea.value = '/topic'; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + sent_stanza = await u.waitUntil(() => sent_stanzas.pop()); + expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe( + '<message from="romeo@montague.lit/orchard" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">'+ + '<subject xmlns="jabber:client"></subject>'+ + '</message>'); + })); + + it("takes /clear to clear messages", mock.initConverse([], {}, async function (_converse) { + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/clear'; + spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(false)); + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: 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?'); + })); + + it("takes /owner to make a user an owner", mock.initConverse([], {}, async function (_converse) { + let sent_IQ, IQ_id; + const sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_IQ = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); + + let presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'id':'27C55F89-1C6A-459A-9EB5-77690145D624', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/owner'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count()); + const err_msg = await u.waitUntil(() => view.querySelector('.chat-error')); + expect(err_msg.textContent.trim()).toBe( + "Error: the \"owner\" command takes two arguments, the user's nickname and optionally a reason."); + + const sel = 'iq[type="set"] query[xmlns="http://jabber.org/protocol/muc#admin"]'; + const stanzas = _converse.connection.IQ_stanzas.filter(s => sizzle(sel, s).length); + expect(stanzas.length).toBe(0); + + // XXX: Calling onFormSubmitted directly, trying + // again via triggering Event doesn't work for some weird + // reason. + textarea.value = '/owner nobody You\'re responsible'; + message_form.onFormSubmitted(new Event('submit')); + await u.waitUntil(() => view.querySelectorAll('.chat-error').length === 2); + expect(Array.from(view.querySelectorAll('.chat-error')).pop().textContent.trim()).toBe( + "Error: couldn't find a groupchat participant based on your arguments"); + + expect(_converse.connection.IQ_stanzas.filter(s => sizzle(sel, s).length).length).toBe(0); + + // Call now with the correct of arguments. + // XXX: Calling onFormSubmitted directly, trying + // again via triggering Event doesn't work for some weird + // reason. + textarea.value = '/owner annoyingGuy You\'re responsible'; + message_form.onFormSubmitted(new Event('submit')); + + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 3); + // Check that the member list now gets updated + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item affiliation="owner" jid="annoyingguy@montague.lit">`+ + `<reason>You're responsible</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'id':'27C55F89-1C6A-459A-9EB5-77690145D628', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'owner', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => + Array.from(view.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() === + "annoyingGuy is now an owner of this groupchat" + ); + })); + + it("takes /ban to ban a user", mock.initConverse([], {}, async function (_converse) { + let sent_IQ, IQ_id; + const sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_IQ = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); + + let presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'id':'27C55F89-1C6A-459A-9EB5-77690145D624', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/ban'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count()); + await u.waitUntil(() => view.querySelector('.message:last-child')?.textContent?.trim() === + "Error: the \"ban\" command takes two arguments, the user's nickname and optionally a reason."); + + const sel = 'iq[type="set"] query[xmlns="http://jabber.org/protocol/muc#admin"]'; + const stanzas = _converse.connection.IQ_stanzas.filter(s => sizzle(sel, s).length); + expect(stanzas.length).toBe(0); + + // Call now with the correct amount of arguments. + // XXX: Calling onFormSubmitted directly, trying + // again via triggering Event doesn't work for some weird + // reason. + textarea.value = '/ban annoyingGuy You\'re annoying'; + message_form.onFormSubmitted(new Event('submit')); + + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 2); + // Check that the member list now gets updated + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item affiliation="outcast" jid="annoyingguy@montague.lit">`+ + `<reason>You're annoying</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'id':'27C55F89-1C6A-459A-9EB5-77690145D628', + 'to': 'romeo@montague.lit/desktop' + }).c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'outcast', + 'role': 'participant' + }).c('actor', {'nick': 'romeo'}).up() + .c('reason').t("You're annoying").up().up() + .c('status', {'code': '301'}); + + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 2); + expect(view.querySelectorAll('.chat-info__message')[1].textContent.trim()).toBe("annoyingGuy has been banned by romeo"); + expect(view.querySelector('.chat-info:last-child q').textContent.trim()).toBe("You're annoying"); + presence = $pres({ + 'from': 'lounge@montague.lit/joe2', + 'id':'27C55F89-1C6A-459A-9EB5-77690145D624', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'joe2@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + textarea.value = '/ban joe22'; + message_form.onFormSubmitted(new Event('submit')); + await u.waitUntil(() => view.querySelector('converse-chat-message:last-child')?.textContent?.trim() === + "Error: couldn't find a groupchat participant based on your arguments"); + })); + + + it("takes a /kick command to kick a user", mock.initConverse([], {}, async function (_converse) { + let sent_IQ, IQ_id; + const sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_IQ = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + spyOn(view.model, 'setRole').and.callThrough(); + spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); + + let presence = $pres({ + 'from': 'lounge@montague.lit/annoying guy', + 'id':'27C55F89-1C6A-459A-9EB5-77690145D624', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'none', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/kick'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count()); + await u.waitUntil(() => view.querySelector('.message:last-child')?.textContent?.trim() === + "Error: the \"kick\" command takes two arguments, the user's nickname and optionally a reason."); + expect(view.model.setRole).not.toHaveBeenCalled(); + // Call now with the correct amount of arguments. + // XXX: Calling onFormSubmitted directly, trying + // again via triggering Event doesn't work for some weird + // reason. + textarea.value = '/kick @annoying guy You\'re annoying'; + message_form.onFormSubmitted(new Event('submit')); + + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 2); + expect(view.model.setRole).toHaveBeenCalled(); + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item nick="annoying guy" role="none">`+ + `<reason>You're annoying</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + /* <presence + * from='harfleur@chat.shakespeare.lit/pistol' + * to='gower@shakespeare.lit/cell' + * type='unavailable'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='none' role='none'/> + * <status code='307'/> + * </x> + * </presence> + */ + presence = $pres({ + 'from': 'lounge@montague.lit/annoying guy', + 'to': 'romeo@montague.lit/desktop', + 'type': 'unavailable' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'affiliation': 'none', + 'role': 'none' + }).c('actor', {'nick': 'romeo'}).up() + .c('reason').t("You're annoying").up().up() + .c('status', {'code': '307'}); + + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 2); + expect(view.querySelectorAll('.chat-info__message')[1].textContent.trim()).toBe("annoying guy has been kicked out by romeo"); + expect(view.querySelector('.chat-info:last-child q').textContent.trim()).toBe("You're annoying"); + })); + + + it("takes /op and /deop to make a user a moderator or not", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + let sent_IQ, IQ_id; + const sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_IQ = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + spyOn(view.model, 'setRole').and.callThrough(); + spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); + + // New user enters the groupchat + /* <presence + * from='coven@chat.shakespeare.lit/thirdwitch' + * id='27C55F89-1C6A-459A-9EB5-77690145D624' + * to='crone1@shakespeare.lit/desktop'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' role='moderator'/> + * </x> + * </presence> + */ + let presence = $pres({ + 'from': 'lounge@montague.lit/trustworthyguy', + 'id':'27C55F89-1C6A-459A-9EB5-77690145D624', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'trustworthyguy@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo and trustworthyguy have entered the groupchat"); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/op'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count()); + await u.waitUntil(() => view.querySelector('.message:last-child')?.textContent?.trim() === + "Error: the \"op\" command takes two arguments, the user's nickname and optionally a reason."); + + expect(view.model.setRole).not.toHaveBeenCalled(); + // Call now with the correct amount of arguments. + // XXX: Calling onFormSubmitted directly, trying + // again via triggering Event doesn't work for some weird + // reason. + textarea.value = '/op trustworthyguy You\'re trustworthy'; + message_form.onFormSubmitted(new Event('submit')); + + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 2); + expect(view.model.setRole).toHaveBeenCalled(); + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item nick="trustworthyguy" role="moderator">`+ + `<reason>You're trustworthy</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + /* <presence + * from='coven@chat.shakespeare.lit/thirdwitch' + * to='crone1@shakespeare.lit/desktop'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' + * jid='hag66@shakespeare.lit/pda' + * role='moderator'/> + * </x> + * </presence> + */ + presence = $pres({ + 'from': 'lounge@montague.lit/trustworthyguy', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'trustworthyguy@montague.lit', + 'affiliation': 'member', + 'role': 'moderator' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + // Check now that things get restored when the user is given a voice + await u.waitUntil( + () => view.querySelector('.chat-content__notifications').textContent.split('\n', 2).pop()?.trim() === + "trustworthyguy is now a moderator"); + + // Call now with the correct amount of arguments. + // XXX: Calling onFormSubmitted directly, trying + // again via triggering Event doesn't work for some weird + // reason. + textarea.value = '/deop trustworthyguy Perhaps not'; + message_form.onFormSubmitted(new Event('submit')); + + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 3); + expect(view.model.setRole).toHaveBeenCalled(); + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item nick="trustworthyguy" role="participant">`+ + `<reason>Perhaps not</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + /* <presence + * from='coven@chat.shakespeare.lit/thirdwitch' + * to='crone1@shakespeare.lit/desktop'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' + * jid='hag66@shakespeare.lit/pda' + * role='participant'/> + * </x> + * </presence> + */ + presence = $pres({ + 'from': 'lounge@montague.lit/trustworthyguy', + 'to': 'romeo@montague.lit/desktop' + }).c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'trustworthyguy@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.includes("trustworthyguy is no longer a moderator")); + })); + + it("takes /mute and /voice to mute and unmute a user", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + var sent_IQ, IQ_id; + var sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_IQ = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + spyOn(view.model, 'setRole').and.callThrough(); + spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); + + // New user enters the groupchat + /* <presence + * from='coven@chat.shakespeare.lit/thirdwitch' + * id='27C55F89-1C6A-459A-9EB5-77690145D624' + * to='crone1@shakespeare.lit/desktop'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' role='participant'/> + * </x> + * </presence> + */ + let presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'id':'27C55F89-1C6A-459A-9EB5-77690145D624', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo and annoyingGuy have entered the groupchat"); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/mute'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count()); + await u.waitUntil(() => view.querySelector('.message:last-child')?.textContent?.trim() === + "Error: the \"mute\" command takes two arguments, the user's nickname and optionally a reason."); + expect(view.model.setRole).not.toHaveBeenCalled(); + // Call now with the correct amount of arguments. + // XXX: Calling onFormSubmitted directly, trying + // again via triggering Event doesn't work for some weird + // reason. + textarea.value = '/mute annoyingGuy You\'re annoying'; + message_form.onFormSubmitted(new Event('submit')); + + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 2) + expect(view.model.setRole).toHaveBeenCalled(); + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item nick="annoyingGuy" role="visitor">`+ + `<reason>You're annoying</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + /* <presence + * from='coven@chat.shakespeare.lit/thirdwitch' + * to='crone1@shakespeare.lit/desktop'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' + * jid='hag66@shakespeare.lit/pda' + * role='visitor'/> + * </x> + * </presence> + */ + presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'member', + 'role': 'visitor' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.includes("annoyingGuy has been muted")); + + // Call now with the correct of arguments. + // XXX: Calling onFormSubmitted directly, trying + // again via triggering Event doesn't work for some weird + // reason. + textarea.value = '/voice annoyingGuy Now you can talk again'; + message_form.onFormSubmitted(new Event('submit')); + + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 3); + expect(view.model.setRole).toHaveBeenCalled(); + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item nick="annoyingGuy" role="participant">`+ + `<reason>Now you can talk again</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + /* <presence + * from='coven@chat.shakespeare.lit/thirdwitch' + * to='crone1@shakespeare.lit/desktop'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' + * jid='hag66@shakespeare.lit/pda' + * role='visitor'/> + * </x> + * </presence> + */ + presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.includes("annoyingGuy has been given a voice")); + })); + + it("takes /destroy to destroy a muc", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const new_muc_jid = 'foyer@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + let view = _converse.chatboxviews.get(muc_jid); + spyOn(_converse.api, 'confirm').and.callThrough(); + let textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/destroy'; + let message_form = view.querySelector('converse-muc-message-form'); + message_form.onFormSubmitted(new Event('submit')); + let modal = await u.waitUntil(() => document.querySelector('.modal-dialog')); + await u.waitUntil(() => u.isVisible(modal)); + + let challenge_el = modal.querySelector('[name="challenge"]'); + challenge_el.value = muc_jid+'e'; + const reason_el = modal.querySelector('[name="reason"]'); + reason_el.value = 'Moved to a new location'; + const newjid_el = modal.querySelector('[name="newjid"]'); + newjid_el.value = new_muc_jid; + let submit = modal.querySelector('[type="submit"]'); + submit.click(); + expect(u.isVisible(modal)).toBeTruthy(); + expect(u.hasClass('error', challenge_el)).toBeTruthy(); + challenge_el.value = muc_jid; + submit.click(); + + let sent_IQs = _converse.connection.IQ_stanzas; + let sent_IQ = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('destroy')).pop()); + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${sent_IQ.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#owner">`+ + `<destroy jid="${new_muc_jid}">`+ + `<reason>`+ + `Moved to a new location`+ + `</reason>`+ + `</destroy>`+ + `</query>`+ + `</iq>`); + + let result_stanza = $iq({ + 'type': 'result', + 'id': sent_IQ.getAttribute('id'), + 'from': view.model.get('jid'), + 'to': _converse.connection.jid + }); + expect(_converse.chatboxes.length).toBe(2); + spyOn(_converse.api, "trigger").and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(result_stanza)); + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED)); + await u.waitUntil(() => _converse.chatboxes.length === 1); + expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object)); + + // Try again without reason or new JID + _converse.connection.IQ_stanzas = []; + sent_IQs = _converse.connection.IQ_stanzas; + await mock.openAndEnterChatRoom(_converse, new_muc_jid, 'romeo'); + view = _converse.chatboxviews.get(new_muc_jid); + textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/destroy'; + message_form = view.querySelector('converse-muc-message-form'); + message_form.onFormSubmitted(new Event('submit')); + modal = await u.waitUntil(() => document.querySelector('.modal-dialog')); + await u.waitUntil(() => u.isVisible(modal)); + + challenge_el = modal.querySelector('[name="challenge"]'); + challenge_el.value = new_muc_jid; + submit = modal.querySelector('[type="submit"]'); + submit.click(); + + sent_IQ = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('destroy')).pop()); + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${sent_IQ.getAttribute('id')}" to="${new_muc_jid}" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#owner">`+ + `<destroy/>`+ + `</query>`+ + `</iq>`); + + result_stanza = $iq({ + 'type': 'result', + 'id': sent_IQ.getAttribute('id'), + 'from': view.model.get('jid'), + 'to': _converse.connection.jid + }); + expect(_converse.chatboxes.length).toBe(2); + _converse.connection._dataRecv(mock.createRequest(result_stanza)); + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED)); + await u.waitUntil(() => _converse.chatboxes.length === 1); + })); + }); + + describe("When attempting to enter a groupchat", function () { + + it("will show an error message if the groupchat requires a password", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'protected'; + await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + + const presence = $pres().attrs({ + 'from': `${muc_jid}/romeo`, + 'id': u.getUniqueId(), + 'to': 'romeo@montague.lit/pda', + 'type': 'error' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({by:'lounge@montague.lit', type:'auth'}) + .c('not-authorized').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}); + + _converse.connection._dataRecv(mock.createRequest(presence)); + + const chat_body = view.querySelector('.chatroom-body'); + await u.waitUntil(() => chat_body.querySelectorAll('form.chatroom-form').length === 1); + expect(chat_body.querySelector('.chatroom-form label').textContent.trim()) + .toBe('This groupchat requires a password'); + + // Let's submit the form + spyOn(view.model, 'join'); + const input_el = view.querySelector('[name="password"]'); + input_el.value = 'secret'; + view.querySelector('input[type=submit]').click(); + expect(view.model.join).toHaveBeenCalledWith('romeo', 'secret'); + })); + + it("will show an error message if the groupchat is members-only and the user not included", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'members-only@muc.montague.lit' + await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop()); + + // State that the chat is members-only via the features IQ + const features_stanza = $iq({ + 'from': muc_jid, + 'id': iq.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result' + }) + .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', { + 'category': 'conference', + 'name': 'A Dark Cave', + 'type': 'text' + }).up() + .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up() + .c('feature', {'var': 'muc_hidden'}).up() + .c('feature', {'var': 'muc_temporary'}).up() + .c('feature', {'var': 'muc_membersonly'}).up(); + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING); + + const presence = $pres().attrs({ + from: `${muc_jid}/romeo`, + id: u.getUniqueId(), + to: 'romeo@montague.lit/pda', + type: 'error' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({by:'lounge@montague.lit', type:'auth'}) + .c('registration-required').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child')?.textContent?.trim() === + 'You are not on the member list of this groupchat.'); + })); + + it("will show an error message if the user has been banned", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'off-limits@muc.montague.lit' + await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo'); + + const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop()); + + const features_stanza = $iq({ + 'from': muc_jid, + 'id': iq.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result' + }) + .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up() + .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up() + .c('feature', {'var': 'muc_hidden'}).up() + .c('feature', {'var': 'muc_temporary'}).up() + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING); + + const presence = $pres().attrs({ + from: `${muc_jid}/romeo`, + id: u.getUniqueId(), + to: 'romeo@montague.lit/pda', + type: 'error' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({by:'lounge@montague.lit', type:'auth'}) + .c('forbidden').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + + const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child')); + expect(el.textContent.trim()).toBe('You have been banned from this groupchat'); + expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.BANNED); + })); + + it("will show an error message if the user is not allowed to have created the groupchat", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'impermissable@muc.montague.lit' + await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo') + + // We pretend this is a new room, so no disco info is returned. + const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop()); + const features_stanza = $iq({ + 'from': 'room@conference.example.org', + 'id': iq.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'error' + }).c('error', {'type': 'cancel'}) + .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"}); + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING)); + + const presence = $pres().attrs({ + from: `${muc_jid}/romeo`, + id: u.getUniqueId(), + to:'romeo@montague.lit/pda', + type:'error' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({by:'lounge@montague.lit', type:'cancel'}) + .c('not-allowed').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child')); + expect(el.textContent.trim()).toBe('You are not allowed to create new groupchats.'); + })); + + it("will show an error message if the groupchat doesn't yet exist", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'nonexistent@muc.montague.lit' + await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo'); + + const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop()); + const features_stanza = $iq({ + 'from': muc_jid, + 'id': iq.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result' + }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up() + .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up() + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING)); + + const presence = $pres().attrs({ + from: `${muc_jid}/romeo`, + id: u.getUniqueId(), + to: 'romeo@montague.lit/pda', + type:'error' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({by:'lounge@montague.lit', type:'cancel'}) + .c('item-not-found').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child')); + expect(el.textContent.trim()).toBe("This groupchat does not (yet) exist."); + })); + + it("will show an error message if the groupchat has reached its maximum number of participants", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'maxed-out@muc.montague.lit' + await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo') + + const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop()); + const features_stanza = $iq({ + 'from': muc_jid, + 'id': iq.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result' + }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up() + .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up() + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING)); + + const presence = $pres().attrs({ + from: `${muc_jid}/romeo`, + id: u.getUniqueId(), + to:'romeo@montague.lit/pda', + type:'error' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({by:'lounge@montague.lit', type:'cancel'}) + .c('service-unavailable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child')); + expect(el.textContent.trim()).toBe("This groupchat has reached its maximum number of participants."); + })); + }); + + + describe("The affiliations delta", function () { + + it("can be computed in various ways", mock.initConverse([], {}, async function (_converse) { + await mock.openChatRoom(_converse, 'coven', 'chat.shakespeare.lit', 'romeo'); + var exclude_existing = false; + var remove_absentees = false; + var new_list = []; + var old_list = []; + const muc_utils = converse.env.muc_utils; + let delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + expect(delta.length).toBe(0); + + new_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}]; + old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}]; + delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + expect(delta.length).toBe(0); + + // When remove_absentees is false, then affiliations in the old + // list which are not in the new one won't be removed. + old_list = [{'jid': 'oldhag666@shakespeare.lit', 'affiliation': 'owner'}, + {'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}]; + delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + expect(delta.length).toBe(0); + + // With exclude_existing set to false, any changed affiliations + // will be included in the delta (i.e. existing affiliations are included in the comparison). + old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'owner'}]; + delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + expect(delta.length).toBe(1); + expect(delta[0].jid).toBe('wiccarocks@shakespeare.lit'); + expect(delta[0].affiliation).toBe('member'); + + // To also remove affiliations from the old list which are not + // in the new list, we set remove_absentees to true + remove_absentees = true; + old_list = [{'jid': 'oldhag666@shakespeare.lit', 'affiliation': 'owner'}, + {'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}]; + delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + expect(delta.length).toBe(1); + expect(delta[0].jid).toBe('oldhag666@shakespeare.lit'); + expect(delta[0].affiliation).toBe('none'); + + delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, [], old_list); + expect(delta.length).toBe(2); + expect(delta[0].jid).toBe('oldhag666@shakespeare.lit'); + expect(delta[0].affiliation).toBe('none'); + expect(delta[1].jid).toBe('wiccarocks@shakespeare.lit'); + expect(delta[1].affiliation).toBe('none'); + + // To only add a user if they don't already have an + // affiliation, we set 'exclude_existing' to true + exclude_existing = true; + old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'owner'}]; + delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + expect(delta.length).toBe(0); + + old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'admin'}]; + delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + expect(delta.length).toBe(0); + })); + }); + + + describe("A XEP-0085 Chat Status Notification", function () { + + it("is is not sent out to a MUC if the user is a visitor in a moderated room", + mock.initConverse( + ['chatBoxesFetched'], {}, + async function (_converse) { + + spyOn(_converse.ChatRoom.prototype, 'sendChatState').and.callThrough(); + + const muc_jid = 'lounge@montague.lit'; + const features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + 'muc_passwordprotected', + 'muc_hidden', + 'muc_temporary', + 'muc_membersonly', + 'muc_moderated', + 'muc_anonymous' + ] + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + + const view = _converse.chatboxviews.get(muc_jid); + view.model.setChatState(_converse.ACTIVE); + + expect(view.model.sendChatState).toHaveBeenCalled(); + const last_stanza = _converse.connection.sent_stanzas.pop(); + expect(Strophe.serialize(last_stanza)).toBe( + `<message to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<no-store xmlns="urn:xmpp:hints"/>`+ + `<no-permanent-store xmlns="urn:xmpp:hints"/>`+ + `</message>`); + + // Romeo loses his voice + const presence = $pres({ + to: 'romeo@montague.lit/orchard', + from: `${muc_jid}/romeo` + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', {'affiliation': 'none', 'role': 'visitor'}).up() + .c('status', {code: '110'}); + _converse.connection._dataRecv(mock.createRequest(presence)); + + const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid}); + await u.waitUntil(() => occupant.get('role') === 'visitor'); + + spyOn(_converse.connection, 'send'); + view.model.setChatState(_converse.INACTIVE); + expect(view.model.sendChatState.calls.count()).toBe(2); + expect(_converse.connection.send).not.toHaveBeenCalled(); + })); + + + describe("A composing notification", function () { + + it("will be shown if received", mock.initConverse([], {}, async function (_converse) { + const muc_jid = 'coven@chat.shakespeare.lit'; + const members = [ + {'affiliation': 'member', 'nick': 'majortom', 'jid': 'majortom@example.org'}, + {'affiliation': 'admin', 'nick': 'groundcontrol', 'jid': 'groundcontrol@example.org'} + ]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'some1', [], members); + const view = _converse.chatboxviews.get(muc_jid); + + let csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent); + expect(csntext.trim()).toEqual("some1 has entered the groupchat"); + + let presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1 and newguy have entered the groupchat"); + + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/nomorenicks' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'nomorenicks@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1, newguy and nomorenicks have entered the groupchat", 1000); + + // Manually clear so that we can more easily test + view.model.notifications.set('entered', []); + await u.waitUntil(() => !view.querySelector('.chat-content__notifications').textContent, 1000); + + // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions + + const remove_notifications_timeouts = []; + const setTimeout = window.setTimeout; + spyOn(window, 'setTimeout').and.callFake((f, w) => { + if (f.toString() === "() => this.removeNotification(actor, state)") { + remove_notifications_timeouts.push(f) + } + setTimeout(f, w); + }); + + // <composing> state + let msg = $msg({ + from: muc_jid+'/newguy', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + _converse.connection._dataRecv(mock.createRequest(msg)); + + csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent, 1000); + expect(csntext.trim()).toEqual('newguy is typing'); + expect(remove_notifications_timeouts.length).toBe(1); + expect(view.querySelector('.chat-content__notifications').textContent.trim()).toEqual('newguy is typing'); + + // <composing> state for a different occupant + msg = $msg({ + from: muc_jid+'/nomorenicks', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === 'newguy and nomorenicks are typing', 1000); + + // <composing> state for a different occupant + msg = $msg({ + from: muc_jid+'/majortom', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === 'newguy, nomorenicks and majortom are typing', 1000); + + // <composing> state for a different occupant + msg = $msg({ + from: muc_jid+'/groundcontrol', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === 'newguy, nomorenicks and others are typing', 1000); + + msg = $msg({ + from: `${muc_jid}/some1`, + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('hello world').tree(); + await view.model.handleMessageStanza(msg); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + expect(view.querySelector('.chat-msg .chat-msg__text').textContent.trim()).toBe('hello world'); + + // Test that the composing notifications get removed via timeout. + if (remove_notifications_timeouts.length) { + remove_notifications_timeouts[0](); + } + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === 'nomorenicks, majortom and groundcontrol are typing', 1000); + })); + }); + + describe("A paused notification", function () { + + it("will be shown if received", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const muc_jid = 'coven@chat.shakespeare.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'some1'); + const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit'); + + /* <presence to="romeo@montague.lit/_converse.js-29092160" + * from="coven@chat.shakespeare.lit/some1"> + * <x xmlns="http://jabber.org/protocol/muc#user"> + * <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/> + * <status code="110"/> + * </x> + * </presence></body> + */ + let presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/some1' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'owner', + 'jid': 'romeo@montague.lit/_converse.js-29092160', + 'role': 'moderator' + }).up() + .c('status', {code: '110'}); + _converse.connection._dataRecv(mock.createRequest(presence)); + const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent); + expect(csntext.trim()).toEqual("some1 has entered the groupchat"); + + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1 and newguy have entered the groupchat"); + + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/nomorenicks' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'nomorenicks@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1, newguy and nomorenicks have entered the groupchat"); + + // Manually clear so that we can more easily test + view.model.notifications.set('entered', []); + await u.waitUntil(() => !view.querySelector('.chat-content__notifications').textContent); + + // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions + + // <composing> state + let msg = $msg({ + from: muc_jid+'/newguy', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent); + expect(view.querySelector('.chat-content__notifications').textContent.trim()).toBe('newguy is typing'); + + // <composing> state for a different occupant + msg = $msg({ + from: muc_jid+'/nomorenicks', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + await view.model.handleMessageStanza(msg); + + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() == 'newguy and nomorenicks are typing'); + + // <paused> state from occupant who typed first + msg = $msg({ + from: muc_jid+'/newguy', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() == 'nomorenicks is typing\nnewguy has stopped typing'); + })); + }); + }); + + describe("A muted user", function () { + + it("will receive a user-friendly error message when trying to send a message", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'trollbox@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'troll'); + const view = _converse.chatboxviews.get(muc_jid); + const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea')); + textarea.value = 'Hello world'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onFormSubmitted(new Event('submit')); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + let stanza = u.toStanza(` + <message id="${view.model.messages.at(0).get('msgid')}" + xmlns="jabber:client" + type="error" + to="troll@montague.lit/resource" + from="trollbox@montague.lit"> + <error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelector('.chat-msg__error')?.textContent.trim(), 1000); + expect(view.querySelector('.chat-msg__error').textContent.trim()).toBe( + "Your message was not delivered because you weren't allowed to send it."); + + textarea.value = 'Hello again'; + message_form.onFormSubmitted(new Event('submit')); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); + + stanza = u.toStanza(` + <message id="${view.model.messages.at(1).get('msgid')}" + xmlns="jabber:client" + type="error" + to="troll@montague.lit/resource" + from="trollbox@montague.lit"> + <error type="auth"> + <forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/> + <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">Thou shalt not!</text> + </error> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__error').length === 2); + const sel = 'converse-message-history converse-chat-message:last-child .chat-msg__error'; + await u.waitUntil(() => view.querySelector(sel)?.textContent.trim()); + expect(view.querySelector(sel).textContent.trim()).toBe('Thou shalt not!') + })); + + it("will see an explanatory message instead of a textarea", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + Strophe.NS.SID, + 'muc_moderated', + ] + const muc_jid = 'trollbox@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'troll', features); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.querySelector('.chat-textarea')); + + let stanza = u.toStanza(` + <presence + from='trollbox@montague.lit/troll' + to='romeo@montague.lit/orchard'> + <x xmlns='http://jabber.org/protocol/muc#user'> + <item affiliation='none' + nick='troll' + role='visitor'/> + <status code='110'/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => view.querySelector('.chat-textarea') === null); + let bottom_panel = view.querySelector('.muc-bottom-panel'); + expect(bottom_panel.textContent.trim()).toBe("You're not allowed to send messages in this room"); + + // This only applies to moderated rooms, so let's check that + // the textarea becomes visible when the room's + // configuration changes to be non-moderated + view.model.features.set('moderated', false); + await u.waitUntil(() => view.querySelector('.muc-bottom-panel') === null); + const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea')); + expect(textarea === null).toBe(false); + + view.model.features.set('moderated', true); + await u.waitUntil(() => view.querySelector('.chat-textarea') === null); + bottom_panel = view.querySelector('.muc-bottom-panel'); + expect(bottom_panel.textContent.trim()).toBe("You're not allowed to send messages in this room"); + + // Check now that things get restored when the user is given a voice + await u.waitUntil(() => + Array.from(view.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() === + "troll is no longer an owner of this groupchat" + ); + + stanza = u.toStanza(` + <presence + from='trollbox@montague.lit/troll' + to='romeo@montague.lit/orchard'> + <x xmlns='http://jabber.org/protocol/muc#user'> + <item affiliation='none' + nick='troll' + role='participant'/> + <status code='110'/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelector('.muc-bottom-panel') === null); + expect(textarea === null).toBe(false); + // Check now that things get restored when the user is given a voice + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === "troll has been given a voice"); + })); + }); + + describe("when muc_send_probes is true", function () { + + it("sends presence probes when muc_send_probes is true", + mock.initConverse([], {'muc_send_probes': true}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + + let stanza = u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="${muc_jid}/ralphm"> + <body>This message will trigger a presence probe</body> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + const view = _converse.chatboxviews.get(muc_jid); + + await u.waitUntil(() => view.model.messages.length); + let occupant = view.model.messages.at(0)?.occupant; + expect(occupant).toBeDefined(); + expect(occupant.get('nick')).toBe('ralphm'); + expect(occupant.get('affiliation')).toBeUndefined(); + expect(occupant.get('role')).toBeUndefined(); + + const sent_stanzas = _converse.connection.sent_stanzas; + let probe = await u.waitUntil(() => sent_stanzas.filter(s => s.matches('presence[type="probe"]')).pop()); + expect(Strophe.serialize(probe)).toBe( + `<presence to="${muc_jid}/ralphm" type="probe" xmlns="jabber:client">`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+ + `</presence>`); + + let presence = u.toStanza( + `<presence xmlns="jabber:client" to="${converse.jid}" from="${muc_jid}/ralphm"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="member" jid="ralph@example.org/Conversations.ZvLu" role="participant"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + + expect(occupant.get('affiliation')).toBe('member'); + expect(occupant.get('role')).toBe('participant'); + + // Check that unavailable but affiliated occupants don't get destroyed + stanza = u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="${muc_jid}/gonePhising"> + <body>This message from an unavailable user will trigger a presence probe</body> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => view.model.messages.length === 2); + occupant = view.model.messages.at(1)?.occupant; + expect(occupant).toBeDefined(); + expect(occupant.get('nick')).toBe('gonePhising'); + expect(occupant.get('affiliation')).toBeUndefined(); + expect(occupant.get('role')).toBeUndefined(); + + probe = await u.waitUntil(() => sent_stanzas.filter(s => s.matches(`presence[to="${muc_jid}/gonePhising"]`)).pop()); + expect(Strophe.serialize(probe)).toBe( + `<presence to="${muc_jid}/gonePhising" type="probe" xmlns="jabber:client">`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+ + `</presence>`); + + presence = u.toStanza( + `<presence xmlns="jabber:client" type="unavailable" to="${converse.jid}" from="${muc_jid}/gonePhising"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="member" jid="gonePhishing@example.org/d34dBEEF" role="participant"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + + expect(view.model.occupants.length).toBe(3); + expect(occupant.get('affiliation')).toBe('member'); + expect(occupant.get('role')).toBe('participant'); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/nickname.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/nickname.js new file mode 100644 index 0000000..2c9f23d --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/nickname.js @@ -0,0 +1,475 @@ +/*global mock, converse */ + +const { $pres, $iq, Strophe, sizzle, u, stx } = converse.env; + +describe("A MUC", function () { + + it("allows you to change your nickname via a modal", + mock.initConverse([], {'view_mode': 'fullscreen'}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + + expect(model.get('nick')).toBe(nick); + expect(model.occupants.length).toBe(1); + expect(model.occupants.at(0).get('nick')).toBe(nick); + + const view = _converse.chatboxviews.get(muc_jid); + const dropdown_item = view.querySelector(".open-nickname-modal"); + dropdown_item.click(); + + const modal = _converse.api.modal.get('converse-muc-nickname-modal'); + await u.waitUntil(() => u.isVisible(modal)); + + const input = modal.querySelector('input[name="nick"]'); + expect(input.value).toBe(nick); + + const newnick = 'loverboy'; + input.value = newnick; + modal.querySelector('input[type="submit"]')?.click(); + + await u.waitUntil(() => !u.isVisible(modal)); + + const { sent_stanzas } = _converse.connection; + const sent_stanza = sent_stanzas.pop() + expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe( + `<presence from="${_converse.jid}" id="${sent_stanza.getAttribute('id')}" to="${muc_jid}/${newnick}" xmlns="jabber:client"/>`); + + // Two presence stanzas are received from the MUC service + _converse.connection._dataRecv(mock.createRequest( + stx` + <presence + xmlns="jabber:server" + from='${muc_jid}/${nick}' + id='DC352437-C019-40EC-B590-AF29E879AF98' + to='${_converse.jid}' + type='unavailable'> + <x xmlns='http://jabber.org/protocol/muc#user'> + <item affiliation='member' + jid='${_converse.jid}' + nick='${newnick}' + role='participant'/> + <status code='303'/> + <status code='110'/> + </x> + </presence>` + )); + + expect(model.get('nick')).toBe(newnick); + + _converse.connection._dataRecv(mock.createRequest( + stx` + <presence + xmlns="jabber:server" + from='${muc_jid}/${newnick}' + id='5B4F27A4-25ED-43F7-A699-382C6B4AFC67' + to='${_converse.jid}'> + <x xmlns='http://jabber.org/protocol/muc#user'> + <item affiliation='member' + jid='${_converse.jid}' + role='participant'/> + <status code='110'/> + </x> + </presence>` + )); + + await u.waitUntil(() => model.occupants.at(0).get('nick') === newnick); + expect(model.occupants.length).toBe(1); + })); + + it("informs users if their nicknames have been changed.", + mock.initConverse([], {}, async function (_converse) { + + /* The service then sends two presence stanzas to the full JID + * of each occupant (including the occupant who is changing his + * or her room nickname), one of type "unavailable" for the old + * nickname and one indicating availability for the new + * nickname. + * + * See: https://xmpp.org/extensions/xep-0045.html#changenick + * + * <presence + * from='coven@montague.lit/thirdwitch' + * id='DC352437-C019-40EC-B590-AF29E879AF98' + * to='hag66@shakespeare.lit/pda' + * type='unavailable'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' + * jid='hag66@shakespeare.lit/pda' + * nick='oldhag' + * role='participant'/> + * <status code='303'/> + * <status code='110'/> + * </x> + * </presence> + * + * <presence + * from='coven@montague.lit/oldhag' + * id='5B4F27A4-25ED-43F7-A699-382C6B4AFC67' + * to='hag66@shakespeare.lit/pda'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' + * jid='hag66@shakespeare.lit/pda' + * role='participant'/> + * <status code='110'/> + * </x> + * </presence> + */ + const { __ } = _converse; + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'oldnick'); + + const view = _converse.chatboxviews.get('lounge@montague.lit'); + await u.waitUntil(() => view.querySelectorAll('li .occupant-nick').length, 500); + let occupants = view.querySelector('.occupant-list'); + expect(occupants.childElementCount).toBe(1); + expect(occupants.firstElementChild.querySelector('.occupant-nick').textContent.trim()).toBe("oldnick"); + + const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent); + expect(csntext.trim()).toEqual("oldnick has entered the groupchat"); + + let presence = $pres().attrs({ + from:'lounge@montague.lit/oldnick', + id:'DC352437-C019-40EC-B590-AF29E879AF98', + to:'romeo@montague.lit/pda', + type:'unavailable' + }) + .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'owner', + jid: 'romeo@montague.lit/pda', + nick: 'newnick', + role: 'moderator' + }).up() + .c('status').attrs({code:'303'}).up() + .c('status').attrs({code:'110'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelectorAll('.chat-info').length); + + expect(sizzle('div.chat-info:last').pop().textContent.trim()).toBe( + __(_converse.muc.new_nickname_messages["303"], "newnick") + ); + expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED); + + occupants = view.querySelector('.occupant-list'); + expect(occupants.childElementCount).toBe(1); + + presence = $pres().attrs({ + from:'lounge@montague.lit/newnick', + id:'5B4F27A4-25ED-43F7-A699-382C6B4AFC67', + to:'romeo@montague.lit/pda' + }) + .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'owner', + jid: 'romeo@montague.lit/pda', + role: 'moderator' + }).up() + .c('status').attrs({code:'110'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED); + expect(view.querySelectorAll('div.chat-info').length).toBe(1); + expect(sizzle('div.chat-info', view)[0].textContent.trim()).toBe( + __(_converse.muc.new_nickname_messages["303"], "newnick") + ); + occupants = view.querySelector('.occupant-list'); + await u.waitUntil(() => sizzle('.occupant-nick:first', occupants).pop().textContent.trim() === "newnick"); + expect(view.model.occupants.length).toBe(1); + expect(view.model.get('nick')).toBe("newnick"); + })); + + describe("when being entered", function () { + + it("will use the user's reserved nickname, if it exists", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const IQ_stanzas = _converse.connection.IQ_stanzas; + const muc_jid = 'lounge@montague.lit'; + await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo'); + + let stanza = await u.waitUntil(() => IQ_stanzas.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop() + ); + // We pretend this is a new room, so no disco info is returned. + const features_stanza = $iq({ + from: 'lounge@montague.lit', + 'id': stanza.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'error' + }).c('error', {'type': 'cancel'}) + .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"}); + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + + /* <iq from='hag66@shakespeare.lit/pda' + * id='getnick1' + * to='coven@chat.shakespeare.lit' + * type='get'> + * <query xmlns='http://jabber.org/protocol/disco#info' + * node='x-roomuser-item'/> + * </iq> + */ + const iq = await u.waitUntil(() => IQ_stanzas.filter( + s => sizzle(`iq[to="${muc_jid}"] query[node="x-roomuser-item"]`, s).length + ).pop()); + + expect(Strophe.serialize(iq)).toBe( + `<iq from="romeo@montague.lit/orchard" id="${iq.getAttribute('id')}" to="lounge@montague.lit" `+ + `type="get" xmlns="jabber:client">`+ + `<query node="x-roomuser-item" xmlns="http://jabber.org/protocol/disco#info"/></iq>`); + + /* <iq from='coven@chat.shakespeare.lit' + * id='getnick1' + * to='hag66@shakespeare.lit/pda' + * type='result'> + * <query xmlns='http://jabber.org/protocol/disco#info' + * node='x-roomuser-item'> + * <identity + * category='conference' + * name='thirdwitch' + * type='text'/> + * </query> + * </iq> + */ + const view = _converse.chatboxviews.get('lounge@montague.lit'); + stanza = $iq({ + 'type': 'result', + 'id': iq.getAttribute('id'), + 'from': view.model.get('jid'), + 'to': _converse.connection.jid + }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info', 'node': 'x-roomuser-item'}) + .c('identity', {'category': 'conference', 'name': 'thirdwitch', 'type': 'text'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + // The user has just entered the groupchat (because join was called) + // and receives their own presence from the server. + // See example 24: + // https://xmpp.org/extensions/xep-0045.html#enter-pres + const presence = $pres({ + to:'romeo@montague.lit/orchard', + from:'lounge@montague.lit/thirdwitch', + id:'DC352437-C019-40EC-B590-AF29E879AF97' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'member', + jid: 'romeo@montague.lit/orchard', + role: 'participant' + }).up() + .c('status').attrs({code:'110'}).up() + .c('status').attrs({code:'210'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + await mock.returnMemberLists(_converse, muc_jid, [], ['member', 'admin', 'owner']); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-info').length); + const info_text = sizzle('.chat-content .chat-info:first', view).pop().textContent.trim(); + expect(info_text).toBe('Your nickname has been automatically set to thirdwitch'); + })); + + it("will use the nickname set in the global settings if the user doesn't have a VCard nickname", + mock.initConverse(['chatBoxesFetched'], {'nickname': 'Benedict-Cucumberpatch'}, + async function (_converse) { + + await mock.openChatRoomViaModal(_converse, 'roomy@muc.montague.lit'); + const view = _converse.chatboxviews.get('roomy@muc.montague.lit'); + expect(view.model.get('nick')).toBe('Benedict-Cucumberpatch'); + })); + + it("will render a nickname form if a nickname conflict happens and muc_nickname_from_jid=false", + mock.initConverse([], { vcard: { nickname: '' }}, async function (_converse) { + + const muc_jid = 'conflicted@muc.montague.lit'; + await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo'); + const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop()); + + const features_stanza = $iq({ + 'from': muc_jid, + 'id': iq.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result' + }) + .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up() + .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up() + .c('feature', {'var': 'muc_hidden'}).up() + .c('feature', {'var': 'muc_temporary'}).up() + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING); + + const presence = $pres().attrs({ + from: `${muc_jid}/romeo`, + id: u.getUniqueId(), + to: 'romeo@montague.lit/pda', + type: 'error' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({by: muc_jid, type:'cancel'}) + .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + + const el = await u.waitUntil(() => view.querySelector('.muc-nickname-form .validation-message')); + expect(el.textContent.trim()).toBe('The nickname you chose is reserved or currently in use, please choose a different one.'); + })); + + + it("will automatically choose a new nickname if a nickname conflict happens and muc_nickname_from_jid=true", + mock.initConverse(['chatBoxesFetched'], {vcard: { nickname: '' }}, async function (_converse) { + + const { api } = _converse; + const muc_jid = 'conflicting@muc.montague.lit' + await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo'); + /* <presence + * from='coven@chat.shakespeare.lit/thirdwitch' + * id='n13mt3l' + * to='hag66@shakespeare.lit/pda' + * type='error'> + * <x xmlns='http://jabber.org/protocol/muc'/> + * <error by='coven@chat.shakespeare.lit' type='cancel'> + * <conflict xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/> + * </error> + * </presence> + */ + api.settings.set('muc_nickname_from_jid', true); + + const attrs = { + 'from': `${muc_jid}/romeo`, + 'id': u.getUniqueId(), + 'to': 'romeo@montague.lit/pda', + 'type': 'error' + }; + let presence = $pres().attrs(attrs) + .c('x').attrs({'xmlns':'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({'by': muc_jid, 'type':'cancel'}) + .c('conflict').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + + const view = _converse.chatboxviews.get(muc_jid); + spyOn(view.model, 'join').and.callThrough(); + + // Simulate repeatedly that there's already someone in the groupchat + // with that nickname + _converse.connection._dataRecv(mock.createRequest(presence)); + expect(view.model.join).toHaveBeenCalledWith('romeo-2'); + + attrs.from = `${muc_jid}/romeo-2`; + attrs.id = u.getUniqueId(); + presence = $pres().attrs(attrs) + .c('x').attrs({'xmlns':'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({'by': muc_jid, type:'cancel'}) + .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + + expect(view.model.join).toHaveBeenCalledWith('romeo-3'); + + attrs.from = `${muc_jid}/romeo-3`; + attrs.id = new Date().getTime(); + presence = $pres().attrs(attrs) + .c('x').attrs({'xmlns': 'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({'by': muc_jid, 'type': 'cancel'}) + .c('conflict').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + expect(view.model.join).toHaveBeenCalledWith('romeo-4'); + })); + + it("will show an error message if the user's nickname doesn't conform to groupchat policy", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'conformist@muc.montague.lit' + await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo'); + + const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop()); + const features_stanza = $iq({ + 'from': muc_jid, + 'id': iq.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result' + }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up() + .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up() + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING)); + + const presence = $pres().attrs({ + from: `${muc_jid}/romeo`, + id: u.getUniqueId(), + to:'romeo@montague.lit/pda', + type:'error' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({by:'lounge@montague.lit', type:'cancel'}) + .c('not-acceptable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child')); + expect(el.textContent.trim()).toBe("Your nickname doesn't conform to this groupchat's policies."); + })); + + it("doesn't show the nickname field if locked_muc_nickname is true", + mock.initConverse(['chatBoxesFetched'], { + locked_muc_nickname: true, + muc_nickname_from_jid: true, + vcard: { nickname: '' }, + }, async function (_converse) { + + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current', 0); + const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list'); + roomspanel.querySelector('.show-add-muc-modal').click(); + mock.closeControlBox(_converse); + const modal = _converse.api.modal.get('converse-add-muc-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000) + const name_input = modal.querySelector('input[name="chatroom"]'); + name_input.value = 'lounge@montague.lit'; + expect(modal.querySelector('label[for="nickname"]')).toBe(null); + expect(modal.querySelector('input[name="nickname"]')).toBe(null); + modal.querySelector('form input[type="submit"]').click(); + await u.waitUntil(() => _converse.chatboxes.length > 1); + const chatroom = _converse.chatboxes.get('lounge@montague.lit'); + expect(chatroom.get('nick')).toBe('romeo'); + })); + + it("uses the JID node if muc_nickname_from_jid is set to true", + mock.initConverse(['chatBoxesFetched'], {'muc_nickname_from_jid': true}, async function (_converse) { + + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current', 0); + const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list'); + roomspanel.querySelector('.show-add-muc-modal').click(); + mock.closeControlBox(_converse); + const modal = _converse.api.modal.get('converse-add-muc-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000) + const label_nick = modal.querySelector('label[for="nickname"]'); + expect(label_nick.textContent.trim()).toBe('Nickname:'); + const nick_input = modal.querySelector('input[name="nickname"]'); + expect(nick_input.value).toBe('romeo'); + })); + + it("uses the nickname passed in to converse.initialize", + mock.initConverse(['chatBoxesFetched'], {'nickname': 'st.nick'}, async function (_converse) { + + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current', 0); + const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list'); + roomspanel.querySelector('.show-add-muc-modal').click(); + mock.closeControlBox(_converse); + const modal = _converse.api.modal.get('converse-add-muc-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000) + const label_nick = modal.querySelector('label[for="nickname"]'); + expect(label_nick.textContent.trim()).toBe('Nickname:'); + const nick_input = modal.querySelector('input[name="nickname"]'); + expect(nick_input.value).toBe('st.nick'); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/occupants.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/occupants.js new file mode 100644 index 0000000..1ae2c94 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/occupants.js @@ -0,0 +1,228 @@ +/*global mock, converse */ + +const { $pres, sizzle, u } = converse.env; + +describe("The occupants sidebar", function () { + + it("shows all members even if they're not currently present in the groupchat", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit' + const members = [{ + 'nick': 'juliet', + 'jid': 'juliet@capulet.lit', + 'affiliation': 'member' + }]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.model.occupants.length === 2); + + const occupants = view.querySelector('.occupant-list'); + for (let i=0; i<mock.chatroom_names.length; i++) { + const name = mock.chatroom_names[i]; + const role = mock.chatroom_roles[name].role; + // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres + const presence = $pres({ + to:'romeo@montague.lit/pda', + from:'lounge@montague.lit/'+name + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: mock.chatroom_roles[name].affiliation, + jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit', + role: role + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + } + + await u.waitUntil(() => occupants.querySelectorAll('li').length > 2, 500); + expect(occupants.querySelectorAll('li').length).toBe(2+mock.chatroom_names.length); + expect(view.model.occupants.length).toBe(2+mock.chatroom_names.length); + + mock.chatroom_names.forEach(name => { + const model = view.model.occupants.findWhere({'nick': name}); + const index = view.model.occupants.indexOf(model); + expect(occupants.querySelectorAll('li .occupant-nick')[index].textContent.trim()).toBe(name); + }); + + // Test users leaving the groupchat + // https://xmpp.org/extensions/xep-0045.html#exit + for (let i=mock.chatroom_names.length-1; i>-1; i--) { + const name = mock.chatroom_names[i]; + // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres + const presence = $pres({ + to:'romeo@montague.lit/pda', + from:'lounge@montague.lit/'+name, + type: 'unavailable' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: mock.chatroom_roles[name].affiliation, + jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit', + role: 'none' + }).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + expect(occupants.querySelectorAll('li').length).toBe(8); + } + const presence = $pres({ + to: 'romeo@montague.lit/pda', + from: 'lounge@montague.lit/nonmember' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: null, + jid: 'servant@montague.lit', + role: 'visitor' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => occupants.querySelectorAll('li').length > 8, 500); + expect(occupants.querySelectorAll('li').length).toBe(9); + expect(view.model.occupants.length).toBe(9); + expect(view.model.occupants.filter(o => o.isMember()).length).toBe(8); + + view.model.rejoin(); + // Test that members aren't removed when we reconnect + expect(view.model.occupants.length).toBe(8); + view.model.session.set('connection_status', converse.ROOMSTATUS.ENTERED); // Hack + await u.waitUntil(() => view.querySelectorAll('.occupant-list li').length === 8); + })); + + it("shows users currently present in the groupchat", + mock.initConverse([], {}, async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + var view = _converse.chatboxviews.get('lounge@montague.lit'); + const occupants = view.querySelector('.occupant-list'); + for (var i=0; i<mock.chatroom_names.length; i++) { + const name = mock.chatroom_names[i]; + // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres + const presence = $pres({ + to:'romeo@montague.lit/pda', + from:'lounge@montague.lit/'+name + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'none', + jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit', + role: 'participant' + }).up() + .c('status'); + _converse.connection._dataRecv(mock.createRequest(presence)); + } + + await u.waitUntil(() => occupants.querySelectorAll('li').length > 1, 500); + expect(occupants.querySelectorAll('li').length).toBe(1+mock.chatroom_names.length); + + mock.chatroom_names.forEach(name => { + const model = view.model.occupants.findWhere({'nick': name}); + const index = view.model.occupants.indexOf(model); + expect(occupants.querySelectorAll('li .occupant-nick')[index].textContent.trim()).toBe(name); + }); + + // Test users leaving the groupchat + // https://xmpp.org/extensions/xep-0045.html#exit + for (i=mock.chatroom_names.length-1; i>-1; i--) { + const name = mock.chatroom_names[i]; + // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres + const presence = $pres({ + to:'romeo@montague.lit/pda', + from:'lounge@montague.lit/'+name, + type: 'unavailable' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: "none", + jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit', + role: 'none' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + } + await u.waitUntil(() => occupants.querySelectorAll('li').length === 1); + })); + + it("lets you click on an occupant to insert it into the chat textarea", + mock.initConverse([], {'view_mode': 'fullscreen'}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + var view = _converse.chatboxviews.get(muc_jid); + const occupants = view.querySelector('.occupant-list'); + const name = mock.chatroom_names[0]; + const presence = $pres({ + to:'romeo@montague.lit/pda', + from:'lounge@montague.lit/'+name + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'none', + jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit', + role: 'participant' + }).up() + .c('status'); + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => occupants.querySelectorAll('li').length > 1, 500); + expect(occupants.querySelectorAll('li').length).toBe(2); + view.querySelectorAll('.occupant-nick')[1].click() + + const textarea = view.querySelector('.chat-textarea'); + expect(textarea.value).toBe('@Dyon van de Wege '); + })); + + it("indicates moderators and visitors by means of a special css class and tooltip", + mock.initConverse([], {'view_mode': 'fullscreen'}, async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + let contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + + await u.waitUntil(() => view.querySelectorAll('.occupant-list li').length, 500); + let occupants = view.querySelectorAll('.occupant-list li'); + expect(occupants.length).toBe(1); + expect(occupants[0].querySelector('.occupant-nick').textContent.trim()).toBe("romeo"); + expect(occupants[0].querySelectorAll('.badge').length).toBe(2); + expect(occupants[0].querySelectorAll('.badge')[0].textContent.trim()).toBe('Owner'); + expect(sizzle('.badge:last', occupants[0]).pop().textContent.trim()).toBe('Moderator'); + + var presence = $pres({ + to:'romeo@montague.lit/pda', + from:'lounge@montague.lit/moderatorman' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'admin', + jid: contact_jid, + role: 'moderator', + }).up() + .c('status').attrs({code:'110'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelectorAll('.occupant-list li').length > 1, 500); + occupants = view.querySelectorAll('.occupant-list li'); + expect(occupants.length).toBe(2); + expect(occupants[0].querySelector('.occupant-nick').textContent.trim()).toBe("moderatorman"); + expect(occupants[1].querySelector('.occupant-nick').textContent.trim()).toBe("romeo"); + expect(occupants[0].querySelectorAll('.badge').length).toBe(2); + expect(occupants[0].querySelectorAll('.badge')[0].textContent.trim()).toBe('Admin'); + expect(occupants[0].querySelectorAll('.badge')[1].textContent.trim()).toBe('Moderator'); + + expect(occupants[0].getAttribute('title')).toBe( + contact_jid + ' This user is a moderator. Click to mention moderatorman in your message.' + ); + + contact_jid = mock.cur_names[3].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + presence = $pres({ + to:'romeo@montague.lit/pda', + from:'lounge@montague.lit/visitorwoman' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + jid: contact_jid, + role: 'visitor', + }).up() + .c('status').attrs({code:'110'}).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => view.querySelectorAll('.occupant-list li').length > 2, 500); + occupants = view.querySelector('.occupant-list').querySelectorAll('li'); + expect(occupants.length).toBe(3); + expect(occupants[2].querySelector('.occupant-nick').textContent.trim()).toBe("visitorwoman"); + expect(occupants[2].querySelectorAll('.badge').length).toBe(1); + expect(sizzle('.badge', occupants[2]).pop().textContent.trim()).toBe('Visitor'); + expect(occupants[2].getAttribute('title')).toBe( + contact_jid + ' This user can NOT send messages in this groupchat. Click to mention visitorwoman in your message.' + ); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/rai.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/rai.js new file mode 100644 index 0000000..147c94e --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/rai.js @@ -0,0 +1,221 @@ +/*global mock, converse */ + +const { Strophe } = converse.env; +const u = converse.env.utils; +// See: https://xmpp.org/rfcs/rfc3921.html + + +describe("XEP-0437 Room Activity Indicators", function () { + + it("will be activated for a MUC that becomes hidden", + mock.initConverse( + [], { + 'allow_bookmarks': false, // Hack to get the rooms list to render + 'muc_subscribe_to_rai': true, + 'view_mode': 'fullscreen'}, + async function (_converse) { + + expect(_converse.session.get('rai_enabled_domains')).toBe(undefined); + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + expect(view.model.get('hidden')).toBe(false); + + const sent_IQs = _converse.connection.IQ_stanzas; + const iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + const first_msg_id = _converse.connection.getUniqueId(); + const last_msg_id = _converse.connection.getUniqueId(); + let message = u.toStanza( + `<message xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:15:23Z"/> + <message from="${muc_jid}/some1" type="groupchat"> + <body>1st MAM Message</body> + </message> + </forwarded> + </result> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + message = u.toStanza( + `<message xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${last_msg_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:16:23Z"/> + <message from="${muc_jid}/some1" type="groupchat"> + <body>2nd MAM Message</body> + </message> + </forwarded> + </result> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + const result = u.toStanza( + `<iq type='result' id='${iq_get.getAttribute('id')}'> + <fin xmlns='urn:xmpp:mam:2'> + <set xmlns='http://jabber.org/protocol/rsm'> + <first index='0'>${first_msg_id}</first> + <last>${last_msg_id}</last> + <count>2</count> + </set> + </fin> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(result)); + await u.waitUntil(() => view.model.messages.length === 2); + + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s)); + view.model.save({'hidden': true}); + await u.waitUntil(() => sent_stanzas.length === 3); + + expect(Strophe.serialize(sent_stanzas[0])).toBe( + `<message from="${_converse.jid}" id="${sent_stanzas[0].getAttribute('id')}" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">`+ + `<received id="${last_msg_id}" xmlns="urn:xmpp:chat-markers:0"/>`+ + `</message>` + ); + expect(Strophe.serialize(sent_stanzas[1])).toBe( + `<presence to="${muc_jid}/romeo" type="unavailable" xmlns="jabber:client">`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+ + `</presence>` + ); + expect(Strophe.serialize(sent_stanzas[2])).toBe( + `<presence to="montague.lit" xmlns="jabber:client">`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+ + `<rai xmlns="urn:xmpp:rai:0"/>`+ + `</presence>` + ); + + await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED); + expect(view.model.get('has_activity')).toBe(false); + + const room_el = await u.waitUntil(() => document.querySelector("converse-rooms-list .available-chatroom")); + expect(Array.from(room_el.classList).includes('unread-msgs')).toBeFalsy(); + + const activity_stanza = u.toStanza(` + <message from="${Strophe.getDomainFromJid(muc_jid)}"> + <rai xmlns="urn:xmpp:rai:0"> + <activity>${muc_jid}</activity> + </rai> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(activity_stanza)); + + await u.waitUntil(() => view.model.get('has_activity')); + expect(Array.from(room_el.classList).includes('unread-msgs')).toBeTruthy(); + })); + + it("will be activated for a MUC that starts out hidden", + mock.initConverse( + [], { + 'allow_bookmarks': false, // Hack to get the rooms list to render + 'muc_subscribe_to_rai': true, + 'view_mode': 'fullscreen'}, + async function (_converse) { + + const { api } = _converse; + expect(_converse.session.get('rai_enabled_domains')).toBe(undefined); + + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + const sent_stanzas = _converse.connection.sent_stanzas; + + const muc_creation_promise = await api.rooms.open(muc_jid, {nick, 'hidden': true}, false); + await mock.getRoomFeatures(_converse, muc_jid, []); + await mock.receiveOwnMUCPresence(_converse, muc_jid, nick); + await muc_creation_promise; + + const model = _converse.chatboxes.get(muc_jid); + await u.waitUntil(() => (model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + expect(model.get('hidden')).toBe(true); + + + const getSentPresences = () => sent_stanzas.filter(s => s.nodeName === 'presence'); + await u.waitUntil(() => getSentPresences().length === 3, 500); + const sent_presences = getSentPresences(); + + expect(Strophe.serialize(sent_presences[1])).toBe( + `<presence to="${muc_jid}/romeo" type="unavailable" xmlns="jabber:client">`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+ + `</presence>` + ); + expect(Strophe.serialize(sent_presences[2])).toBe( + `<presence to="montague.lit" xmlns="jabber:client">`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+ + `<rai xmlns="urn:xmpp:rai:0"/>`+ + `</presence>` + ); + + await u.waitUntil(() => model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED); + expect(model.get('has_activity')).toBe(false); + + const room_el = await u.waitUntil(() => document.querySelector("converse-rooms-list .available-chatroom")); + expect(Array.from(room_el.classList).includes('unread-msgs')).toBeFalsy(); + + const activity_stanza = u.toStanza(` + <message from="${Strophe.getDomainFromJid(muc_jid)}"> + <rai xmlns="urn:xmpp:rai:0"> + <activity>${muc_jid}</activity> + </rai> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(activity_stanza)); + + await u.waitUntil(() => model.get('has_activity')); + expect(Array.from(room_el.classList).includes('unread-msgs')).toBeTruthy(); + })); + + + it("may not be activated due to server resource constraints", + mock.initConverse( + [], { + 'allow_bookmarks': false, // Hack to get the rooms list to render + 'muc_subscribe_to_rai': true, + 'view_mode': 'fullscreen'}, + async function (_converse) { + + expect(_converse.session.get('rai_enabled_domains')).toBe(undefined); + + const muc_jid = 'lounge@montague.lit'; + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + expect(model.get('hidden')).toBe(false); + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s)); + model.save({'hidden': true}); + await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'presence').length === 2); + + const sent_presences = sent_stanzas.filter(s => s.nodeName === 'presence'); + expect(Strophe.serialize(sent_presences[0])).toBe( + `<presence to="${muc_jid}/romeo" type="unavailable" xmlns="jabber:client">`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+ + `</presence>` + ); + expect(Strophe.serialize(sent_presences[1])).toBe( + `<presence to="montague.lit" xmlns="jabber:client">`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+ + `<rai xmlns="urn:xmpp:rai:0"/>`+ + `</presence>` + ); + // If an error presence with "resource-constraint" is returned, we rejoin + const activity_stanza = u.toStanza(` + <presence type="error" from="${Strophe.getDomainFromJid(muc_jid)}"> + <error type="wait"><resource-constraint xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error> + </presence> + `); + _converse.connection._dataRecv(mock.createRequest(activity_stanza)); + + await u.waitUntil(() => model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING); + })); + +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/retractions.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/retractions.js new file mode 100644 index 0000000..fda19a7 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/retractions.js @@ -0,0 +1,1084 @@ +/*global mock, converse */ + +const { Strophe, $iq } = converse.env; +const u = converse.env.utils; + + +async function sendAndThenRetractMessage (_converse, view) { + view.model.sendMessage({'body': 'hello world'}); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1); + const msg_obj = view.model.messages.last(); + const reflection_stanza = u.toStanza(` + <message xmlns="jabber:client" + from="${msg_obj.get('from')}" + to="${_converse.connection.jid}" + type="groupchat"> + <msg_body>${msg_obj.get('message')}</msg_body> + <stanza-id xmlns="urn:xmpp:sid:0" + id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad" + by="lounge@montague.lit"/> + <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/> + </message>`); + await view.model.handleMessageStanza(reflection_stanza); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500); + + const retract_button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-retract')); + retract_button.click(); + await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal'))); + const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]'); + submit_button.click(); + const sent_stanzas = _converse.connection.sent_stanzas; + return u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message apply-to[xmlns="urn:xmpp:fasten:0"]')).pop()); +} + + +describe("Message Retractions", function () { + + describe("A groupchat message retraction", function () { + + it("is not applied if it's not from the right author", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + + const received_stanza = u.toStanza(` + <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'> + <body>Hello world</body> + <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/> + </message> + `); + const view = _converse.chatboxviews.get(muc_jid); + await view.model.handleMessageStanza(received_stanza); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); + expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy(); + + const retraction_stanza = u.toStanza(` + <message type="groupchat" id='retraction-id-1' from="${muc_jid}/mallory" to="${muc_jid}/romeo"> + <apply-to id="stanza-id-1" xmlns="urn:xmpp:fasten:0"> + <retract xmlns="urn:xmpp:message-retract:0" /> + </apply-to> + </message> + `); + spyOn(view.model, 'handleRetraction').and.callThrough(); + + _converse.connection._dataRecv(mock.createRequest(retraction_stanza)); + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1); + expect(await view.model.handleRetraction.calls.first().returnValue).toBe(true); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.model.messages.length).toBe(2); + expect(view.model.messages.at(1).get('retracted')).toBeTruthy(); + expect(view.model.messages.at(1).get('is_ephemeral')).toBeFalsy(); + expect(view.model.messages.at(1).get('dangling_retraction')).toBe(true); + + expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); + expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy(); + })); + + it("can be received before the message it pertains to", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const date = (new Date()).toISOString(); + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + + const retraction_stanza = u.toStanza(` + <message type="groupchat" id='retraction-id-1' from="${muc_jid}/eve" to="${muc_jid}/romeo"> + <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0"> + <retract by="${muc_jid}/eve" xmlns="urn:xmpp:message-retract:0" /> + </apply-to> + </message> + `); + const view = _converse.chatboxviews.get(muc_jid); + spyOn(converse.env.log, 'warn'); + spyOn(view.model, 'handleRetraction').and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(retraction_stanza)); + + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1); + await u.waitUntil(() => view.model.messages.length === 1); + expect(await view.model.handleRetraction.calls.first().returnValue).toBe(true); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('retracted')).toBeTruthy(); + expect(view.model.messages.at(0).get('dangling_retraction')).toBe(true); + + const received_stanza = u.toStanza(` + <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'> + <body>Hello world</body> + <delay xmlns='urn:xmpp:delay' stamp='${date}'/> + <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/> + <origin-id xmlns="urn:xmpp:sid:0" id="origin-id-1"/> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(received_stanza)); + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1, 1000); + expect(view.model.messages.length).toBe(1); + + const message = view.model.messages.at(0) + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('dangling_retraction')).toBe(false); + expect(message.get('origin_id')).toBe('origin-id-1'); + expect(message.get(`stanza_id ${muc_jid}`)).toBe('stanza-id-1'); + expect(message.get('time')).toBe(date); + expect(message.get('type')).toBe('groupchat'); + expect(await view.model.handleRetraction.calls.all().pop().returnValue).toBe(true); + })); + }); + + describe("A groupchat message moderator retraction", function () { + + it("can be received before the message it pertains to", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const date = (new Date()).toISOString(); + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const retraction_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" type="groupchat" id="retraction-id-1"> + <apply-to xmlns="urn:xmpp:fasten:0" id="stanza-id-1"> + <moderated xmlns="urn:xmpp:message-moderate:0" by="${muc_jid}/madison"> + <retract xmlns="urn:xmpp:message-retract:0"/> + <reason>Insults</reason> + </moderated> + </apply-to> + </message> + `); + const view = _converse.chatboxviews.get(muc_jid); + spyOn(converse.env.log, 'warn'); + spyOn(view.model, 'handleModeration').and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(retraction_stanza)); + + await u.waitUntil(() => view.model.handleModeration.calls.count() === 1); + await u.waitUntil(() => view.model.messages.length === 1); + expect(await view.model.handleModeration.calls.first().returnValue).toBe(true); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.model.messages.at(0).get('dangling_moderation')).toBe(true); + + const received_stanza = u.toStanza(` + <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'> + <body>Hello world</body> + <delay xmlns='urn:xmpp:delay' stamp='${date}'/> + <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/> + </message> + + `); + + _converse.connection._dataRecv(mock.createRequest(received_stanza)); + await u.waitUntil(() => view.model.handleModeration.calls.count() === 2); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.model.messages.length).toBe(1); + + const message = view.model.messages.at(0) + expect(message.get('moderated')).toBe('retracted'); + expect(message.get('dangling_moderation')).toBe(false); + expect(message.get(`stanza_id ${muc_jid}`)).toBe('stanza-id-1'); + expect(message.get('time')).toBe(date); + expect(message.get('type')).toBe('groupchat'); + expect(await view.model.handleModeration.calls.all().pop().returnValue).toBe(true); + })); + }); + + + describe("A message retraction", function () { + + it("can be received before the message it pertains to", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const date = (new Date()).toISOString(); + 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'; + const view = await mock.openChatBoxFor(_converse, contact_jid); + spyOn(view.model, 'handleRetraction').and.callThrough(); + + const retraction_stanza = u.toStanza(` + <message id="${u.getUniqueId()}" + to="${_converse.bare_jid}" + from="${contact_jid}" + type="chat" + xmlns="jabber:client"> + <apply-to id="2e972ea0-0050-44b7-a830-f6638a2595b3" xmlns="urn:xmpp:fasten:0"> + <retract xmlns="urn:xmpp:message-retract:0"/> + </apply-to> + </message> + `); + + _converse.connection._dataRecv(mock.createRequest(retraction_stanza)); + await u.waitUntil(() => view.model.messages.length === 1); + const message = view.model.messages.at(0); + expect(message.get('dangling_retraction')).toBe(true); + expect(message.get('is_ephemeral')).toBe(false); + expect(message.get('retracted')).toBeTruthy(); + expect(view.querySelectorAll('.chat-msg').length).toBe(0); + + const stanza = u.toStanza(` + <message xmlns="jabber:client" + to="${_converse.bare_jid}" + type="chat" + id="2e972ea0-0050-44b7-a830-f6638a2595b3" + from="${contact_jid}"> + <body>Hello world</body> + <delay xmlns='urn:xmpp:delay' stamp='${date}'/> + <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.model.handleRetraction.calls.count() === 2); + expect(view.model.messages.length).toBe(1); + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('dangling_retraction')).toBe(false); + expect(message.get('origin_id')).toBe('2e972ea0-0050-44b7-a830-f6638a2595b3'); + expect(message.get('time')).toBe(date); + expect(message.get('type')).toBe('chat'); + })); + }); + + describe("A Received Chat Message", function () { + + it("can be followed up by a retraction", mock.initConverse(['chatBoxesFetched'], {}, 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'; + const view = await mock.openChatBoxFor(_converse, contact_jid); + + let stanza = u.toStanza(` + <message xmlns="jabber:client" + to="${_converse.bare_jid}" + type="chat" + id="29132ea0-0121-2897-b121-36638c259554" + from="${contact_jid}"> + <body>😊</body> + <markable xmlns="urn:xmpp:chat-markers:0"/> + <origin-id xmlns="urn:xmpp:sid:0" id="29132ea0-0121-2897-b121-36638c259554"/> + <stanza-id xmlns="urn:xmpp:sid:0" id="kxViLhgbnNMcWv10" by="${_converse.bare_jid}"/> + </message>`); + + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.model.messages.length === 1); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + + stanza = u.toStanza(` + <message xmlns="jabber:client" + to="${_converse.bare_jid}" + type="chat" + id="2e972ea0-0050-44b7-a830-f6638a2595b3" + from="${contact_jid}"> + <body>This message will be retracted</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.model.messages.length === 2); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + + const retraction_stanza = u.toStanza(` + <message id="${u.getUniqueId()}" + to="${_converse.bare_jid}" + from="${contact_jid}" + type="chat" + xmlns="jabber:client"> + <apply-to id="2e972ea0-0050-44b7-a830-f6638a2595b3" xmlns="urn:xmpp:fasten:0"> + <retract xmlns="urn:xmpp:message-retract:0"/> + </apply-to> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(retraction_stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1); + + expect(view.model.messages.length).toBe(2); + + const message = view.model.messages.at(1); + expect(message.get('retracted')).toBeTruthy(); + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message'); + expect(msg_el.textContent.trim()).toBe('Mercutio has removed this message'); + expect(u.hasClass('chat-msg--followup', view.querySelector('.chat-msg--retracted'))).toBe(true); + })); + }); + + describe("A Sent Chat Message", function () { + + it("can be retracted by its author", mock.initConverse(['chatBoxesFetched'], { vcard: { nickname: ''} }, async function (_converse) { + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const view = await mock.openChatBoxFor(_converse, contact_jid); + + view.model.sendMessage({'body': 'hello world'}); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + + const message = view.model.messages.at(0); + expect(view.model.messages.length).toBe(1); + expect(message.get('retracted')).toBeFalsy(); + expect(message.get('editable')).toBeTruthy(); + + + const retract_button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-retract')); + retract_button.click(); + await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal'))); + const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]'); + submit_button.click(); + + const sent_stanzas = _converse.connection.sent_stanzas; + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1); + + const msg_obj = view.model.messages.at(0); + const retraction_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message apply-to[xmlns="urn:xmpp:fasten:0"]')).pop()); + expect(Strophe.serialize(retraction_stanza)).toBe( + `<message id="${retraction_stanza.getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+ + `<store xmlns="urn:xmpp:hints"/>`+ + `<apply-to id="${msg_obj.get('origin_id')}" xmlns="urn:xmpp:fasten:0">`+ + `<retract xmlns="urn:xmpp:message-retract:0"/>`+ + `</apply-to>`+ + `</message>`); + + expect(view.model.messages.length).toBe(1); + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('editable')).toBeFalsy(); + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const el = view.querySelector('.chat-msg--retracted .chat-msg__message'); + expect(el.textContent.trim()).toBe('Romeo Montague has removed this message'); + })); + }); + + + describe("A Received Groupchat Message", function () { + + it("can be followed up by a retraction by the author", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + + const received_stanza = u.toStanza(` + <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'> + <body>Hello world</body> + <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/> + <origin-id xmlns='urn:xmpp:sid:0' id='origin-id-1' by='${muc_jid}'/> + </message> + `); + const view = _converse.chatboxviews.get(muc_jid); + await view.model.handleMessageStanza(received_stanza); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); + expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy(); + + const retraction_stanza = u.toStanza(` + <message type="groupchat" id='retraction-id-1' from="${muc_jid}/eve" to="${muc_jid}/romeo"> + <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0"> + <retract by="${muc_jid}/eve" xmlns="urn:xmpp:message-retract:0" /> + </apply-to> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(retraction_stanza)); + + // We opportunistically save the message as retracted, even before receiving the retraction message + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('retracted')).toBeTruthy(); + expect(view.model.messages.at(0).get('editable')).toBe(false); + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message'); + expect(msg_el.textContent.trim()).toBe('eve has removed this message'); + expect(msg_el.querySelector('.chat-msg--retracted q')).toBe(null); + })); + + + it("can be retracted by a moderator, with the IQ response received before the retraction message", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + + const view = _converse.chatboxviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + + const received_stanza = u.toStanza(` + <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.connection.getUniqueId()}'> + <body>Visit this site to get free Bitcoin!</body> + <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/> + </message> + `); + await view.model.handleMessageStanza(received_stanza); + await u.waitUntil(() => view.model.messages.length === 1); + expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); + + const reason = "This content is inappropriate for this forum!" + const retract_button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-retract')); + retract_button.click(); + + await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal'))); + + const reason_input = document.querySelector('#converse-modals .modal input[name="reason"]'); + reason_input.value = 'This content is inappropriate for this forum!'; + const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]'); + submit_button.click(); + + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop()); + const message = view.model.messages.at(0); + const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`); + + expect(Strophe.serialize(stanza)).toBe( + `<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+ + `<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">`+ + `<moderate xmlns="urn:xmpp:message-moderate:0">`+ + `<retract xmlns="urn:xmpp:message-retract:0"/>`+ + `<reason>This content is inappropriate for this forum!</reason>`+ + `</moderate>`+ + `</apply-to>`+ + `</iq>`); + + const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'}); + _converse.connection._dataRecv(mock.createRequest(result_iq)); + + // We opportunistically save the message as retracted, even before receiving the retraction message + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); + expect(view.model.messages.at(0).get('editable')).toBe(false); + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + + const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message'); + expect(msg_el.firstElementChild.textContent.trim()).toBe('romeo has removed this message'); + + const qel = msg_el.querySelector('q'); + expect(qel.textContent.trim()).toBe('This content is inappropriate for this forum!'); + + // The server responds with a retraction message + const retraction = u.toStanza(` + <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo"> + <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0"> + <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'> + <retract xmlns='urn:xmpp:message-retract:0' /> + <reason>${reason}</reason> + </moderated> + </apply-to> + </message>`); + await view.model.handleMessageStanza(retraction); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); + expect(view.model.messages.at(0).get('editable')).toBe(false); + })); + + it("can not be retracted if the MUC doesn't support message moderation", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + + const received_stanza = u.toStanza(` + <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.connection.getUniqueId()}'> + <body>Visit this site to get free Bitcoin!</body> + <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/> + </message> + `); + await view.model.handleMessageStanza(received_stanza); + await u.waitUntil(() => view.querySelector('.chat-msg__content')); + expect(view.querySelector('.chat-msg__content .chat-msg__action-retract')).toBe(null); + const result = await view.model.canModerateMessages(); + expect(result).toBe(false); + })); + + + it("can be retracted by a moderator, with the retraction message received before the IQ response", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.chatboxviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + + const received_stanza = u.toStanza(` + <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.connection.getUniqueId()}'> + <body>Visit this site to get free Bitcoin!</body> + <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/> + </message> + `); + await view.model.handleMessageStanza(received_stanza); + await u.waitUntil(() => view.model.messages.length === 1); + expect(view.model.messages.length).toBe(1); + + const retract_button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-retract')); + retract_button.click(); + await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal'))); + + const reason_input = document.querySelector('#converse-modals .modal input[name="reason"]'); + const reason = "This content is inappropriate for this forum!" + reason_input.value = reason; + const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]'); + submit_button.click(); + + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop()); + const message = view.model.messages.at(0); + const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`); + // The server responds with a retraction message + const retraction = u.toStanza(` + <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo"> + <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0"> + <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'> + <retract xmlns='urn:xmpp:message-retract:0' /> + <reason>${reason}</reason> + </moderated> + </apply-to> + </message>`); + await view.model.handleMessageStanza(retraction); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(msg_el.textContent).toBe('romeo has removed this message'); + const qel = view.querySelector('.chat-msg--retracted .chat-msg__message q'); + expect(qel.textContent).toBe('This content is inappropriate for this forum!'); + + const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'}); + _converse.connection._dataRecv(mock.createRequest(result_iq)); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.model.messages.at(0).get('moderated_by')).toBe(_converse.bare_jid); + expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason); + expect(view.model.messages.at(0).get('editable')).toBe(false); + })); + }); + + + describe("A Sent Groupchat Message", function () { + + it("can be retracted by its author", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.chatboxviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + occupant.save('role', 'member'); + const retraction_stanza = await sendAndThenRetractMessage(_converse, view); + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1, 1000); + + const msg_obj = view.model.messages.last(); + expect(msg_obj.get('retracted')).toBeTruthy(); + + expect(Strophe.serialize(retraction_stanza)).toBe( + `<message id="${retraction_stanza.getAttribute('id')}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+ + `<store xmlns="urn:xmpp:hints"/>`+ + `<apply-to id="${msg_obj.get('origin_id')}" xmlns="urn:xmpp:fasten:0">`+ + `<retract xmlns="urn:xmpp:message-retract:0"/>`+ + `</apply-to>`+ + `</message>`); + + const message = view.model.messages.last(); + expect(message.get('is_ephemeral')).toBe(false); + expect(message.get('editable')).toBeFalsy(); + + const stanza_id = message.get(`stanza_id ${muc_jid}`); + // The server responds with a retraction message + const reflection = u.toStanza(` + <message type="groupchat" id="${retraction_stanza.getAttribute('id')}" from="${muc_jid}" to="${muc_jid}/romeo"> + <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0"> + <retract xmlns='urn:xmpp:message-retract:0' /> + </apply-to> + </message>`); + + spyOn(view.model, 'handleRetraction').and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(reflection)); + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1, 1000); + + await u.waitUntil(() => view.model.messages.length === 2, 1000); + expect(view.model.messages.last().get('retracted')).toBeTruthy(); + expect(view.model.messages.last().get('is_ephemeral')).toBe(false); + expect(view.model.messages.last().get('editable')).toBe(false); + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const el = view.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(el.textContent).toBe('romeo has removed this message'); + })); + + it("can be retracted by its author, causing an error message in response", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.chatboxviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + + expect(occupant.get('role')).toBe('moderator'); + occupant.save('role', 'member'); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.includes("romeo is no longer a moderator")); + const retraction_stanza = await sendAndThenRetractMessage(_converse, view); + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1, 1000); + + expect(view.model.messages.length).toBe(1); + await u.waitUntil(() => view.model.messages.last().get('retracted'), 1000); + const el = view.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(el.textContent.trim()).toBe('romeo has removed this message'); + + const message = view.model.messages.last(); + const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`); + // The server responds with an error message + const error = u.toStanza(` + <message type="error" id="${retraction_stanza.getAttribute('id')}" from="${muc_jid}" to="${view.model.get('jid')}/romeo"> + <error by='${muc_jid}' type='auth'> + <forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/> + </error> + <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0"> + <retract xmlns='urn:xmpp:message-retract:0' /> + </apply-to> + </message>`); + + _converse.connection._dataRecv(mock.createRequest(error)); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__error').length === 1, 1000); + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 0, 1000); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); + expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy(); + expect(view.model.messages.at(0).get('editable')).toBe(false); + + const errmsg = view.querySelector('.chat-msg__error'); + expect(errmsg.textContent.trim()).toBe("You're not allowed to retract your message."); + })); + + it("can be retracted by its author, causing a timeout error in response", + mock.initConverse(['chatBoxesFetched'], { stanza_timeout: 1 }, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.chatboxviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + occupant.save('role', 'member'); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.includes("romeo is no longer a moderator")) + await sendAndThenRetractMessage(_converse, view); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.last().get('retracted')).toBeTruthy(); + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1); + const el = view.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(el.textContent.trim()).toBe('romeo has removed this message'); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 0); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); + expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy(); + expect(view.model.messages.at(0).get('editable')).toBeTruthy(); + + const error_messages = view.querySelectorAll('.chat-msg__error'); + expect(error_messages.length).toBe(1); + expect(error_messages[0].textContent.trim()).toBe('A timeout happened while while trying to retract your message.'); + })); + + + it("can be retracted by a moderator", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.chatboxviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + + view.model.sendMessage({'body': 'Visit this site to get free bitcoin'}); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + const stanza_id = 'retraction-id-1'; + const msg_obj = view.model.messages.at(0); + const reflection_stanza = u.toStanza(` + <message xmlns="jabber:client" + from="${msg_obj.get('from')}" + to="${_converse.connection.jid}" + type="groupchat"> + <msg_body>${msg_obj.get('message')}</msg_body> + <stanza-id xmlns="urn:xmpp:sid:0" + id="${stanza_id}" + by="lounge@montague.lit"/> + <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/> + </message>`); + await view.model.handleMessageStanza(reflection_stanza); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('editable')).toBe(true); + + // The server responds with a retraction message + const reason = "This content is inappropriate for this forum!" + const retraction = u.toStanza(` + <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo"> + <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0"> + <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'> + <retract xmlns='urn:xmpp:message-retract:0' /> + <reason>${reason}</reason> + </moderated> + </apply-to> + </message>`); + await view.model.handleMessageStanza(retraction); + expect(view.model.messages.length).toBe(1); + await u.waitUntil(() => view.model.messages.at(0).get('moderated') === 'retracted'); + expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); + expect(view.model.messages.at(0).get('editable')).toBe(false); + })); + + it("can be retracted by the sender if they're a moderator", + mock.initConverse(['chatBoxesFetched'], {'allow_message_retraction': 'moderator'}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.chatboxviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + + view.model.sendMessage({'body': 'Visit this site to get free bitcoin'}); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + + // Check that you can only edit a message before it's been + // reflected. You can't retract because it hasn't + await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-edit')); + expect(view.querySelectorAll('.chat-msg__action').length).toBe(1); + + const stanza_id = 'retraction-id-1'; + const msg_obj = view.model.messages.at(0); + const reflection_stanza = u.toStanza(` + <message xmlns="jabber:client" + from="${msg_obj.get('from')}" + to="${_converse.connection.jid}" + type="groupchat"> + <msg_body>${msg_obj.get('message')}</msg_body> + <stanza-id xmlns="urn:xmpp:sid:0" + id="${stanza_id}" + by="lounge@montague.lit"/> + <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/> + </message>`); + + await view.model.handleMessageStanza(reflection_stanza); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('editable')).toBe(true); + + const retract_button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-retract')); + retract_button.click(); + await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal'))); + const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]'); + submit_button.click(); + + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop()); + + expect(Strophe.serialize(stanza)).toBe( + `<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+ + `<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">`+ + `<moderate xmlns="urn:xmpp:message-moderate:0">`+ + `<retract xmlns="urn:xmpp:message-retract:0"/>`+ + `<reason></reason>`+ + `</moderate>`+ + `</apply-to>`+ + `</iq>`); + + const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'}); + _converse.connection._dataRecv(mock.createRequest(result_iq)); + + // We opportunistically save the message as retracted, even before receiving the retraction message + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.model.messages.at(0).get('moderation_reason')).toBe(undefined); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); + expect(view.model.messages.at(0).get('editable')).toBe(false); + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + + const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message'); + expect(msg_el.firstElementChild.textContent.trim()).toBe('romeo has removed this message'); + expect(msg_el.querySelector('q')).toBe(null); + + // The server responds with a retraction message + const retraction = u.toStanza(` + <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo"> + <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0"> + <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'> + <retract xmlns='urn:xmpp:message-retract:0' /> + </moderated> + </apply-to> + </message>`); + await view.model.handleMessageStanza(retraction); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.model.messages.at(0).get('moderation_reason')).toBe(undefined); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); + expect(view.model.messages.at(0).get('editable')).toBe(false); + })); + }); + + + describe("when archived", function () { + + it("may be returned as a tombstone message", + mock.initConverse( + ['discoInitialized'], {}, + 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); + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]); + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + const queryid = stanza.querySelector('query').getAttribute('queryid'); + const view = _converse.chatboxviews.get(contact_jid); + const first_id = u.getUniqueId(); + + spyOn(view.model, 'handleRetraction').and.callThrough(); + const first_message = u.toStanza(` + <message id='${u.getUniqueId()}' to='${_converse.jid}'> + <result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${first_id}"> + <forwarded xmlns='urn:xmpp:forward:0'> + <delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:01:15Z'/> + <message type="chat" from="${contact_jid}" to="${_converse.bare_jid}" id="message-id-0"> + <origin-id xmlns='urn:xmpp:sid:0' id="origin-id-0"/> + <body>😊</body> + </message> + </forwarded> + </result> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(first_message)); + + const tombstone = u.toStanza(` + <message id='${u.getUniqueId()}' to='${_converse.jid}'> + <result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${u.getUniqueId()}"> + <forwarded xmlns='urn:xmpp:forward:0'> + <delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:08:25Z'/> + <message type="chat" from="${contact_jid}" to="${_converse.bare_jid}" id="message-id-1"> + <origin-id xmlns='urn:xmpp:sid:0' id="origin-id-1"/> + <retracted stamp='2019-09-20T23:09:32Z' xmlns='urn:xmpp:message-retract:0'/> + </message> + </forwarded> + </result> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(tombstone)); + + const last_id = u.getUniqueId(); + const retraction = u.toStanza(` + <message id='${u.getUniqueId()}' to='${_converse.jid}'> + <result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${last_id}"> + <forwarded xmlns='urn:xmpp:forward:0'> + <delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:08:25Z'/> + <message from="${contact_jid}" to='${_converse.bare_jid}' id='retract-message-1'> + <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0"> + <retract xmlns='urn:xmpp:message-retract:0'/> + </apply-to> + </message> + </forwarded> + </result> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(retraction)); + + const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')}) + .c('fin', {'xmlns': 'urn:xmpp:mam:2'}) + .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'}) + .c('first', {'index': '0'}).t(first_id).up() + .c('last').t(last_id).up() + .c('count').t('2'); + _converse.connection._dataRecv(mock.createRequest(iq_result)); + + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 3); + + expect(view.model.messages.length).toBe(2); + const message = view.model.messages.at(1); + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('is_tombstone')).toBe(true); + expect(await view.model.handleRetraction.calls.first().returnValue).toBe(false); + expect(await view.model.handleRetraction.calls.all()[1].returnValue).toBe(false); + expect(await view.model.handleRetraction.calls.all()[2].returnValue).toBe(true); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const el = view.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(el.textContent.trim()).toBe('Mercutio has removed this message'); + expect(u.hasClass('chat-msg--followup', el.parentElement)).toBe(false); + })); + + it("may be returned as a tombstone groupchat message", + mock.initConverse( + ['discoInitialized'], {}, + async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.chatboxviews.get(muc_jid); + + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + const queryid = stanza.querySelector('query').getAttribute('queryid'); + + const first_id = u.getUniqueId(); + const tombstone = u.toStanza(` + <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="stanza-id"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/> + <message type="groupchat" from="${muc_jid}/eve" to="${_converse.bare_jid}" id="message-id-1"> + <origin-id xmlns='urn:xmpp:sid:0' id="origin-id-1"/> + <retracted stamp="2019-09-20T23:09:32Z" xmlns="urn:xmpp:message-retract:0"/> + </message> + </forwarded> + </result> + </message> + `); + spyOn(view.model, 'handleRetraction').and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(tombstone)); + + const last_id = u.getUniqueId(); + const retraction = u.toStanza(` + <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="${last_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/> + <message type="groupchat" from="${muc_jid}/eve" to="${_converse.bare_jid}" id="retract-message-1"> + <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0"> + <retract xmlns="urn:xmpp:message-retract:0"/> + </apply-to> + </message> + </forwarded> + </result> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(retraction)); + + const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')}) + .c('fin', {'xmlns': 'urn:xmpp:mam:2'}) + .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'}) + .c('first', {'index': '0'}).t(first_id).up() + .c('last').t(last_id).up() + .c('count').t('2'); + _converse.connection._dataRecv(mock.createRequest(iq_result)); + + await u.waitUntil(() => view.model.messages.length === 1); + let message = view.model.messages.at(0); + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('is_tombstone')).toBe(true); + + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2); + expect(await view.model.handleRetraction.calls.first().returnValue).toBe(false); + expect(await view.model.handleRetraction.calls.all()[1].returnValue).toBe(true); + expect(view.model.messages.length).toBe(1); + message = view.model.messages.at(0); + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('is_tombstone')).toBe(true); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const el = view.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(el.textContent.trim()).toBe('eve has removed this message'); + })); + + it("may be returned as a tombstone moderated groupchat message", + mock.initConverse( + ['discoInitialized', 'chatBoxesFetched'], {}, + async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.chatboxviews.get(muc_jid); + + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + const queryid = stanza.querySelector('query').getAttribute('queryid'); + + const first_id = u.getUniqueId(); + const tombstone = u.toStanza(` + <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="stanza-id"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/> + <message type="groupchat" from="${muc_jid}/eve" to="${_converse.bare_jid}" id="message-id-1"> + <moderated by="${muc_jid}/bob" stamp="2019-09-20T23:09:32Z" xmlns='urn:xmpp:message-moderate:0'> + <retracted xmlns="urn:xmpp:message-retract:0"/> + <reason>This message contains inappropriate content</reason> + </moderated> + </message> + </forwarded> + </result> + </message> + `); + spyOn(view.model, 'handleModeration').and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(tombstone)); + + const last_id = u.getUniqueId(); + const retraction = u.toStanza(` + <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="${last_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/> + <message type="groupchat" from="${muc_jid}" to="${_converse.bare_jid}" id="retract-message-1"> + <apply-to id="stanza-id" xmlns="urn:xmpp:fasten:0"> + <moderated by="${muc_jid}/bob" xmlns='urn:xmpp:message-moderate:0'> + <retract xmlns="urn:xmpp:message-retract:0"/> + <reason>This message contains inappropriate content</reason> + </moderated> + </apply-to> + </message> + </forwarded> + </result> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(retraction)); + + const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')}) + .c('fin', {'xmlns': 'urn:xmpp:mam:2'}) + .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'}) + .c('first', {'index': '0'}).t(first_id).up() + .c('last').t(last_id).up() + .c('count').t('2'); + _converse.connection._dataRecv(mock.createRequest(iq_result)); + + await u.waitUntil(() => view.model.messages.length); + expect(view.model.messages.length).toBe(1); + let message = view.model.messages.at(0); + await u.waitUntil(() => message.get('retracted')); + expect(message.get('is_tombstone')).toBe(true); + + await u.waitUntil(() => view.model.handleModeration.calls.count() === 2); + expect(await view.model.handleModeration.calls.first().returnValue).toBe(false); + expect(await view.model.handleModeration.calls.all()[1].returnValue).toBe(true); + + expect(view.model.messages.length).toBe(1); + message = view.model.messages.at(0); + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('is_tombstone')).toBe(true); + expect(message.get('moderation_reason')).toBe("This message contains inappropriate content"); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length, 500); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const el = view.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(el.textContent.trim()).toBe('A moderator has removed this message'); + const qel = view.querySelector('.chat-msg--retracted .chat-msg__message q'); + expect(qel.textContent.trim()).toBe('This message contains inappropriate content'); + })); + }); +}) diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/styling.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/styling.js new file mode 100644 index 0000000..e285892 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/styling.js @@ -0,0 +1,58 @@ +/*global mock, converse */ + +const { u, $msg } = converse.env; + +describe("An incoming groupchat Message", function () { + + it("can be styled with span XEP-0393 message styling hints that contain mentions", + mock.initConverse(['chatBoxesFetched'], {}, + async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const msg_text = "This *message mentions romeo*"; + const msg = $msg({ + from: 'lounge@montague.lit/gibson', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(msg_text).up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'23', 'end':'29', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).nodeTree; + await view.model.handleMessageStanza(msg); + const message = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(message.classList.length).toEqual(1); + + const 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 <span class="styling-directive">*</span>'+ + '<b>message mentions <span class="mention mention--self badge badge-info" data-uri="xmpp:romeo@montague.lit">romeo</span></b>'+ + '<span class="styling-directive">*</span>'); + })); + + it("will not have styling applied to mentioned nicknames themselves", + mock.initConverse(['chatBoxesFetched'], {}, + async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const msg_text = "x_y_z_ hello"; + const msg = $msg({ + from: 'lounge@montague.lit/gibson', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(msg_text).up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'0', 'end':'6', 'type':'mention', 'uri':'xmpp:xyz@montague.lit'}).nodeTree; + await view.model.handleMessageStanza(msg); + const message = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(message.classList.length).toEqual(1); + + const 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="mention" data-uri="xmpp:xyz@montague.lit">x_y_z_</span> hello'); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/toolbar.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/toolbar.js new file mode 100644 index 0000000..d0d2db2 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/toolbar.js @@ -0,0 +1,18 @@ +/*global mock, converse */ + +const { u } = converse.env; + +describe('The visible_toolbar_buttons configuration setting', function () { + + it("can be used to show a participants toggle in a MUC's toolbar", + mock.initConverse([], { 'visible_toolbar_buttons': { 'toggle_occupants': true } }, + async (_converse) => { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.querySelector('converse-chat-toolbar .toggle_occupants')); + expect(1).toBe(1); + }) + ); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/unfurls.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/unfurls.js new file mode 100644 index 0000000..a7494af --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/unfurls.js @@ -0,0 +1,489 @@ +/*global mock, converse */ + +const { Strophe, u } = converse.env; + +describe("A Groupchat Message", function () { + + it("will render an unfurl based on OGP data", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + + const unfurl_image_src = "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg"; + const unfurl_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; + + const message_stanza = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/> + <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message_stanza)); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); + + const metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="${unfurl_url}" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="${unfurl_image_src}" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:width" content="1280" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:height" content="720" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&#39;s official music video for "Never Gonna Give You Up" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:type" content="video.other" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:secure_url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:type" content="text/html" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:width" content="1280" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:height" content="720" /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + const unfurl = await u.waitUntil(() => view.querySelector('converse-message-unfurl')); + expect(unfurl.querySelector('.card-img-top').getAttribute('src')).toBe(unfurl_image_src); + expect(unfurl.querySelector('.card-img-top').getAttribute('href')).toBe(unfurl_url); + })); + + it("will render an unfurl with limited OGP data", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + /* Some sites don't include ogp data such as title, description and + * url. This test is to check that we fall back gracefully */ + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + + const message_stanza = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <body>https://mempool.space</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/> + <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message_stanza)); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe('https://mempool.space'); + + const metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://conversejs.org/dist/images/custom_emojis/converse.png" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:type" content="image/png" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:width" content="1000" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:height" content="500" /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + const unfurl = await u.waitUntil(() => view.querySelector('converse-message-unfurl')); + expect(unfurl.querySelector('.card-img-top').getAttribute('src')).toBe('https://conversejs.org/dist/images/custom_emojis/converse.png'); + expect(unfurl.querySelector('.card-body')).toBe(null); + })); + + it("will render an unfurl containing a GIF", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + const unfurl_url = "https://giphy.com/gifs/giphyqa-4YY4DnqeUDBXNTcYMu"; + const gif_url = "https://media4.giphy.com/media/4YY4DnqeUDBXNTcYMu/giphy.gif?foo=bar"; + + const message_stanza = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <body>${unfurl_url}</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/> + <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message_stanza)); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe(unfurl_url); + + const metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Animated GIF" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Alright then, keep your secrets" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="${unfurl_url}" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="${gif_url}" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:type" content="image/gif" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:width" content="360" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:height" content="302" /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + const unfurl = await u.waitUntil(() => view.querySelector('converse-message-unfurl')); + expect(unfurl.querySelector('.card-img-top').getAttribute('src')).toBe(gif_url); + })); + + it("will render multiple unfurls based on OGP data", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + + const message_stanza = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <body>Check out https://www.youtube.com/watch?v=dQw4w9WgXcQ and https://duckduckgo.com</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/> + <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message_stanza)); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe('Check out https://www.youtube.com/watch?v=dQw4w9WgXcQ and https://duckduckgo.com'); + + let metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:width" content="1280" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:height" content="720" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&#39;s official music video for "Never Gonna Give You Up" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:type" content="video.other" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:secure_url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:type" content="text/html" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:width" content="1280" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:height" content="720" /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + await u.waitUntil(() => view.querySelectorAll('converse-message-unfurl').length === 1); + + metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://duckduckgo.com" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="DuckDuckGo" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://duckduckgo.com/assets/logo_social-media.png" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="DuckDuckGo - Privacy, simplified." /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="The Internet privacy company that empowers you to seamlessly take control of your personal information online, without any tradeoffs." /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + await u.waitUntil(() => view.querySelectorAll('converse-message-unfurl').length === 2); + })); + + it("will not render an unfurl received from a MUC participant", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + + const message_stanza = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/> + <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message_stanza)); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); + + spyOn(view.model, 'handleMetadataFastening').and.callThrough(); + + const metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}/arzu" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&#39;s official music video for "Never Gonna Give You Up" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + await u.waitUntil(() => view.model.handleMetadataFastening.calls.count()); + expect(view.model.handleMetadataFastening.calls.first().returnValue).toBe(false); + expect(view.querySelector('converse-message-unfurl')).toBe(null); + })); + + it("will not render an unfurl based on OGP data if render_media is false", + mock.initConverse(['chatBoxesFetched'], + { 'render_media': false }, + async function (_converse) { + + const { api } = _converse; + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + + const message_stanza = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/> + <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message_stanza)); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); + + spyOn(view.model, 'handleMetadataFastening').and.callThrough(); + + const metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&#39;s official music video for "Never Gonna Give You Up" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + expect(view.querySelector('converse-message-unfurl')).toBe(null); + + api.settings.set('render_media', true); + await u.waitUntil(() => view.querySelector('converse-message-unfurl')); + + let button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-hide-previews')); + expect(button.textContent.trim()).toBe('Hide media'); + button.click(); + + await u.waitUntil(() => !view.querySelector('converse-message-unfurl'), 1000); + button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-hide-previews')); + expect(button.textContent.trim()).toBe('Show media'); + })); + + it("will only render a single unfurl when receiving the same OGP data multiple times", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + + const message_stanza = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/> + <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message_stanza)); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); + + spyOn(view.model, 'handleMetadataFastening').and.callThrough(); + + const metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&#39;s official music video for "Never Gonna Give You Up" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + await u.waitUntil(() => view.model.handleMetadataFastening.calls.count()); + const unfurls = await u.waitUntil(() => view.querySelectorAll('converse-message-unfurl')); + expect(unfurls.length).toBe(1); + })); + + it("will not render an unfurl image if the domain is not in allowed_image_domains", + mock.initConverse(['chatBoxesFetched'], + {'allowed_image_domains': []}, + async function (_converse) { + + const { api } = _converse; + + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + + const message_stanza = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/> + <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message_stanza)); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); + + const metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&#39;s official music video for "Never Gonna Give You Up" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + await u.waitUntil(() => !view.querySelector('converse-message-unfurl')); + + api.settings.set('allowed_image_domains', null); + await u.waitUntil(() => view.querySelector('converse-message-unfurl')); + })); + + it("lets the user hide an unfurl", + mock.initConverse(['chatBoxesFetched'], + {'render_media': true}, + async function (_converse) { + + const { api } = _converse; + + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + + const message_stanza = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/> + <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message_stanza)); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); + + const metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&#39;s official music video for "Never Gonna Give You Up" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:type" content="video.other" /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + await u.waitUntil(() => view.querySelector('converse-message-unfurl')); + let button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-hide-previews')); + expect(button.textContent.trim()).toBe('Hide media'); + button.click(); + await u.waitUntil(() => view.querySelector('converse-message-unfurl') === null, 750); + button = view.querySelector('.chat-msg__content .chat-msg__action-hide-previews'); + expect(button.textContent.trim()).toBe('Show media'); + button.click(); + await u.waitUntil(() => view.querySelector('converse-message-unfurl'), 750); + + // Check that the image doesn't render if the domain is not allowed + expect(view.querySelector('converse-message-unfurl .chat-image')).not.toBe(null); + api.settings.set('allowed_image_domains', []); + await u.waitUntil(() => view.querySelector('converse-message-unfurl .chat-image') === null); + api.settings.set('allowed_image_domains', undefined); + await u.waitUntil(() => view.querySelector('converse-message-unfurl .chat-image') !== null); + })); + + it("will not render an unfurl that has been removed in a subsequent correction", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const nick = 'romeo'; + const muc_jid = 'lounge@muc.montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + + const unfurl_image_src = "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg"; + const unfurl_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; + + spyOn(_converse.connection, 'send').and.callThrough(); + + const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea')); + const message_form = view.querySelector('converse-muc-message-form'); + textarea.value = unfurl_url; + const enter_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 13 // Enter + } + message_form.onKeyDown(enter_event); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + expect(view.querySelector('.chat-msg__text').textContent) + .toBe(unfurl_url); + + let msg = _converse.connection.send.calls.all()[1].args[0]; + expect(Strophe.serialize(msg)) + .toBe( + `<message from="${_converse.jid}" id="${msg.getAttribute('id')}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+ + `<body>${unfurl_url}</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<origin-id id="${msg.querySelector('origin-id')?.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>`); + + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe(unfurl_url); + + const metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="${msg.getAttribute('id')}"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="${unfurl_url}" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="${unfurl_image_src}" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:width" content="1280" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:height" content="720" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&#39;s official music video for "Never Gonna Give You Up" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:type" content="video.other" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:secure_url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:type" content="text/html" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:width" content="1280" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:height" content="720" /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + const unfurl = await u.waitUntil(() => view.querySelector('converse-message-unfurl')); + expect(unfurl.querySelector('.card-img-top').getAttribute('src')).toBe(unfurl_image_src); + expect(unfurl.querySelector('.card-img-top').getAttribute('href')).toBe(unfurl_url); + + // Modify the message to use a different URL + expect(textarea.value).toBe(''); + message_form.onKeyDown({ + target: textarea, + keyCode: 38 // Up arrow + }); + expect(textarea.value).toBe(unfurl_url); + textarea.value = "never mind"; + message_form.onKeyDown(enter_event); + + const getSentMessages = () => _converse.connection.send.calls.all().map(c => c.args[0]).filter(s => s.nodeName === 'message'); + await u.waitUntil(() => getSentMessages().length == 2); + msg = getSentMessages().pop(); + expect(Strophe.serialize(msg)) + .toBe( + `<message from="${_converse.jid}" id="${msg.getAttribute('id')}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+ + `<body>never mind</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<replace id="${msg.querySelector('replace')?.getAttribute('id')}" xmlns="urn:xmpp:message-correct:0"/>`+ + `<origin-id id="${msg.querySelector('origin-id')?.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>`); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/xss.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/xss.js new file mode 100644 index 0000000..35d062e --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/xss.js @@ -0,0 +1,54 @@ +/*global mock, converse */ + +const $pres = converse.env.$pres; +const u = converse.env.utils; + +describe("XSS", function () { + describe("A Groupchat", function () { + + it("escapes occupant nicknames when rendering them, to avoid JS-injection attacks", + mock.initConverse([], {}, async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + /* <presence xmlns="jabber:client" to="jc@chat.example.org/converse.js-17184538" + * from="oo@conference.chat.example.org/<img src="x" onerror="alert(123)"/>"> + * <x xmlns="http://jabber.org/protocol/muc#user"> + * <item jid="jc@chat.example.org/converse.js-17184538" affiliation="owner" role="moderator"/> + * <status code="110"/> + * </x> + * </presence>" + */ + const presence = $pres({ + to:'romeo@montague.lit/pda', + from:"lounge@montague.lit/<img src="x" onerror="alert(123)"/>" + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + jid: 'someone@montague.lit', + role: 'moderator', + }).up() + .c('status').attrs({code:'110'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + await u.waitUntil(() => view.querySelectorAll('.occupant-list .occupant-nick').length === 2); + const occupants = view.querySelectorAll('.occupant-list li .occupant-nick'); + expect(occupants.length).toBe(2); + expect(occupants[0].textContent.trim()).toBe("<img src="x" onerror="alert(123)"/>"); + })); + + it("escapes the subject before rendering it, to avoid JS-injection attacks", + mock.initConverse([], {}, async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc'); + spyOn(window, 'alert'); + const subject = '<img src="x" onerror="alert(\'XSS\');"/>'; + const view = _converse.chatboxviews.get('jdev@conference.jabber.org'); + view.model.set({'subject': { + 'text': subject, + 'author': 'ralphm' + }}); + const text = await u.waitUntil(() => view.querySelector('.chat-head__desc')?.textContent.trim()); + expect(text).toBe(subject); + })); + }); +}); |