diff --git a/sass/_chatrooms.scss b/sass/_chatrooms.scss index 03115ec63..b230c65c2 100644 --- a/sass/_chatrooms.scss +++ b/sass/_chatrooms.scss @@ -346,6 +346,10 @@ } } + converse-muc-bottom-panel { + display: contents; + } + .muc-bottom-panel { height: 3em; padding: 0.5em; @@ -354,6 +358,11 @@ background-color: var(--chatroom-head-bg-color); color: white; + &.muc-bottom-panel--muted { + height: 8em; + width: 100%; + } + &.muc-bottom-panel--nickname { padding: 0; height: 16em; diff --git a/spec/autocomplete.js b/spec/autocomplete.js index 7c5624e2f..c897c3fe3 100644 --- a/spec/autocomplete.js +++ b/spec/autocomplete.js @@ -40,7 +40,7 @@ describe("The nickname autocomplete feature", function () { await u.waitUntil(() => view.model.messages.last()?.get('received')); // Test that pressing @ brings up all options - const textarea = view.querySelector('textarea.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); const at_event = { 'target': textarea, 'preventDefault': function preventDefault () {}, @@ -48,9 +48,10 @@ describe("The nickname autocomplete feature", function () { 'keyCode': 50, 'key': '@' }; - view.onKeyDown(at_event); + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown(at_event); textarea.value = '@'; - view.onKeyUp(at_event); + bottom_panel.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'); @@ -93,7 +94,7 @@ describe("The nickname autocomplete feature", function () { await u.waitUntil(() => view.model.messages.last()?.get('received')); // Test that pressing @ brings up all options - const textarea = view.querySelector('textarea.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); const at_event = { 'target': textarea, 'preventDefault': function preventDefault () {}, @@ -101,10 +102,11 @@ describe("The nickname autocomplete feature", function () { 'keyCode': 50, 'key': '@' }; + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); textarea.value = '\n' - view.onKeyDown(at_event); + bottom_panel.onKeyDown(at_event); textarea.value = '\n@'; - view.onKeyUp(at_event); + bottom_panel.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'); @@ -148,7 +150,7 @@ describe("The nickname autocomplete feature", function () { await u.waitUntil(() => view.model.messages.last()?.get('received')); // Test that pressing @ brings up all options - const textarea = view.querySelector('textarea.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); const at_event = { 'target': textarea, 'preventDefault': function preventDefault () {}, @@ -157,9 +159,10 @@ describe("The nickname autocomplete feature", function () { 'key': '@' }; textarea.value = '(' - view.onKeyDown(at_event); + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown(at_event); textarea.value = '(@'; - view.onKeyUp(at_event); + bottom_panel.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'); @@ -189,7 +192,7 @@ describe("The nickname autocomplete feature", function () { }))); }); - const textarea = view.querySelector('textarea.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); const at_event = { 'target': textarea, 'preventDefault': function preventDefault() { }, @@ -198,10 +201,11 @@ describe("The nickname autocomplete feature", function () { 'key': '@' }; + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); // Test that results are sorted by query index - view.onKeyDown(at_event); + bottom_panel.onKeyDown(at_event); textarea.value = '@ber'; - view.onKeyUp(at_event); + bottom_panel.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'); @@ -209,7 +213,7 @@ describe("The nickname autocomplete feature", function () { // Test that when the query index is equal, results should be sorted by length textarea.value = '@jo'; - view.onKeyUp(at_event); + bottom_panel.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'); @@ -235,7 +239,7 @@ describe("The nickname autocomplete feature", function () { _converse.connection._dataRecv(mock.createRequest(presence)); expect(view.model.occupants.length).toBe(2); - const textarea = view.querySelector('textarea.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = "hello som"; // Press tab @@ -246,8 +250,9 @@ describe("The nickname autocomplete feature", function () { 'keyCode': 9, 'key': 'Tab' } - view.onKeyDown(tab_event); - view.onKeyUp(tab_event); + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown(tab_event); + bottom_panel.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'); @@ -259,9 +264,9 @@ describe("The nickname autocomplete feature", function () { } for (var i=0; i<3; i++) { // Press backspace 3 times to remove "som" - view.onKeyDown(backspace_event); + bottom_panel.onKeyDown(backspace_event); textarea.value = textarea.value.slice(0, textarea.value.length-1) - view.onKeyUp(backspace_event); + bottom_panel.onKeyUp(backspace_event); } await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === true); @@ -278,8 +283,8 @@ describe("The nickname autocomplete feature", function () { _converse.connection._dataRecv(mock.createRequest(presence)); textarea.value = "hello s s"; - view.onKeyDown(tab_event); - view.onKeyUp(tab_event); + bottom_panel.onKeyDown(tab_event); + bottom_panel.onKeyUp(tab_event); await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false); expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(2); @@ -289,13 +294,13 @@ describe("The nickname autocomplete feature", function () { 'stopPropagation': function stopPropagation () {}, 'keyCode': 38 } - view.onKeyDown(up_arrow_event); - view.onKeyUp(up_arrow_event); + bottom_panel.onKeyDown(up_arrow_event); + bottom_panel.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'); - view.onKeyDown({ + bottom_panel.onKeyDown({ 'target': textarea, 'preventDefault': function preventDefault () {}, 'stopPropagation': function stopPropagation () {}, @@ -316,12 +321,12 @@ describe("The nickname autocomplete feature", function () { }); _converse.connection._dataRecv(mock.createRequest(presence)); textarea.value = "hello z"; - view.onKeyDown(tab_event); - view.onKeyUp(tab_event); + bottom_panel.onKeyDown(tab_event); + bottom_panel.onKeyUp(tab_event); await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false); - view.onKeyDown(tab_event); - view.onKeyUp(tab_event); + bottom_panel.onKeyDown(tab_event); + bottom_panel.onKeyUp(tab_event); await u.waitUntil(() => textarea.value === 'hello @z3r0 '); done(); })); @@ -345,7 +350,7 @@ describe("The nickname autocomplete feature", function () { _converse.connection._dataRecv(mock.createRequest(presence)); expect(view.model.occupants.length).toBe(2); - const textarea = view.querySelector('textarea.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = "hello @some1 "; // Press backspace @@ -356,9 +361,10 @@ describe("The nickname autocomplete feature", function () { 'keyCode': 8, 'key': 'Backspace' } - view.onKeyDown(backspace_event); + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown(backspace_event); textarea.value = "hello @some1"; // Mimic backspace - view.onKeyUp(backspace_event); + bottom_panel.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/spec/chatbox.js b/spec/chatbox.js index baae9ca09..3bb371e92 100644 --- a/spec/chatbox.js +++ b/spec/chatbox.js @@ -22,7 +22,6 @@ describe("Chatboxes", function () { await mock.openChatBoxFor(_converse, contact_jid); const view = _converse.chatboxviews.get(contact_jid); mock.sendMessage(view, '/help'); - await u.waitUntil(() => sizzle('.chat-info:not(.chat-date)', view).length); const info_messages = await u.waitUntil(() => sizzle('.chat-info:not(.chat-date)', view)); expect(info_messages.length).toBe(4); @@ -60,7 +59,8 @@ describe("Chatboxes", function () { const textarea = view.querySelector('textarea.chat-textarea'); textarea.value = '/clear'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-chat-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -279,13 +279,14 @@ describe("Chatboxes", function () { preventDefault: function preventDefault () {}, keyCode: 13 // Enter }; - view.onKeyDown(ev); + const bottom_panel = view.querySelector('converse-chat-bottom-panel'); + bottom_panel.onKeyDown(ev); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); - view.onKeyUp(ev); + bottom_panel.onKeyUp(ev); expect(counter.textContent).toBe('200'); textarea.value = 'hello world'; - view.onKeyUp(ev); + bottom_panel.onKeyUp(ev); expect(counter.textContent).toBe('189'); done(); })); @@ -430,7 +431,9 @@ describe("Chatboxes", function () { expect(view.model.get('chat_state')).toBe('active'); spyOn(_converse.connection, 'send'); spyOn(_converse.api, "trigger").and.callThrough(); - view.onKeyDown({ + + const bottom_panel = view.querySelector('converse-chat-bottom-panel'); + bottom_panel.onKeyDown({ target: view.querySelector('textarea.chat-textarea'), keyCode: 1 }); @@ -445,7 +448,7 @@ describe("Chatboxes", function () { expect(stanza.childNodes[2].tagName).toBe('no-permanent-store'); // The notification is not sent again - view.onKeyDown({ + bottom_panel.onKeyDown({ target: view.querySelector('textarea.chat-textarea'), keyCode: 1 }); @@ -469,7 +472,8 @@ describe("Chatboxes", function () { expect(view.model.get('chat_state')).toBe('active'); spyOn(_converse.connection, 'send'); spyOn(_converse.api, "trigger").and.callThrough(); - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-chat-bottom-panel'); + bottom_panel.onKeyDown({ target: view.querySelector('textarea.chat-textarea'), keyCode: 1 }); @@ -578,7 +582,8 @@ describe("Chatboxes", function () { spyOn(_converse.connection, 'send'); spyOn(view.model, 'setChatState').and.callThrough(); expect(view.model.get('chat_state')).toBe('active'); - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-chat-bottom-panel'); + bottom_panel.onKeyDown({ target: view.querySelector('textarea.chat-textarea'), keyCode: 1 }); @@ -602,14 +607,14 @@ describe("Chatboxes", function () { // Test #359. A paused notification should not be sent // out if the user simply types longer than the // timeout. - view.onKeyDown({ + bottom_panel.onKeyDown({ target: view.querySelector('textarea.chat-textarea'), keyCode: 1 }); expect(view.model.setChatState).toHaveBeenCalled(); expect(view.model.get('chat_state')).toBe('composing'); - view.onKeyDown({ + bottom_panel.onKeyDown({ target: view.querySelector('textarea.chat-textarea'), keyCode: 1 }); @@ -697,7 +702,8 @@ describe("Chatboxes", function () { let messages = await u.waitUntil(() => sent_stanzas.filter(s => s.matches('message'))); expect(messages.length).toBe(1); expect(view.model.get('chat_state')).toBe('active'); - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-chat-bottom-panel'); + bottom_panel.onKeyDown({ target: view.querySelector('textarea.chat-textarea'), keyCode: 1 }); @@ -924,18 +930,19 @@ describe("Chatboxes", function () { await u.waitUntil(() => view.querySelector('.chat-msg')); message = '/clear'; - spyOn(view, 'clearMessages').and.callThrough(); + const bottom_panel = view.querySelector('converse-chat-bottom-panel'); + spyOn(bottom_panel, 'clearMessages').and.callThrough(); spyOn(window, 'confirm').and.callFake(function () { return true; }); view.querySelector('.chat-textarea').value = message; - view.onKeyDown({ + bottom_panel.onKeyDown({ target: view.querySelector('textarea.chat-textarea'), preventDefault: function preventDefault () {}, keyCode: 13 }); - expect(view.clearMessages.calls.all().length).toBe(1); - await view.clearMessages.calls.all()[0].returnValue; + expect(bottom_panel.clearMessages.calls.all().length).toBe(1); + await bottom_panel.clearMessages.calls.all()[0].returnValue; expect(window.confirm).toHaveBeenCalled(); expect(view.model.messages.length, 0); // The messages must be removed from the chatbox stored_messages = await view.model.messages.browserStorage.findAll(); diff --git a/spec/corrections.js b/spec/corrections.js index de4414551..43fb8a3e1 100644 --- a/spec/corrections.js +++ b/spec/corrections.js @@ -15,14 +15,15 @@ describe("A Chat Message", function () { const view = _converse.api.chatviews.get(contact_jid); const textarea = view.querySelector('textarea.chat-textarea'); expect(textarea.value).toBe(''); - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-chat-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, keyCode: 38 // Up arrow }); expect(textarea.value).toBe(''); textarea.value = 'But soft, what light through yonder airlock breaks?'; - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -34,7 +35,7 @@ describe("A Chat Message", function () { const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'}); expect(textarea.value).toBe(''); - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, keyCode: 38 // Up arrow }); @@ -46,7 +47,7 @@ describe("A Chat Message", function () { spyOn(_converse.connection, 'send'); let new_text = 'But soft, what light through yonder window breaks?'; textarea.value = new_text; - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -80,7 +81,7 @@ describe("A Chat Message", function () { // Test that pressing the down arrow cancels message correction await u.waitUntil(() => textarea.value === '') - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, keyCode: 38 // Up arrow }); @@ -89,7 +90,7 @@ describe("A Chat Message", function () { expect(view.querySelectorAll('.chat-msg').length).toBe(1); await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500); expect(textarea.value).toBe('But soft, what light through yonder window breaks?'); - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, keyCode: 40 // Down arrow }); @@ -100,7 +101,7 @@ describe("A Chat Message", function () { new_text = 'It is the east, and Juliet is the one.'; textarea.value = new_text; - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -110,14 +111,14 @@ describe("A Chat Message", function () { expect(view.querySelectorAll('.chat-msg').length).toBe(2); textarea.value = 'Arise, fair sun, and kill the envious moon'; - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter }); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3); - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, keyCode: 38 // Up arrow }); @@ -129,7 +130,7 @@ describe("A Chat Message", function () { textarea.selectionEnd = 0; // Happens by pressing up, // but for some reason not in tests, so we set it manually. - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, keyCode: 38 // Up arrow }); @@ -140,7 +141,7 @@ describe("A Chat Message", function () { await u.waitUntil(() => u.hasClass('correcting', sizzle('.chat-msg', view)[1]), 500); textarea.value = 'It is the east, and Juliet is the sun.'; - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -176,7 +177,8 @@ describe("A Chat Message", function () { const textarea = view.querySelector('textarea.chat-textarea'); textarea.value = 'But soft, what light through yonder airlock breaks?'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-chat-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -203,7 +205,7 @@ describe("A Chat Message", function () { spyOn(_converse.connection, 'send'); textarea.value = 'But soft, what light through yonder window breaks?'; - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -520,16 +522,17 @@ describe("A Groupchat Message", function () { const muc_jid = 'lounge@montague.lit'; await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); const view = _converse.api.chatviews.get(muc_jid); - const textarea = view.querySelector('textarea.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea')); expect(textarea.value).toBe(''); - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, keyCode: 38 // Up arrow }); expect(textarea.value).toBe(''); textarea.value = 'But soft, what light through yonder airlock breaks?'; - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -540,7 +543,7 @@ describe("A Groupchat Message", function () { const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'}); expect(textarea.value).toBe(''); - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, keyCode: 38 // Up arrow }); @@ -552,7 +555,7 @@ describe("A Groupchat Message", function () { spyOn(_converse.connection, 'send'); const new_text = 'But soft, what light through yonder window breaks?' textarea.value = new_text; - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -597,7 +600,7 @@ describe("A Groupchat Message", function () { // Test that pressing the down arrow cancels message correction expect(textarea.value).toBe(''); - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, keyCode: 38 // Up arrow }); @@ -606,7 +609,7 @@ describe("A Groupchat Message", function () { 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?'); - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, keyCode: 40 // Down arrow }); diff --git a/spec/emojis.js b/spec/emojis.js index 53a587b46..a1bb79aea 100644 --- a/spec/emojis.js +++ b/spec/emojis.js @@ -48,7 +48,8 @@ describe("Emojis", function () { 'keyCode': 9, 'key': 'Tab' } - view.onKeyDown(tab_event); + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.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); @@ -88,7 +89,7 @@ describe("Emojis", function () { _converse.connection._dataRecv(mock.createRequest(presence)); textarea.value = ':use'; - view.onKeyDown(tab_event); + bottom_panel.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); @@ -114,7 +115,8 @@ describe("Emojis", function () { 'keyCode': 9, 'key': 'Tab' } - view.onKeyDown(tab_event); + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown(tab_event); await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists'))); const picker = view.querySelector('converse-emoji-picker'); @@ -132,7 +134,7 @@ describe("Emojis", function () { emoji.click(); await u.waitUntil(() => textarea.value === ':grinning: '); textarea.value = ':grinning: :'; - view.onKeyDown(tab_event); + bottom_panel.onKeyDown(tab_event); await u.waitUntil(() => input.value === ':'); input.value = ':grimacing'; @@ -165,7 +167,8 @@ describe("Emojis", function () { 'key': 'Tab' } textarea.value = ':'; - view.onKeyDown(tab_event); + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.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'); @@ -176,7 +179,7 @@ describe("Emojis", function () { expect(textarea.value).toBe(':100: '); textarea.value = ':'; - view.onKeyDown(tab_event); + bottom_panel.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)); @@ -282,7 +285,8 @@ describe("Emojis", function () { // emojis now renders normally again. const textarea = view.querySelector('textarea.chat-textarea'); textarea.value = ':poop: :innocent:'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-chat-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -292,7 +296,7 @@ describe("Emojis", function () { await u.waitUntil(() => view.content.querySelector(last_msg_sel).textContent === 'πŸ’© πŸ˜‡'); expect(textarea.value).toBe(''); - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, keyCode: 38 // Up arrow }); @@ -302,7 +306,7 @@ describe("Emojis", function () { await u.waitUntil(() => u.hasClass('correcting', view.querySelector(sel)), 500); const edited_text = textarea.value += 'This is no longer an emoji-only message'; textarea.value = edited_text; - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -314,7 +318,7 @@ describe("Emojis", function () { expect(u.hasClass('chat-msg__text--larger', message)).toBe(false); textarea.value = ':smile: Hello world!'; - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -322,7 +326,7 @@ describe("Emojis", function () { await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4); textarea.value = ':smile: :smiley: :imp:'; - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -363,7 +367,8 @@ describe("Emojis", function () { const textarea = view.querySelector('textarea.chat-textarea'); textarea.value = ':poop: :innocent:'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-chat-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -414,7 +419,8 @@ describe("Emojis", function () { const textarea = view.querySelector('textarea.chat-textarea'); textarea.value = 'Running tests for :converse:'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-chat-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter diff --git a/spec/markers.js b/spec/markers.js index 0c7c267ca..39d15ce11 100644 --- a/spec/markers.js +++ b/spec/markers.js @@ -123,9 +123,10 @@ describe("A XEP-0333 Chat Marker", function () { const muc_jid = 'lounge@montague.lit'; await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); const view = _converse.api.chatviews.get(muc_jid); - const textarea = view.querySelector('textarea.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = 'But soft, what light through yonder airlock breaks?'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter diff --git a/spec/mentions.js b/spec/mentions.js index fef5e292b..97770f132 100644 --- a/spec/mentions.js +++ b/spec/mentions.js @@ -306,7 +306,7 @@ describe("A sent groupchat message", function () { }))); await u.waitUntil(() => view.model.occupants.length === 2); - const textarea = view.querySelector('textarea.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = 'hello @Link Mauve' const enter_event = { 'target': textarea, @@ -315,9 +315,10 @@ describe("A sent groupchat message", function () { 'keyCode': 13 // Enter } spyOn(_converse.connection, 'send'); - view.onKeyDown(enter_event); + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown(enter_event); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); - const msg = _converse.connection.send.calls.all()[1].args[0]; + const msg = _converse.connection.send.calls.all()[0].args[0]; expect(msg.toLocaleString()) .toBe(` view.model.occupants.length === 5); - const textarea = view.querySelector('textarea.chat-textarea'); + 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, @@ -374,7 +375,8 @@ describe("A sent groupchat message", function () { 'keyCode': 13 // Enter } spyOn(_converse.connection, 'send'); - view.onKeyDown(enter_event); + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown(enter_event); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text'; @@ -383,7 +385,7 @@ describe("A sent groupchat message", function () { 'hello z3r0 gibson mr.robot, how are you?' ); - const msg = _converse.connection.send.calls.all()[1].args[0]; + const msg = _converse.connection.send.calls.all()[0].args[0]; expect(msg.toLocaleString()) .toBe(` u.hasClass('correcting', view.querySelector('.chat-msg')), 500); - await u.waitUntil(() => _converse.connection.send.calls.count() === 2); + await u.waitUntil(() => _converse.connection.send.calls.count() === 1); textarea.value = 'hello @z3r0 @gibson @sw0rdf1sh, how are you?'; - view.onKeyDown(enter_event); + bottom_panel.onKeyDown(enter_event); await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent === 'hello z3r0 gibson sw0rdf1sh, how are you?', 500); - const correction = _converse.connection.send.calls.all()[2].args[0]; + const correction = _converse.connection.send.calls.all()[1].args[0]; expect(correction.toLocaleString()) .toBe(` view.model.occupants.length === 5); spyOn(_converse.connection, 'send'); - const textarea = view.querySelector('textarea.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = 'hello @z3r0 @gibson @mr.robot, how are you?' const enter_event = { 'target': textarea, @@ -457,7 +459,8 @@ describe("A sent groupchat message", function () { 'stopPropagation': function stopPropagation () {}, 'keyCode': 13 // Enter } - view.onKeyDown(enter_event); + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown(enter_event); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); const msg = _converse.connection.send.calls.all()[1].args[0]; @@ -483,7 +486,7 @@ describe("A sent groupchat message", function () { const muc_jid = 'lounge@montague.lit'; await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom', [], members); const view = _converse.api.chatviews.get(muc_jid); - const textarea = view.querySelector('textarea.chat-textarea'); + 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, @@ -491,7 +494,8 @@ describe("A sent groupchat message", function () { 'stopPropagation': function stopPropagation () {}, 'keyCode': 13 // Enter } - view.onKeyDown(enter_event); + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown(enter_event); const message = await u.waitUntil(() => view.querySelector('.chat-msg__text')); expect(message.innerHTML.replace(//g, '')).toEqual( `Welcome gibson πŸ’© `+ diff --git a/spec/messages.js b/spec/messages.js index 81f9773d3..b1ecc5727 100644 --- a/spec/messages.js +++ b/spec/messages.js @@ -527,7 +527,7 @@ describe("A Chat Message", function () { const view = _converse.api.chatviews.get(contact_jid); const message = 'This message contains a hyperlink: www.opkode.com'; spyOn(view.model, 'sendMessage').and.callThrough(); - mock.sendMessage(view, message); + await mock.sendMessage(view, message); expect(view.model.sendMessage).toHaveBeenCalled(); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); @@ -547,7 +547,7 @@ describe("A Chat Message", function () { await mock.openChatBoxFor(_converse, contact_jid); const view = _converse.api.chatviews.get(contact_jid); let message = 'This message contains a hyperlink with forbidden query params: https://www.opkode.com/?id=0&utm_content=1&utm_medium=2&s=1'; - mock.sendMessage(view, message); + await mock.sendMessage(view, message); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); await u.waitUntil(() => msg.innerHTML.replace(//g, '') === @@ -556,10 +556,10 @@ describe("A Chat Message", function () { // Test assigning a string to filter_url_query_params _converse.api.settings.set('filter_url_query_params', 'utm_medium'); message = 'Another message with a hyperlink with forbidden query params: https://www.opkode.com/?id=0&utm_content=1&utm_medium=2&s=1'; - mock.sendMessage(view, message); + await mock.sendMessage(view, message); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); - expect(msg.textContent).toEqual('Another message with a hyperlink with forbidden query params: https://www.opkode.com/?id=0&utm_content=1&s=1'); + expect(msg.textContent).toEqual(message); await u.waitUntil(() => msg.innerHTML.replace(//g, '') === 'Another message with a hyperlink with forbidden query params: '+ 'https://www.opkode.com/?id=0&utm_content=1&s=1'); @@ -622,7 +622,7 @@ describe("A Chat Message", function () { await mock.openChatBoxFor(_converse, contact_jid); const view = _converse.api.chatviews.get(contact_jid); spyOn(view.model, 'sendMessage').and.callThrough(); - mock.sendMessage(view, message); + await mock.sendMessage(view, message); await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length, 1000) expect(view.model.sendMessage).toHaveBeenCalled(); let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); @@ -632,7 +632,7 @@ describe("A Chat Message", function () { ``); message += "?param1=val1¶m2=val2"; - mock.sendMessage(view, message); + await mock.sendMessage(view, message); await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length === 2, 1000); expect(view.model.sendMessage).toHaveBeenCalled(); msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); @@ -643,7 +643,7 @@ describe("A Chat Message", function () { // Test now with two images in one message message += ' hello world '+base_url+"/logo/conversejs-filled.svg"; - mock.sendMessage(view, message); + await mock.sendMessage(view, message); await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length === 4, 1000); expect(view.model.sendMessage).toHaveBeenCalled(); msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); @@ -653,7 +653,7 @@ describe("A Chat Message", function () { // Configured image URLs are rendered _converse.api.settings.set('image_urls_regex', /^https?:\/\/(?:www.)?(?:imgur\.com\/\w{7})\/?$/i); message = 'https://imgur.com/oxymPax'; - mock.sendMessage(view, message); + await mock.sendMessage(view, message); await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length === 5, 1000); expect(view.content.querySelectorAll('.chat-content .chat-image').length).toBe(5); @@ -674,11 +674,11 @@ describe("A Chat Message", function () { await mock.openChatBoxFor(_converse, contact_jid); const view = _converse.api.chatviews.get(contact_jid); spyOn(view.model, 'sendMessage').and.callThrough(); - mock.sendMessage(view, message); + await mock.sendMessage(view, message); await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg').length === 1); message = base_url+"/logo/conversejs-filled.svg"; - mock.sendMessage(view, message); + await mock.sendMessage(view, message); await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg').length === 2, 1000); await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length === 1, 1000) expect(view.content.querySelectorAll('.chat-content .chat-image').length).toBe(1); @@ -698,7 +698,7 @@ describe("A Chat Message", function () { await mock.openChatBoxFor(_converse, contact_jid); const view = _converse.api.chatviews.get(contact_jid); spyOn(view.model, 'sendMessage').and.callThrough(); - mock.sendMessage(view, message); + await mock.sendMessage(view, message); await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length, 1000) expect(view.model.sendMessage).toHaveBeenCalled(); const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); @@ -721,7 +721,7 @@ describe("A Chat Message", function () { await mock.openChatBoxFor(_converse, contact_jid); const view = _converse.api.chatviews.get(contact_jid); spyOn(view.model, 'sendMessage').and.callThrough(); - mock.sendMessage(view, message); + await mock.sendMessage(view, message); expect(view.model.sendMessage).toHaveBeenCalled(); await u.waitUntil(() => view.querySelector('.chat-content .chat-msg'), 1000); const msg = view.querySelector('.chat-content .chat-msg .chat-msg__text'); diff --git a/spec/minchats.js b/spec/minchats.js index 5394c87ef..279b916c0 100644 --- a/spec/minchats.js +++ b/spec/minchats.js @@ -235,7 +235,7 @@ describe("A Minimized ChatBoxView's Unread Message Count", function () { await mock.openChatBoxFor(_converse, contact_jid); const view = _converse.chatboxviews.get(contact_jid); spyOn(view.model, 'sendMessage').and.callThrough(); - mock.sendMessage(view, message); + await mock.sendMessage(view, message); await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg').length, 1000); expect(view.model.sendMessage).toHaveBeenCalled(); const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); diff --git a/spec/mock.js b/spec/mock.js index 5f2a1dc59..fe70d5220 100644 --- a/spec/mock.js +++ b/spec/mock.js @@ -438,10 +438,12 @@ window.addEventListener('converse-loaded', () => { .c('active', {'xmlns': Strophe.NS.CHATSTATES}).tree(); } - mock.sendMessage = function (view, message) { + mock.sendMessage = async function (view, message) { const promise = new Promise(resolve => view.model.messages.once('rendered', resolve)); - view.querySelector('.chat-textarea').value = message; - view.onKeyDown({ + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = message; + const bottom_panel = view.querySelector('converse-chat-bottom-panel') || view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown({ target: view.querySelector('textarea.chat-textarea'), preventDefault: () => {}, keyCode: 13 diff --git a/spec/modtools.js b/spec/modtools.js index f8487920c..0ae2710e5 100644 --- a/spec/modtools.js +++ b/spec/modtools.js @@ -8,10 +8,11 @@ const u = converse.env.utils; async function openModtools (_converse, view) { - const textarea = view.querySelector('.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = '/modtools'; const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 }; - view.onKeyDown(enter); + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown(enter); await u.waitUntil(() => view.showModeratorToolsModal.calls.count()); const modal = _converse.api.modal.get('converse-modtools-modal'); await u.waitUntil(() => u.isVisible(modal.el), 1000); @@ -257,10 +258,11 @@ describe("The groupchat moderator tool", function () { )); await u.waitUntil(() => (view.model.occupants.length === 7), 1000); - const textarea = view.querySelector('.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = '/modtools'; const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 }; - view.onKeyDown(enter); + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown(enter); await u.waitUntil(() => view.showModeratorToolsModal.calls.count()); const modal = _converse.api.modal.get('converse-modtools-modal'); @@ -460,10 +462,11 @@ describe("The groupchat moderator tool", function () { 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 = view.querySelector('.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = '/modtools'; const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 }; - view.onKeyDown(enter); + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown(enter); await u.waitUntil(() => view.showModeratorToolsModal.calls.count()); const modal = _converse.api.modal.get('converse-modtools-modal'); diff --git a/spec/muc.js b/spec/muc.js index 60d026dbc..6a693fc19 100644 --- a/spec/muc.js +++ b/spec/muc.js @@ -386,7 +386,8 @@ describe("Groupchats", function () { await u.waitUntil(() => view.querySelector(sel)?.textContent.trim()); expect(view.querySelector(sel).textContent.trim()).toBe('Hello world') - view.querySelector('[name="nick"]').value = nick; + 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); @@ -2074,10 +2075,7 @@ describe("Groupchats", function () { await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); spyOn(_converse.api, "trigger").and.callThrough(); const view = _converse.chatboxviews.get('lounge@montague.lit'); - if (!view.querySelectorAll('.chat-area').length) { - view.renderChatArea(); - } - var nick = mock.chatroom_names[0]; + const nick = mock.chatroom_names[0]; view.model.occupants.create({ 'nick': nick, 'muc_jid': `${view.model.get('jid')}/${nick}` @@ -2101,13 +2099,11 @@ describe("Groupchats", function () { await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); spyOn(_converse.api, "trigger").and.callThrough(); const view = _converse.chatboxviews.get('lounge@montague.lit'); - if (!view.querySelectorAll('.chat-area').length) { - view.renderChatArea(); - } const text = 'This is a sent message'; - const textarea = view.querySelector('.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = text; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 @@ -2961,10 +2957,11 @@ describe("Groupchats", function () { spyOn(window, 'confirm').and.callFake(() => true); await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); const view = _converse.chatboxviews.get('lounge@montague.lit'); - let textarea = view.querySelector('.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 }; textarea.value = '/help'; - view.onKeyDown(enter); + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown(enter); await u.waitUntil(() => sizzle('converse-chat-help .chat-info', view).length); const chat_help_el = view.querySelector('converse-chat-help'); @@ -2997,7 +2994,7 @@ describe("Groupchats", function () { await u.waitUntil(() => chat_help_el.hidden); textarea.value = '/help'; - view.onKeyDown(enter); + bottom_panel.onKeyDown(enter); await u.waitUntil(() => !chat_help_el.hidden); info_messages = sizzle('.chat-info', chat_help_el); expect(info_messages.length).toBe(18); @@ -3012,7 +3009,7 @@ describe("Groupchats", function () { await u.waitUntil(() => chat_help_el.hidden); textarea.value = '/help'; - view.onKeyDown(enter); + bottom_panel.onKeyDown(enter); await u.waitUntil(() => !chat_help_el.hidden); info_messages = sizzle('.chat-info', chat_help_el); expect(info_messages.length).toBe(9); @@ -3025,10 +3022,9 @@ describe("Groupchats", function () { occupant.set('role', 'participant'); // Role changes causes rerender, so we need to get the new textarea - textarea = view.querySelector('.chat-textarea'); textarea.value = '/help'; - view.onKeyDown(enter); + bottom_panel.onKeyDown(enter); await u.waitUntil(() => view.model.get('show_help_messages')); await u.waitUntil(() => !chat_help_el.hidden); info_messages = sizzle('.chat-info', chat_help_el); @@ -3043,7 +3039,7 @@ describe("Groupchats", function () { await u.waitUntil(() => chat_help_el.hidden); textarea.value = '/help'; - view.onKeyDown(enter); + bottom_panel.onKeyDown(enter); await u.waitUntil(() => !chat_help_el.hidden, 1000); info_messages = sizzle('.chat-info', chat_help_el); expect(info_messages.length).toBe(7); @@ -3057,13 +3053,14 @@ describe("Groupchats", function () { await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); const view = _converse.chatboxviews.get('lounge@montague.lit'); - var textarea = view.querySelector('.chat-textarea'); + 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'; - view.onKeyDown(enter); + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown(enter); textarea.value = '/help'; - view.onKeyDown(enter); + bottom_panel.onKeyDown(enter); await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view).length); const info_messages = sizzle('.chat-info:not(.chat-event)', view); @@ -3110,7 +3107,7 @@ describe("Groupchats", function () { _converse.connection._dataRecv(mock.createRequest(presence)); expect(view.model.occupants.length).toBe(2); - const textarea = view.querySelector('.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); let sent_stanza; spyOn(_converse.connection, 'send').and.callFake((stanza) => { sent_stanza = stanza; @@ -3119,7 +3116,8 @@ describe("Groupchats", function () { // First check that an error message appears when a // non-existent nick is used. textarea.value = '/member chris Welcome to the club!'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 @@ -3131,7 +3129,7 @@ describe("Groupchats", function () { // Now test with an existing nick textarea.value = '/member marc Welcome to the club!'; - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 @@ -3234,15 +3232,15 @@ describe("Groupchats", function () { it("takes /topic to set the groupchat topic", mock.initConverse([], {}, async function (done, _converse) { await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); const view = _converse.chatboxviews.get('lounge@montague.lit'); - spyOn(view, 'clearMessages'); let sent_stanza; spyOn(_converse.connection, 'send').and.callFake(function (stanza) { sent_stanza = stanza; }); // Check the alias /topic - const textarea = view.querySelector('.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = '/topic This is the groupchat subject'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 @@ -3252,7 +3250,7 @@ describe("Groupchats", function () { // Check /subject textarea.value = '/subject This is a new subject'; - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 @@ -3266,7 +3264,7 @@ describe("Groupchats", function () { // Check case insensitivity textarea.value = '/Subject This is yet another subject'; - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 @@ -3279,7 +3277,7 @@ describe("Groupchats", function () { // Check unsetting the topic textarea.value = '/topic'; - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 @@ -3294,15 +3292,16 @@ describe("Groupchats", function () { it("takes /clear to clear messages", mock.initConverse([], {}, async function (done, _converse) { await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); const view = _converse.chatboxviews.get('lounge@montague.lit'); - spyOn(view, 'clearMessages'); - const textarea = view.querySelector('.chat-textarea') + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = '/clear'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + spyOn(bottom_panel, 'clearMessages'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 }); - expect(view.clearMessages).toHaveBeenCalled(); + expect(bottom_panel.clearMessages).toHaveBeenCalled(); done(); })); @@ -3317,7 +3316,7 @@ describe("Groupchats", function () { await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); const view = _converse.chatboxviews.get('lounge@montague.lit'); spyOn(view.model, 'setAffiliation').and.callThrough(); - spyOn(view, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); + spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); let presence = $pres({ 'from': 'lounge@montague.lit/annoyingGuy', @@ -3332,14 +3331,15 @@ describe("Groupchats", function () { }); _converse.connection._dataRecv(mock.createRequest(presence)); - var textarea = view.querySelector('.chat-textarea') + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = '/owner'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 }); - expect(view.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled(); + expect(view.model.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled(); 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."); @@ -3349,7 +3349,7 @@ describe("Groupchats", function () { // again via triggering Event doesn't work for some weird // reason. textarea.value = '/owner nobody You\'re responsible'; - view.onFormSubmitted(new Event('submit')); + bottom_panel.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"); @@ -3361,9 +3361,9 @@ describe("Groupchats", function () { // again via triggering Event doesn't work for some weird // reason. textarea.value = '/owner annoyingGuy You\'re responsible'; - view.onFormSubmitted(new Event('submit')); + bottom_panel.onFormSubmitted(new Event('submit')); - expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3); + expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3); expect(view.model.setAffiliation).toHaveBeenCalled(); // Check that the member list now gets updated expect(Strophe.serialize(sent_IQ)).toBe( @@ -3405,7 +3405,7 @@ describe("Groupchats", function () { await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); const view = _converse.chatboxviews.get('lounge@montague.lit'); spyOn(view.model, 'setAffiliation').and.callThrough(); - spyOn(view, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); + spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); let presence = $pres({ 'from': 'lounge@montague.lit/annoyingGuy', @@ -3420,14 +3420,15 @@ describe("Groupchats", function () { }); _converse.connection._dataRecv(mock.createRequest(presence)); - const textarea = view.querySelector('.chat-textarea') + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = '/ban'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 }); - expect(view.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled(); + expect(view.model.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled(); 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."); @@ -3437,9 +3438,9 @@ describe("Groupchats", function () { // again via triggering Event doesn't work for some weird // reason. textarea.value = '/ban annoyingGuy You\'re annoying'; - view.onFormSubmitted(new Event('submit')); + bottom_panel.onFormSubmitted(new Event('submit')); - expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2); + expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2); expect(view.model.setAffiliation).toHaveBeenCalled(); // Check that the member list now gets updated expect(Strophe.serialize(sent_IQ)).toBe( @@ -3483,7 +3484,7 @@ describe("Groupchats", function () { _converse.connection._dataRecv(mock.createRequest(presence)); textarea.value = '/ban joe22'; - view.onFormSubmitted(new Event('submit')); + bottom_panel.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"); done(); @@ -3502,7 +3503,7 @@ describe("Groupchats", function () { await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); const view = _converse.api.chatviews.get(muc_jid); spyOn(view.model, 'setRole').and.callThrough(); - spyOn(view, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); + spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); let presence = $pres({ 'from': 'lounge@montague.lit/annoying guy', @@ -3517,14 +3518,15 @@ describe("Groupchats", function () { }); _converse.connection._dataRecv(mock.createRequest(presence)); - const textarea = view.querySelector('.chat-textarea') + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = '/kick'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 }); - expect(view.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled(); + expect(view.model.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled(); 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(); @@ -3533,9 +3535,9 @@ describe("Groupchats", function () { // again via triggering Event doesn't work for some weird // reason. textarea.value = '/kick @annoying guy You\'re annoying'; - view.onFormSubmitted(new Event('submit')); + bottom_panel.onFormSubmitted(new Event('submit')); - expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2); + expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2); expect(view.model.setRole).toHaveBeenCalled(); expect(Strophe.serialize(sent_IQ)).toBe( ``+ @@ -3591,7 +3593,7 @@ describe("Groupchats", function () { IQ_id = sendIQ.bind(this)(iq, callback, errback); }); spyOn(view.model, 'setRole').and.callThrough(); - spyOn(view, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); + spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); // New user enters the groupchat /* view.querySelector('.chat-content__notifications').textContent.trim() === "romeo and trustworthyguy have entered the groupchat"); - const textarea = view.querySelector('.chat-textarea') + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = '/op'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 }); - expect(view.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled(); + expect(view.model.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled(); 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."); @@ -3636,9 +3639,9 @@ describe("Groupchats", function () { // again via triggering Event doesn't work for some weird // reason. textarea.value = '/op trustworthyguy You\'re trustworthy'; - view.onFormSubmitted(new Event('submit')); + bottom_panel.onFormSubmitted(new Event('submit')); - expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2); + expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2); expect(view.model.setRole).toHaveBeenCalled(); expect(Strophe.serialize(sent_IQ)).toBe( ``+ @@ -3680,9 +3683,9 @@ describe("Groupchats", function () { // again via triggering Event doesn't work for some weird // reason. textarea.value = '/deop trustworthyguy Perhaps not'; - view.onFormSubmitted(new Event('submit')); + bottom_panel.onFormSubmitted(new Event('submit')); - expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3); + expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3); expect(view.model.setRole).toHaveBeenCalled(); expect(Strophe.serialize(sent_IQ)).toBe( ``+ @@ -3730,7 +3733,7 @@ describe("Groupchats", function () { IQ_id = sendIQ.bind(this)(iq, callback, errback); }); spyOn(view.model, 'setRole').and.callThrough(); - spyOn(view, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); + spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); // New user enters the groupchat /* view.querySelector('.chat-content__notifications').textContent.trim() === "romeo and annoyingGuy have entered the groupchat"); - const textarea = view.querySelector('.chat-textarea') + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = '/mute'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 }); - expect(view.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled(); + expect(view.model.validateRoleOrAffiliationChangeArgs).toHaveBeenCalled(); 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(); @@ -3774,9 +3778,9 @@ describe("Groupchats", function () { // again via triggering Event doesn't work for some weird // reason. textarea.value = '/mute annoyingGuy You\'re annoying'; - view.onFormSubmitted(new Event('submit')); + bottom_panel.onFormSubmitted(new Event('submit')); - expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2); + expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2); expect(view.model.setRole).toHaveBeenCalled(); expect(Strophe.serialize(sent_IQ)).toBe( ``+ @@ -3815,9 +3819,9 @@ describe("Groupchats", function () { // again via triggering Event doesn't work for some weird // reason. textarea.value = '/voice annoyingGuy Now you can talk again'; - view.onFormSubmitted(new Event('submit')); + bottom_panel.onFormSubmitted(new Event('submit')); - expect(view.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3); + expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3); expect(view.model.setRole).toHaveBeenCalled(); expect(Strophe.serialize(sent_IQ)).toBe( ``+ @@ -3861,9 +3865,10 @@ describe("Groupchats", function () { await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); let view = _converse.api.chatviews.get(muc_jid); spyOn(_converse.api, 'confirm').and.callThrough(); - let textarea = view.querySelector('.chat-textarea'); + let textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = '/destroy'; - view.onFormSubmitted(new Event('submit')); + let bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onFormSubmitted(new Event('submit')); let modal = await u.waitUntil(() => document.querySelector('.modal-dialog')); await u.waitUntil(() => u.isVisible(modal)); @@ -3899,8 +3904,8 @@ describe("Groupchats", function () { 'from': view.model.get('jid'), 'to': _converse.connection.jid }); - spyOn(_converse.api, "trigger").and.callThrough(); 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); @@ -3911,9 +3916,10 @@ describe("Groupchats", function () { sent_IQs = _converse.connection.IQ_stanzas; await mock.openAndEnterChatRoom(_converse, new_muc_jid, 'romeo'); view = _converse.api.chatviews.get(new_muc_jid); - textarea = view.querySelector('.chat-textarea'); + textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = '/destroy'; - view.onFormSubmitted(new Event('submit')); + bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onFormSubmitted(new Event('submit')); modal = await u.waitUntil(() => document.querySelector('.modal-dialog')); await u.waitUntil(() => u.isVisible(modal)); @@ -5194,9 +5200,10 @@ describe("Groupchats", function () { const muc_jid = 'trollbox@montague.lit'; await mock.openAndEnterChatRoom(_converse, muc_jid, 'troll'); const view = _converse.api.chatviews.get(muc_jid); - const textarea = view.querySelector('.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea')); textarea.value = 'Hello world'; - view.onFormSubmitted(new Event('submit')); + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onFormSubmitted(new Event('submit')); await new Promise(resolve => view.model.messages.once('rendered', resolve)); let stanza = u.toStanza(` @@ -5213,7 +5220,7 @@ describe("Groupchats", function () { "Your message was not delivered because you weren't allowed to send it."); textarea.value = 'Hello again'; - view.onFormSubmitted(new Event('submit')); + bottom_panel.onFormSubmitted(new Event('submit')); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); stanza = u.toStanza(` @@ -5248,7 +5255,7 @@ describe("Groupchats", function () { const muc_jid = 'trollbox@montague.lit'; await mock.openAndEnterChatRoom(_converse, muc_jid, 'troll', features); const view = _converse.api.chatviews.get(muc_jid); - expect(_.isNull(view.querySelector('.chat-textarea'))).toBe(false); + await u.waitUntil(() => view.querySelector('.chat-textarea')); let stanza = u.toStanza(` 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); - expect(view.querySelector('.chat-textarea')).toBe(null); + 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"); @@ -5299,10 +5306,7 @@ describe("Groupchats", function () { `); _converse.connection._dataRecv(mock.createRequest(stanza)); await u.waitUntil(() => view.querySelector('.muc-bottom-panel') === null); - - textarea = view.querySelector('.chat-textarea'); 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"); done(); diff --git a/spec/muc_messages.js b/spec/muc_messages.js index 3d80890ac..345b05862 100644 --- a/spec/muc_messages.js +++ b/spec/muc_messages.js @@ -17,7 +17,7 @@ describe("A Groupchat Message", function () { const muc_jid = 'lounge@montague.lit'; await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); const view = _converse.api.chatviews.get(muc_jid); - const textarea = view.querySelector('textarea.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = 'hello world' const enter_event = { 'target': textarea, @@ -25,7 +25,8 @@ describe("A Groupchat Message", function () { 'stopPropagation': function stopPropagation () {}, 'keyCode': 13 // Enter } - view.onKeyDown(enter_event); + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown(enter_event); await new Promise(resolve => view.model.messages.once('rendered', resolve)); const msg = view.model.messages.at(0); @@ -510,9 +511,10 @@ describe("A Groupchat Message", function () { const muc_jid = 'lounge@montague.lit'; await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); const view = _converse.api.chatviews.get(muc_jid); - const textarea = view.querySelector('textarea.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = 'But soft, what light through yonder airlock breaks?'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -584,9 +586,10 @@ describe("A Groupchat Message", function () { const muc_jid = 'lounge@montague.lit'; await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); const view = _converse.api.chatviews.get(muc_jid); - const textarea = view.querySelector('textarea.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = 'But soft, what light through yonder airlock breaks?'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter diff --git a/spec/omemo.js b/spec/omemo.js index 5d2f232ad..6a89b7e14 100644 --- a/spec/omemo.js +++ b/spec/omemo.js @@ -112,7 +112,8 @@ describe("The OMEMO module", function() { const textarea = view.querySelector('.chat-textarea'); textarea.value = 'This message will be encrypted'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-chat-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -238,7 +239,7 @@ describe("The OMEMO module", function() { const view = _converse.chatboxviews.get('lounge@montague.lit'); await u.waitUntil(() => initializedOMEMO(_converse)); - const toolbar = view.querySelector('.chat-toolbar'); + const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar')); const el = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo')); el.click(); expect(view.model.get('omemo_active')).toBe(true); @@ -293,7 +294,8 @@ describe("The OMEMO module", function() { const textarea = view.querySelector('.chat-textarea'); textarea.value = 'This message will be encrypted'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -457,7 +459,8 @@ describe("The OMEMO module", function() { const textarea = view.querySelector('.chat-textarea'); textarea.value = 'This is an encrypted message from this device'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-chat-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -504,15 +507,16 @@ describe("The OMEMO module", function() { }).tree(); _converse.connection._dataRecv(mock.createRequest(stanza)); - const toolbar = view.querySelector('.chat-toolbar'); + const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar')); const toggle = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo')); toggle.click(); expect(view.model.get('omemo_active')).toBe(true); expect(view.model.get('omemo_supported')).toBe(true); - const textarea = view.querySelector('.chat-textarea'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = 'This message will be encrypted'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -1229,7 +1233,8 @@ describe("The OMEMO module", function() { const textarea = view.querySelector('.chat-textarea'); textarea.value = 'This message will be sent encrypted'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-chat-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 @@ -1274,7 +1279,7 @@ describe("The OMEMO module", function() { const view = _converse.chatboxviews.get('lounge@montague.lit'); await u.waitUntil(() => initializedOMEMO(_converse)); - const toolbar = view.querySelector('.chat-toolbar'); + const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar')); let toggle = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo')); expect(view.model.get('omemo_active')).toBe(undefined); expect(view.model.get('omemo_supported')).toBe(true); diff --git a/spec/receipts.js b/spec/receipts.js index 7b1a64afe..a4b5a20ba 100644 --- a/spec/receipts.js +++ b/spec/receipts.js @@ -110,7 +110,8 @@ describe("A delivery receipt", function () { const view = _converse.chatboxviews.get(contact_jid); const textarea = view.querySelector('textarea.chat-textarea'); textarea.value = 'But soft, what light through yonder airlock breaks?'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-chat-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -131,7 +132,7 @@ describe("A delivery receipt", function () { // Also handle receipts with type 'chat'. See #1353 spyOn(_converse, 'handleMessageStanza').and.callThrough(); textarea.value = 'Another message'; - view.onKeyDown({ + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter diff --git a/spec/room_registration.js b/spec/room_registration.js index 01087126a..fc24108b3 100644 --- a/spec/room_registration.js +++ b/spec/room_registration.js @@ -17,9 +17,10 @@ describe("Chatrooms", function () { const muc_jid = 'coven@chat.shakespeare.lit'; await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo') const view = _converse.chatboxviews.get(muc_jid); - const textarea = view.querySelector('.chat-textarea') + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = '/register'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-muc-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 diff --git a/spec/spoilers.js b/spec/spoilers.js index e81ac0197..2b9b8f0a1 100644 --- a/spec/spoilers.js +++ b/spec/spoilers.js @@ -112,7 +112,8 @@ describe("A spoiler message", function () { const textarea = view.querySelector('.chat-textarea'); textarea.value = 'This is the spoiler'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-chat-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 @@ -193,7 +194,8 @@ describe("A spoiler message", function () { const hint_input = view.querySelector('.spoiler-hint'); hint_input.value = 'This is the hint'; - view.onKeyDown({ + const bottom_panel = view.querySelector('converse-chat-bottom-panel'); + bottom_panel.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 diff --git a/src/headless/plugins/muc/muc.js b/src/headless/plugins/muc/muc.js index 8ce746c6c..d64a6117c 100644 --- a/src/headless/plugins/muc/muc.js +++ b/src/headless/plugins/muc/muc.js @@ -11,6 +11,11 @@ import { isArchived } from '@converse/headless/shared/parsers'; import { parseMemberListIQ, parseMUCMessage, parseMUCPresence } from './parsers.js'; import { sendMarker } from '@converse/headless/shared/actions'; +const OWNER_COMMANDS = ['owner']; +const ADMIN_COMMANDS = ['admin', 'ban', 'deop', 'destroy', 'member', 'op', 'revoke']; +const MODERATOR_COMMANDS = ['kick', 'mute', 'voice', 'modtools']; +const VISITOR_COMMANDS = ['nick']; + const ACTION_INFO_CODES = ['301', '303', '333', '307', '321', '322']; const MUCSession = Model.extend({ @@ -1213,6 +1218,127 @@ const ChatRoomMixin = { return api.sendIQ(iq); }, + onCommandError (err) { + const { __ } = _converse; + log.fatal(err); + const message = + __('Sorry, an error happened while running the command.') + + ' ' + + __("Check your browser's developer console for details."); + this.createMessage({ message, 'type': 'error' }); + }, + + getNickOrJIDFromCommandArgs (args) { + const { __ } = _converse; + if (u.isValidJID(args.trim())) { + return args.trim(); + } + if (!args.startsWith('@')) { + args = '@' + args; + } + const [text, references] = this.parseTextForReferences(args); // eslint-disable-line no-unused-vars + if (!references.length) { + const message = __("Error: couldn't find a groupchat participant based on your arguments"); + this.createMessage({ message, 'type': 'error' }); + return; + } + if (references.length > 1) { + const message = __('Error: found multiple groupchat participant based on your arguments'); + this.createMessage({ message, 'type': 'error' }); + return; + } + const nick_or_jid = references.pop().value; + const reason = args.split(nick_or_jid, 2)[1]; + if (reason && !reason.startsWith(' ')) { + const message = __("Error: couldn't find a groupchat participant based on your arguments"); + this.createMessage({ message, 'type': 'error' }); + return; + } + return nick_or_jid; + }, + + validateRoleOrAffiliationChangeArgs (command, args) { + const { __ } = _converse; + if (!args) { + const message = __( + 'Error: the "%1$s" command takes two arguments, the user\'s nickname and optionally a reason.', + command + ); + this.createMessage({ message, 'type': 'error' }); + return false; + } + return true; + }, + + getAllowedCommands () { + let allowed_commands = ['clear', 'help', 'me', 'nick', 'register']; + if (this.config.get('changesubject') || ['owner', 'admin'].includes(this.getOwnAffiliation())) { + allowed_commands = [...allowed_commands, ...['subject', 'topic']]; + } + const occupant = this.occupants.findWhere({ 'jid': _converse.bare_jid }); + if (this.verifyAffiliations(['owner'], occupant, false)) { + allowed_commands = allowed_commands.concat(OWNER_COMMANDS).concat(ADMIN_COMMANDS); + } else if (this.verifyAffiliations(['admin'], occupant, false)) { + allowed_commands = allowed_commands.concat(ADMIN_COMMANDS); + } + if (this.verifyRoles(['moderator'], occupant, false)) { + allowed_commands = allowed_commands.concat(MODERATOR_COMMANDS).concat(VISITOR_COMMANDS); + } else if (!this.verifyRoles(['visitor', 'participant', 'moderator'], occupant, false)) { + allowed_commands = allowed_commands.concat(VISITOR_COMMANDS); + } + allowed_commands.sort(); + + if (Array.isArray(api.settings.get('muc_disable_slash_commands'))) { + return allowed_commands.filter(c => !api.settings.get('muc_disable_slash_commands').includes(c)); + } else { + return allowed_commands; + } + }, + + verifyAffiliations (affiliations, occupant, show_error = true) { + const { __ } = _converse; + if (!Array.isArray(affiliations)) { + throw new TypeError('affiliations must be an Array'); + } + if (!affiliations.length) { + return true; + } + occupant = occupant || this.occupants.findWhere({ 'jid': _converse.bare_jid }); + if (occupant) { + const a = occupant.get('affiliation'); + if (affiliations.includes(a)) { + return true; + } + } + if (show_error) { + const message = __('Forbidden: you do not have the necessary affiliation in order to do that.'); + this.createMessage({ message, 'type': 'error' }); + } + return false; + }, + + verifyRoles (roles, occupant, show_error = true) { + const { __ } = _converse; + if (!Array.isArray(roles)) { + throw new TypeError('roles must be an Array'); + } + if (!roles.length) { + return true; + } + occupant = occupant || this.occupants.findWhere({ 'jid': _converse.bare_jid }); + if (occupant) { + const role = occupant.get('role'); + if (roles.includes(role)) { + return true; + } + } + if (show_error) { + const message = __('Forbidden: you do not have the necessary role in order to do that.'); + this.createMessage({ message, 'type': 'error' }); + } + return false; + }, + /** * Returns the `role` which the current user has in this MUC * @private diff --git a/src/shared/chatview.js b/src/plugins/chatview/bottom_panel.js similarity index 50% rename from src/shared/chatview.js rename to src/plugins/chatview/bottom_panel.js index 671f558a7..16dc307e9 100644 --- a/src/shared/chatview.js +++ b/src/plugins/chatview/bottom_panel.js @@ -1,40 +1,51 @@ -import debounce from 'lodash/debounce'; -import log from '@converse/headless/log'; import tpl_chatbox_message_form from 'templates/chatbox_message_form.js'; -import tpl_spinner from 'templates/spinner.js'; import tpl_toolbar from 'templates/toolbar.js'; import { ElementView } from '@converse/skeletor/src/element.js'; import { __ } from 'i18n'; -import { _converse, api, converse } from '@converse/headless/core'; +import { _converse, api, converse } from "@converse/headless/core"; import { html, render } from 'lit-html'; -const u = converse.env.utils; +const { u } = converse.env; -export default class BaseChatView extends ElementView { +export default class ChatBottomPanel extends ElementView { - initDebounced () { - this.markScrolled = debounce(this._markScrolled, 100); - this.debouncedScrollDown = debounce(this.scrollDown, 100); + events = { + 'click .send-button': 'onFormSubmitted', + 'click .toggle-clear': 'clearMessages', } - async renderHeading () { - const tpl = await this.generateHeadingTemplate(); - render(tpl, this.querySelector('.chat-head-chatbox')); + connectedCallback () { + super.connectedCallback(); + this.model = _converse.chatboxes.get(this.getAttribute('jid')); + this.listenTo(this.model, 'change:composing_spoiler', this.renderMessageForm); + this.render(); } - renderHelpMessages () { - render( - html` - - `, - this.help_container + render () { + render(html`
`, this); + this.renderMessageForm(); + } + + renderToolbar () { + if (!api.settings.get('show_toolbar')) { + return this; + } + const options = Object.assign({ + 'model': this.model, + 'chatview': _converse.chatboxviews.get(this.getAttribute('jid')) + }, + this.model.toJSON(), + this.getToolbarOptions() ); + render(tpl_toolbar(options), this.querySelector('.chat-toolbar')); + /** + * Triggered once the _converse.ChatBoxView's toolbar has been rendered + * @event _converse#renderToolbar + * @type { _converse.ChatBoxView } + * @example _converse.api.listen.on('renderToolbar', this => { ... }); + */ + api.trigger('renderToolbar', this); + return this; } renderMessageForm () { @@ -42,6 +53,12 @@ export default class BaseChatView extends ElementView { render( tpl_chatbox_message_form( Object.assign(this.model.toJSON(), { + 'onDrop': ev => this.onDrop(ev), + 'inputChanged': ev => this.inputChanged(ev), + 'onKeyDown': ev => this.onKeyDown(ev), + 'onKeyUp': ev => this.onKeyUp(ev), + 'onPaste': ev => this.onPaste(ev), + 'onChange': ev => this.updateCharCounter(ev.target.value), 'hint_value': this.querySelector('.spoiler-hint')?.value, 'label_message': this.model.get('composing_spoiler') ? __('Hidden message') : __('Message'), 'label_spoiler_hint': __('Optional hint'), @@ -58,88 +75,9 @@ export default class BaseChatView extends ElementView { this.renderToolbar(); } - renderToolbar () { - if (!api.settings.get('show_toolbar')) { - return this; - } - const options = Object.assign( - { - 'model': this.model, - 'chatview': this - }, - this.model.toJSON(), - this.getToolbarOptions() - ); - render(tpl_toolbar(options), this.querySelector('.chat-toolbar')); - /** - * Triggered once the _converse.ChatBoxView's toolbar has been rendered - * @event _converse#renderToolbar - * @type { _converse.ChatBoxView } - * @example _converse.api.listen.on('renderToolbar', view => { ... }); - */ - api.trigger('renderToolbar', this); - return this; - } - - async getHeadingStandaloneButton (promise_or_data) { // eslint-disable-line class-methods-use-this - const data = await promise_or_data; - return html` - - `; - } - - hideNewMessagesIndicator () { - const new_msgs_indicator = this.querySelector('.new-msgs-indicator'); - if (new_msgs_indicator !== null) { - new_msgs_indicator.classList.add('hidden'); - } - } - - maybeFocus () { - api.settings.get('auto_focus') && this.focus(); - } - - focus () { - const textarea_el = this.getElementsByClassName('chat-textarea')[0]; - if (textarea_el && document.activeElement !== textarea_el) { - textarea_el.focus(); - } - return this; - } - - show () { - if (this.model.get('hidden')) { - log.debug(`Not showing chat ${this.model.get('jid')} because it's set as hidden`); - return; - } - if (u.isVisible(this)) { - this.maybeFocus(); - return; - } - this.afterShown(); - } - - emitBlurred (ev) { - if (this.contains(document.activeElement) || this.contains(ev.relatedTarget)) { - // Something else in this chatbox is still focused - return; - } - /** - * Triggered when the focus has been removed from a particular chat. - * @event _converse#chatBoxBlurred - * @type { _converse.ChatBoxView | _converse.ChatRoomView } - * @example _converse.api.listen.on('chatBoxBlurred', (view, event) => { ... }); - */ - api.trigger('chatBoxBlurred', this, ev); - } - emitFocused (ev) { - if (this.contains(ev.relatedTarget)) { + const chatview = _converse.chatboxviews.get(this.getAttribute('jid')); + if (chatview.contains(document.activeElement) || chatview.contains(ev.relatedTarget)) { // Something else in this chatbox was already focused return; } @@ -152,61 +90,52 @@ export default class BaseChatView extends ElementView { api.trigger('chatBoxFocused', this, ev); } - /** - * Scroll to the previously saved scrollTop position, or scroll - * down if it wasn't set. - */ - maintainScrollTop () { - const pos = this.model.get('scrollTop'); - if (pos) { - const msgs_container = this.querySelector('.chat-content__messages'); - msgs_container.scrollTop = pos; - } else { - this.scrollDown(); + emitBlurred (ev) { + const chatview = _converse.chatboxviews.get(this.getAttribute('jid')); + if (!chatview) { + return; } - } - - addSpinner (append = false) { - if (this.querySelector('.spinner') === null) { - const el = u.getElementFromTemplateResult(tpl_spinner()); - if (append) { - this.content.insertAdjacentElement('beforeend', el); - this.scrollDown(); - } else { - this.content.insertAdjacentElement('afterbegin', el); - } + if (chatview.contains(document.activeElement) || chatview.contains(ev.relatedTarget)) { + // Something else in this chatbox is still focused + return; } - } - - clearSpinner () { - this.content.querySelectorAll('.spinner').forEach(u.removeElement); - } - - onStatusMessageChanged (item) { - this.renderHeading(); /** - * When a contact's custom status message has changed. - * @event _converse#contactStatusMessageChanged - * @type {object} - * @property { object } contact - The chat buddy - * @property { string } message - The message text - * @example _converse.api.listen.on('contactStatusMessageChanged', obj => { ... }); + * Triggered when the focus has been removed from a particular chat. + * @event _converse#chatBoxBlurred + * @type { _converse.ChatBoxView | _converse.ChatRoomView } + * @example _converse.api.listen.on('chatBoxBlurred', (view, event) => { ... }); */ - api.trigger('contactStatusMessageChanged', { - 'contact': item.attributes, - 'message': item.get('status') - }); + api.trigger('chatBoxBlurred', this, ev); } + getToolbarOptions () { // eslint-disable-line class-methods-use-this + return {}; + } - getOwnMessages () { - return this.model.messages.filter({ 'sender': 'me' }); + inputChanged (ev) { // eslint-disable-line class-methods-use-this + const height = ev.target.scrollHeight + 'px'; + if (ev.target.style.height != height) { + ev.target.style.height = 'auto'; + ev.target.style.height = height; + } + } + + onDrop (evt) { + if (evt.dataTransfer.files.length == 0) { + // There are no files to be dropped, so this isn’t a file + // transfer operation. + return; + } + evt.preventDefault(); + this.model.sendFiles(evt.dataTransfer.files); + } + + onDragOver (ev) { // eslint-disable-line class-methods-use-this + ev.preventDefault(); } async clearMessages (ev) { - if (ev && ev.preventDefault) { - ev.preventDefault(); - } + ev?.preventDefault?.(); const result = confirm(__('Are you sure you want to clear the messages from this conversation?')); if (result === true) { await this.model.clearMessages(); @@ -214,236 +143,23 @@ export default class BaseChatView extends ElementView { return this; } - editEarlierMessage () { - let message; - let idx = this.model.messages.findLastIndex('correcting'); - if (idx >= 0) { - this.model.messages.at(idx).save('correcting', false); - while (idx > 0) { - idx -= 1; - const candidate = this.model.messages.at(idx); - if (candidate.get('editable')) { - message = candidate; - break; - } - } - } - message = - message || - this.getOwnMessages() - .reverse() - .find(m => m.get('editable')); - if (message) { - message.save('correcting', true); - } - } - - editLaterMessage () { - let message; - let idx = this.model.messages.findLastIndex('correcting'); - if (idx >= 0) { - this.model.messages.at(idx).save('correcting', false); - while (idx < this.model.messages.length - 1) { - idx += 1; - const candidate = this.model.messages.at(idx); - if (candidate.get('editable')) { - message = candidate; - break; - } - } - } - if (message) { - this.insertIntoTextArea(u.prefixMentions(message), true, true); - message.save('correcting', true); - } else { - this.insertIntoTextArea('', true, false); - } - } - - async getHeadingDropdownItem (promise_or_data) { // eslint-disable-line class-methods-use-this - const data = await promise_or_data; - return html` - ${data.i18n_text} - `; - } - - autocompleteInPicker (input, value) { - const emoji_dropdown = this.querySelector('converse-emoji-dropdown'); - const emoji_picker = this.querySelector('converse-emoji-picker'); - if (emoji_picker && emoji_dropdown) { - emoji_picker.model.set({ - 'ac_position': input.selectionStart, - 'autocompleting': value, - 'query': value - }); - emoji_dropdown.showMenu(); - return true; - } - } - - showNewMessagesIndicator () { - u.showElement(this.querySelector('.new-msgs-indicator')); - } - - onMessageAdded (message) { - if (u.isNewMessage(message)) { - if (message.get('sender') === 'me') { - // We remove the "scrolled" flag so that the chat area - // gets scrolled down. We always want to scroll down - // when the user writes a message as opposed to when a - // message is received. - this.model.set('scrolled', false); - } else if (this.model.get('scrolled', true)) { - this.showNewMessagesIndicator(); + parseMessageForCommands (text) { + const match = text.replace(/^\s*/, '').match(/^\/(.*)\s*$/); + if (match) { + if (match[1] === 'clear') { + this.clearMessages(); + return true; + } else if (match[1] === 'close') { + _converse.chatboxviews.get(this.getAttribute('jid'))?.close(); + return true; + } else if (match[1] === 'help') { + this.model.set({ 'show_help_messages': true }); + return true; } } } - onEmojiReceivedFromPicker (emoji) { - const model = this.querySelector('converse-emoji-picker').model; - const autocompleting = model.get('autocompleting'); - const ac_position = model.get('ac_position'); - this.insertIntoTextArea(emoji, autocompleting, false, ac_position); - } - - onMessageCorrecting (message) { - if (message.get('correcting')) { - this.insertIntoTextArea(u.prefixMentions(message), true, true); - } else { - const currently_correcting = this.model.messages.findWhere('correcting'); - if (currently_correcting && currently_correcting !== message) { - this.insertIntoTextArea(u.prefixMentions(message), true, true); - } else { - this.insertIntoTextArea('', true, false); - } - } - } - - /** - * Insert a particular string value into the textarea of this chat box. - * @private - * @method _converse.ChatBoxView#insertIntoTextArea - * @param {string} value - The value to be inserted. - * @param {(boolean|string)} [replace] - Whether an existing value - * should be replaced. If set to `true`, the entire textarea will - * be replaced with the new value. If set to a string, then only - * that string will be replaced *if* a position is also specified. - * @param {integer} [position] - The end index of the string to be - * replaced with the new value. - */ - insertIntoTextArea (value, replace = false, correcting = false, position) { - const textarea = this.querySelector('.chat-textarea'); - if (correcting) { - u.addClass('correcting', textarea); - } else { - u.removeClass('correcting', textarea); - } - if (replace) { - if (position && typeof replace == 'string') { - textarea.value = textarea.value.replace(new RegExp(replace, 'g'), (match, offset) => - offset == position - replace.length ? value + ' ' : match - ); - } else { - textarea.value = value; - } - } else { - let existing = textarea.value; - if (existing && existing[existing.length - 1] !== ' ') { - existing = existing + ' '; - } - textarea.value = existing + value + ' '; - } - this.updateCharCounter(textarea.value); - u.placeCaretAtEnd(textarea); - } - - /** - * Called when the chat content is scrolled up or down. - * We want to record when the user has scrolled away from - * the bottom, so that we don't automatically scroll away - * from what the user is reading when new messages are received. - * - * Don't call this method directly, instead, call `markScrolled`, - * which debounces this method by 100ms. - * @private - */ - _markScrolled (ev) { - let scrolled = true; - let scrollTop = null; - const msgs_container = this.querySelector('.chat-content__messages'); - const is_at_bottom = - msgs_container.scrollTop + msgs_container.clientHeight >= msgs_container.scrollHeight - 62; // sigh... - - if (is_at_bottom) { - scrolled = false; - this.onScrolledDown(); - } else if (msgs_container.scrollTop === 0) { - /** - * Triggered once the chat's message area has been scrolled to the top - * @event _converse#chatBoxScrolledUp - * @property { _converse.ChatBoxView | _converse.ChatRoomView } view - * @example _converse.api.listen.on('chatBoxScrolledUp', obj => { ... }); - */ - api.trigger('chatBoxScrolledUp', this); - } else { - scrollTop = ev.target.scrollTop; - } - u.safeSave(this.model, { scrolled, scrollTop }); - } - - /** - * Scrolls the chat down. - * - * This method will always scroll the chat down, regardless of - * whether the user scrolled up manually or not. - * @param { Event } [ev] - An optional event that is the cause for needing to scroll down. - */ - scrollDown (ev) { - ev?.preventDefault?.(); - ev?.stopPropagation?.(); - if (this.model.get('scrolled')) { - u.safeSave(this.model, { - 'scrolled': false, - 'scrollTop': null - }); - } - this.querySelector('.chat-content__messages').scrollDown(); - this.onScrolledDown(); - } - - onScrolledDown () { - this.hideNewMessagesIndicator(); - if (!this.model.isHidden()) { - this.model.clearUnreadMsgCounter(); - // Clear location hash if set to one of the messages in our history - const hash = window.location.hash; - hash && this.model.messages.get(hash.slice(1)) && _converse.router.history.navigate(); - } - /** - * Triggered once the chat's message area has been scrolled down to the bottom. - * @event _converse#chatBoxScrolledDown - * @type {object} - * @property { _converse.ChatBox | _converse.ChatRoom } chatbox - The chat model - * @example _converse.api.listen.on('chatBoxScrolledDown', obj => { ... }); - */ - api.trigger('chatBoxScrolledDown', { 'chatbox': this.model }); // TODO: clean up - } - - onWindowStateChanged (data) { - if (data.state === 'visible') { - if (!this.model.isHidden() && this.model.get('num_unread', 0)) { - this.model.clearUnreadMsgCounter(); - } - } else if (data.state === 'hidden') { - this.model.setChatState(_converse.INACTIVE, { 'silent': true }); - this.model.sendChatState(); - } - } - - async onFormSubmitted (ev) { - ev.preventDefault(); + async onFormSubmitted () { const textarea = this.querySelector('.chat-textarea'); const message_text = textarea.value.trim(); if ( @@ -489,7 +205,8 @@ export default class BaseChatView extends ElementView { if (api.settings.get('view_mode') === 'overlayed') { // XXX: Chrome flexbug workaround. The .chat-content area // doesn't resize when the textarea is resized to its original size. - const msgs_container = this.querySelector('.chat-content__messages'); + const chatview = _converse.chatboxviews.get(this.getAttribute('jid')); + const msgs_container = chatview.querySelector('.chat-content__messages'); msgs_container.parentElement.style.display = 'none'; } textarea.removeAttribute('disabled'); @@ -497,7 +214,8 @@ export default class BaseChatView extends ElementView { if (api.settings.get('view_mode') === 'overlayed') { // XXX: Chrome flexbug workaround. - const msgs_container = this.querySelector('.chat-content__messages'); + const chatview = _converse.chatboxviews.get(this.getAttribute('jid')); + const msgs_container = chatview.querySelector('.chat-content__messages'); msgs_container.parentElement.style.display = ''; } // Suppress events, otherwise superfluous CSN gets set @@ -506,8 +224,162 @@ export default class BaseChatView extends ElementView { textarea.focus(); } - onEnterPressed (ev) { - return this.onFormSubmitted(ev); + /** + * Insert a particular string value into the textarea of this chat box. + * @param {string} value - The value to be inserted. + * @param {(boolean|string)} [replace] - Whether an existing value + * should be replaced. If set to `true`, the entire textarea will + * be replaced with the new value. If set to a string, then only + * that string will be replaced *if* a position is also specified. + * @param {integer} [position] - The end index of the string to be + * replaced with the new value. + */ + insertIntoTextArea (value, replace = false, correcting = false, position) { + const textarea = this.querySelector('.chat-textarea'); + if (correcting) { + u.addClass('correcting', textarea); + } else { + u.removeClass('correcting', textarea); + } + if (replace) { + if (position && typeof replace == 'string') { + textarea.value = textarea.value.replace(new RegExp(replace, 'g'), (match, offset) => + offset == position - replace.length ? value + ' ' : match + ); + } else { + textarea.value = value; + } + } else { + let existing = textarea.value; + if (existing && existing[existing.length - 1] !== ' ') { + existing = existing + ' '; + } + textarea.value = existing + value + ' '; + } + const ev = document.createEvent('HTMLEvents'); + ev.initEvent('change', false, true); + textarea.dispatchEvent(ev) + u.placeCaretAtEnd(textarea); + } + + onEscapePressed (ev) { + ev.preventDefault(); + const idx = this.model.messages.findLastIndex('correcting'); + const message = idx >= 0 ? this.model.messages.at(idx) : null; + if (message) { + message.save('correcting', false); + } + this.insertIntoTextArea('', true, false); + } + + editEarlierMessage () { + let message; + let idx = this.model.messages.findLastIndex('correcting'); + if (idx >= 0) { + this.model.messages.at(idx).save('correcting', false); + while (idx > 0) { + idx -= 1; + const candidate = this.model.messages.at(idx); + if (candidate.get('editable')) { + message = candidate; + break; + } + } + } + message = + message || + this.model.messages.filter({ 'sender': 'me' }) + .reverse() + .find(m => m.get('editable')); + if (message) { + message.save('correcting', true); + } + } + + editLaterMessage () { + let message; + let idx = this.model.messages.findLastIndex('correcting'); + if (idx >= 0) { + this.model.messages.at(idx).save('correcting', false); + while (idx < this.model.messages.length - 1) { + idx += 1; + const candidate = this.model.messages.at(idx); + if (candidate.get('editable')) { + message = candidate; + break; + } + } + } + if (message) { + this.insertIntoTextArea(u.prefixMentions(message), true, true); + message.save('correcting', true); + } else { + this.insertIntoTextArea('', true, false); + } + } + + autocompleteInPicker (input, value) { + const emoji_dropdown = this.querySelector('converse-emoji-dropdown'); + const emoji_picker = this.querySelector('converse-emoji-picker'); + if (emoji_picker && emoji_dropdown) { + emoji_picker.model.set({ + 'ac_position': input.selectionStart, + 'autocompleting': value, + 'query': value + }); + emoji_dropdown.showMenu(); + return true; + } + } + + onKeyDown (ev) { + if (ev.ctrlKey) { + // When ctrl is pressed, no chars are entered into the textarea. + return; + } + if (!ev.shiftKey && !ev.altKey && !ev.metaKey) { + if (ev.keyCode === converse.keycodes.TAB) { + const value = u.getCurrentWord(ev.target, null, /(:.*?:)/g); + if (value.startsWith(':') && this.autocompleteInPicker(ev.target, value)) { + ev.preventDefault(); + ev.stopPropagation(); + } + } else if (ev.keyCode === converse.keycodes.FORWARD_SLASH) { + // Forward slash is used to run commands. Nothing to do here. + return; + } else if (ev.keyCode === converse.keycodes.ESCAPE) { + return this.onEscapePressed(ev, this); + } else if (ev.keyCode === converse.keycodes.ENTER) { + return this.onFormSubmitted(); + } else if (ev.keyCode === converse.keycodes.UP_ARROW && !ev.target.selectionEnd) { + const textarea = this.querySelector('.chat-textarea'); + if (!textarea.value || u.hasClass('correcting', textarea)) { + return this.editEarlierMessage(); + } + } else if ( + ev.keyCode === converse.keycodes.DOWN_ARROW && + ev.target.selectionEnd === ev.target.value.length && + u.hasClass('correcting', this.querySelector('.chat-textarea')) + ) { + return this.editLaterMessage(); + } + } + if ( + [ + converse.keycodes.SHIFT, + converse.keycodes.META, + converse.keycodes.META_RIGHT, + converse.keycodes.ESCAPE, + converse.keycodes.ALT + ].includes(ev.keyCode) + ) { + return; + } + if (this.model.get('chat_state') !== _converse.COMPOSING) { + // Set chat state to composing if keyCode is not a forward-slash + // (which would imply an internal command and not a message). + this.model.setChatState(_converse.COMPOSING); + } } updateCharCounter (chars) { @@ -522,4 +394,23 @@ export default class BaseChatView extends ElementView { } } } + + onKeyUp (ev) { + this.updateCharCounter(ev.target.value); + } + + onPaste (ev) { + if (ev.clipboardData.files.length !== 0) { + ev.preventDefault(); + // Workaround for quirk in at least Firefox 60.7 ESR: + // It seems that pasted files disappear from the event payload after + // the event has finished, which apparently happens during async + // processing in sendFiles(). So we copy the array here. + this.model.sendFiles(Array.from(ev.clipboardData.files)); + return; + } + this.updateCharCounter(ev.clipboardData.getData('text/plain')); + } } + +api.elements.define('converse-chat-bottom-panel', ChatBottomPanel); diff --git a/src/plugins/chatview/templates/bottom_panel.js b/src/plugins/chatview/templates/bottom_panel.js new file mode 100644 index 000000000..61ed00905 --- /dev/null +++ b/src/plugins/chatview/templates/bottom_panel.js @@ -0,0 +1,4 @@ + +
+
+
diff --git a/src/plugins/chatview/view.js b/src/plugins/chatview/view.js index c0744f149..f17f4237e 100644 --- a/src/plugins/chatview/view.js +++ b/src/plugins/chatview/view.js @@ -1,4 +1,5 @@ -import BaseChatView from 'shared/chatview.js'; +import 'plugins/chatview/bottom_panel.js'; +import BaseChatView from 'shared/chat/baseview.js'; import UserDetailsModal from 'modals/user-details.js'; import tpl_chatbox from 'templates/chatbox.js'; import tpl_chatbox_head from 'templates/chatbox_head.js'; @@ -23,12 +24,6 @@ export default class ChatView extends BaseChatView { events = { 'click .chatbox-navback': 'showControlBox', 'click .new-msgs-indicator': 'viewUnreadMessages', - 'click .send-button': 'onFormSubmitted', - 'click .toggle-clear': 'clearMessages', - 'input .chat-textarea': 'inputChanged', - 'keydown .chat-textarea': 'onKeyDown', - 'keyup .chat-textarea': 'onKeyUp', - 'paste .chat-textarea': 'onPaste' } async initialize () { @@ -39,7 +34,6 @@ export default class ChatView extends BaseChatView { this.initDebounced(); this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged); - this.listenTo(this.model, 'change:composing_spoiler', this.renderMessageForm); this.listenTo(this.model, 'change:hidden', () => !this.model.get('hidden') && this.afterShown()); this.listenTo(this.model, 'change:status', this.onStatusMessageChanged); this.listenTo(this.model, 'vcard:change', this.renderHeading); @@ -80,7 +74,6 @@ export default class ChatView extends BaseChatView { render(result, this); this.content = this.querySelector('.chat-content'); this.help_container = this.querySelector('.chat-content__help'); - this.renderMessageForm(); this.renderHeading(); return this; } @@ -105,20 +98,6 @@ export default class ChatView extends BaseChatView { api.modal.show(UserDetailsModal, { model: this.model }, ev); } - onDragOver (evt) { // eslint-disable-line class-methods-use-this - evt.preventDefault(); - } - - onDrop (evt) { - if (evt.dataTransfer.files.length == 0) { - // There are no files to be dropped, so this isn’t a file - // transfer operation. - return; - } - evt.preventDefault(); - this.model.sendFiles(evt.dataTransfer.files); - } - async generateHeadingTemplate () { const vcard = this.model?.vcard; const vcard_json = vcard ? vcard.toJSON() : {}; @@ -195,11 +174,6 @@ export default class ChatView extends BaseChatView { return _converse.api.hook('getHeadingButtons', this, buttons); } - getToolbarOptions () { // eslint-disable-line class-methods-use-this - // FIXME: can this be removed? - return {}; - } - /** * Given a message element, determine wether it should be * marked as a followup message to the previous element. @@ -248,118 +222,6 @@ export default class ChatView extends BaseChatView { } } - parseMessageForCommands (text) { - const match = text.replace(/^\s*/, '').match(/^\/(.*)\s*$/); - if (match) { - if (match[1] === 'clear') { - this.clearMessages(); - return true; - } else if (match[1] === 'close') { - this.close(); - return true; - } else if (match[1] === 'help') { - this.model.set({ 'show_help_messages': true }); - return true; - } - } - } - - onPaste (ev) { - if (ev.clipboardData.files.length !== 0) { - ev.preventDefault(); - // Workaround for quirk in at least Firefox 60.7 ESR: - // It seems that pasted files disappear from the event payload after - // the event has finished, which apparently happens during async - // processing in sendFiles(). So we copy the array here. - this.model.sendFiles(Array.from(ev.clipboardData.files)); - return; - } - this.updateCharCounter(ev.clipboardData.getData('text/plain')); - } - - /** - * Event handler for when a depressed key goes up - * @private - * @method _converse.ChatBoxView#onKeyUp - */ - onKeyUp (ev) { - this.updateCharCounter(ev.target.value); - } - - /** - * Event handler for when a key is pressed down in a chat box textarea. - * @private - * @method _converse.ChatBoxView#onKeyDown - * @param { Event } ev - */ - onKeyDown (ev) { - if (ev.ctrlKey) { - // When ctrl is pressed, no chars are entered into the textarea. - return; - } - if (!ev.shiftKey && !ev.altKey && !ev.metaKey) { - if (ev.keyCode === converse.keycodes.TAB) { - const value = u.getCurrentWord(ev.target, null, /(:.*?:)/g); - if (value.startsWith(':') && this.autocompleteInPicker(ev.target, value)) { - ev.preventDefault(); - ev.stopPropagation(); - } - } else if (ev.keyCode === converse.keycodes.FORWARD_SLASH) { - // Forward slash is used to run commands. Nothing to do here. - return; - } else if (ev.keyCode === converse.keycodes.ESCAPE) { - return this.onEscapePressed(ev); - } else if (ev.keyCode === converse.keycodes.ENTER) { - return this.onEnterPressed(ev); - } else if (ev.keyCode === converse.keycodes.UP_ARROW && !ev.target.selectionEnd) { - const textarea = this.querySelector('.chat-textarea'); - if (!textarea.value || u.hasClass('correcting', textarea)) { - return this.editEarlierMessage(); - } - } else if ( - ev.keyCode === converse.keycodes.DOWN_ARROW && - ev.target.selectionEnd === ev.target.value.length && - u.hasClass('correcting', this.querySelector('.chat-textarea')) - ) { - return this.editLaterMessage(); - } - } - if ( - [ - converse.keycodes.SHIFT, - converse.keycodes.META, - converse.keycodes.META_RIGHT, - converse.keycodes.ESCAPE, - converse.keycodes.ALT - ].includes(ev.keyCode) - ) { - return; - } - if (this.model.get('chat_state') !== _converse.COMPOSING) { - // Set chat state to composing if keyCode is not a forward-slash - // (which would imply an internal command and not a message). - this.model.setChatState(_converse.COMPOSING); - } - } - - onEscapePressed (ev) { - ev.preventDefault(); - const idx = this.model.messages.findLastIndex('correcting'); - const message = idx >= 0 ? this.model.messages.at(idx) : null; - if (message) { - message.save('correcting', false); - } - this.insertIntoTextArea('', true, false); - } - - inputChanged (ev) { // eslint-disable-line class-methods-use-this - const height = ev.target.scrollHeight + 'px'; - if (ev.target.style.height != height) { - ev.target.style.height = 'auto'; - ev.target.style.height = height; - } - } - onPresenceChanged (item) { const show = item.get('show'); const fullname = this.model.getDisplayName(); diff --git a/src/plugins/headlines-view/view.js b/src/plugins/headlines-view/view.js index 9b5b8d112..17d20b05d 100644 --- a/src/plugins/headlines-view/view.js +++ b/src/plugins/headlines-view/view.js @@ -1,4 +1,4 @@ -import BaseChatView from 'shared/chatview.js'; +import BaseChatView from 'shared/chat/baseview.js'; import tpl_chatbox from 'templates/chatbox.js'; import tpl_chat_head from './templates/chat-head.js'; import { __ } from 'i18n'; diff --git a/src/plugins/muc-views/bottom_panel.js b/src/plugins/muc-views/bottom_panel.js new file mode 100644 index 000000000..864ce2090 --- /dev/null +++ b/src/plugins/muc-views/bottom_panel.js @@ -0,0 +1,310 @@ +import BottomPanel from 'plugins/chatview/bottom_panel.js'; +import debounce from 'lodash/debounce'; +import tpl_muc_bottom_panel from './templates/muc_bottom_panel.js'; +import { $pres, Strophe } from 'strophe.js/src/strophe'; +import { __ } from 'i18n'; +import { _converse, api, converse } from "@converse/headless/core"; +import { getAutoCompleteListItem } from './utils.js'; +import { render } from 'lit-html'; + + +const COMMAND_TO_AFFILIATION = { + 'admin': 'admin', + 'ban': 'outcast', + 'member': 'member', + 'owner': 'owner', + 'revoke': 'none' +}; +const COMMAND_TO_ROLE = { + 'deop': 'participant', + 'kick': 'none', + 'mute': 'visitor', + 'op': 'moderator', + 'voice': 'participant' +}; + +const u = converse.env.utils; + + +export default class MUCBottomPanel extends BottomPanel { + + events = { + 'click .hide-occupants': 'hideOccupants', + 'click .send-button': 'onFormSubmitted', + } + + async connectedCallback () { + super.connectedCallback(); + this.debouncedRender = debounce(this.render, 100); + this.model = _converse.chatboxes.get(this.getAttribute('jid')); + this.listenTo(this.model, 'change:composing_spoiler', this.renderMessageForm); + + await this.model.initialized; + this.listenTo(this.model, 'change:hidden_occupants', this.debouncedRender); + this.listenTo(this.model.features, 'change:moderated', this.debouncedRender); + this.listenTo(this.model.occupants, 'add', this.renderIfOwnOccupant) + this.listenTo(this.model.occupants, 'change:role', this.renderIfOwnOccupant); + this.listenTo(this.model.session, 'change:connection_status', this.debouncedRender); + this.render(); + } + + render () { + const entered = this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED; + const can_edit = entered && !(this.model.features.get('moderated') && this.model.getOwnRole() === 'visitor'); + render(tpl_muc_bottom_panel({ can_edit, entered, 'model': this.model }), this); + if (entered && can_edit) { + this.renderMessageForm(); + this.initMentionAutoComplete(); + } + } + + renderIfOwnOccupant (o) { + (o.get('jid') === _converse.bare_jid) && this.debouncedRender(); + } + + getToolbarOptions () { + return Object.assign(super.getToolbarOptions(), { + 'is_groupchat': true, + 'label_hide_occupants': __('Hide the list of participants'), + 'show_occupants_toggle': _converse.visible_toolbar_buttons.toggle_occupants + }); + } + + getAutoCompleteList () { + return this.model.getAllKnownNicknames().map(nick => ({ 'label': nick, 'value': `@${nick}` })); + } + + initMentionAutoComplete () { + this.mention_auto_complete = new _converse.AutoComplete(this, { + 'auto_first': true, + 'auto_evaluate': false, + 'min_chars': api.settings.get('muc_mention_autocomplete_min_chars'), + 'match_current_word': true, + 'list': () => this.getAutoCompleteList(), + 'filter': + api.settings.get('muc_mention_autocomplete_filter') == 'contains' + ? _converse.FILTER_CONTAINS + : _converse.FILTER_STARTSWITH, + 'ac_triggers': ['Tab', '@'], + 'include_triggers': [], + 'item': getAutoCompleteListItem + }); + this.mention_auto_complete.on('suggestion-box-selectcomplete', () => (this.auto_completing = false)); + } + + /** + * Hide the right sidebar containing the chat occupants. + * @private + * @method _converse.ChatRoomView#hideOccupants + */ + hideOccupants (ev) { + ev?.preventDefault?.(); + ev?.stopPropagation?.(); + this.model.save({ 'hidden_occupants': true }); + _converse.chatboxviews.get(this.getAttribute('jid'))?.scrollDown(); + } + + onKeyDown (ev) { + if (this.mention_auto_complete.onKeyDown(ev)) { + return; + } + super.onKeyDown(ev); + } + + onKeyUp (ev) { + this.mention_auto_complete.evaluate(ev); + super.onKeyUp(ev); + } + + setRole (command, args, required_affiliations = [], required_roles = []) { + /* Check that a command to change a groupchat user's role or + * affiliation has anough arguments. + */ + const role = COMMAND_TO_ROLE[command]; + if (!role) { + throw Error(`ChatRoomView#setRole called with invalid command: ${command}`); + } + if (!this.model.verifyAffiliations(required_affiliations) || !this.model.verifyRoles(required_roles)) { + return false; + } + if (!this.model.validateRoleOrAffiliationChangeArgs(command, args)) { + return false; + } + const nick_or_jid = this.model.getNickOrJIDFromCommandArgs(args); + if (!nick_or_jid) { + return false; + } + const reason = args.split(nick_or_jid, 2)[1].trim(); + // We're guaranteed to have an occupant due to getNickOrJIDFromCommandArgs + const occupant = this.model.getOccupant(nick_or_jid); + this.model.setRole(occupant, role, reason, undefined, this.model.onCommandError.bind(this)); + return true; + } + + setAffiliation (command, args, required_affiliations) { + const affiliation = COMMAND_TO_AFFILIATION[command]; + if (!affiliation) { + throw Error(`ChatRoomView#setAffiliation called with invalid command: ${command}`); + } + if (!this.model.verifyAffiliations(required_affiliations)) { + return false; + } + if (!this.model.validateRoleOrAffiliationChangeArgs(command, args)) { + return false; + } + const nick_or_jid = this.model.getNickOrJIDFromCommandArgs(args); + if (!nick_or_jid) { + return false; + } + + let jid; + const reason = args.split(nick_or_jid, 2)[1].trim(); + const occupant = this.model.getOccupant(nick_or_jid); + if (occupant) { + jid = occupant.get('jid'); + } else { + if (u.isValidJID(nick_or_jid)) { + jid = nick_or_jid; + } else { + const message = __( + "Couldn't find a participant with that nickname. " + 'They might have left the groupchat.' + ); + this.model.createMessage({ message, 'type': 'error' }); + return; + } + } + const attrs = { jid, reason }; + if (occupant && api.settings.get('auto_register_muc_nickname')) { + attrs['nick'] = occupant.get('nick'); + } + this.model + .setAffiliation(affiliation, [attrs]) + .then(() => this.model.occupants.fetchMembers()) + .catch(err => this.model.onCommandError(err)); + } + + + parseMessageForCommands (text) { + if ( + api.settings.get('muc_disable_slash_commands') && + !Array.isArray(api.settings.get('muc_disable_slash_commands')) + ) { + return super.parseMessageForCommands(text); + } + text = text.replace(/^\s*/, ''); + const command = (text.match(/^\/([a-zA-Z]*) ?/) || ['']).pop().toLowerCase(); + if (!command) { + return false; + } + const args = text.slice(('/' + command).length + 1).trim(); + if (!this.model.getAllowedCommands().includes(command)) { + return false; + } + + switch (command) { + case 'admin': { + this.setAffiliation(command, args, ['owner']); + break; + } + case 'ban': { + this.setAffiliation(command, args, ['admin', 'owner']); + break; + } + case 'modtools': { + const chatview = _converse.chatboxviews.get(this.getAttribute('jid')); + chatview.showModeratorToolsModal(args); + break; + } + case 'deop': { + // FIXME: /deop only applies to setting a moderators + // role to "participant" (which only admin/owner can + // do). Moderators can however set non-moderator's role + // to participant (e.g. visitor => participant). + // Currently we don't distinguish between these two + // cases. + this.setRole(command, args, ['admin', 'owner']); + break; + } + case 'destroy': { + if (!this.model.verifyAffiliations(['owner'])) { + break; + } + const chatview = _converse.chatboxviews.get(this.getAttribute('jid')); + chatview.destroy().catch(e => this.model.onCommandError(e)); + break; + } + case 'help': { + this.model.set({ 'show_help_messages': true }); + break; + } + case 'kick': { + this.setRole(command, args, [], ['moderator']); + break; + } + case 'mute': { + this.setRole(command, args, [], ['moderator']); + break; + } + case 'member': { + this.setAffiliation(command, args, ['admin', 'owner']); + break; + } + case 'nick': { + if (!this.model.verifyRoles(['visitor', 'participant', 'moderator'])) { + break; + } else if (args.length === 0) { + // e.g. Your nickname is "coolguy69" + const message = __('Your nickname is "%1$s"', this.model.get('nick')); + this.model.createMessage({ message, 'type': 'error' }); + } else { + const jid = Strophe.getBareJidFromJid(this.model.get('jid')); + api.send( + $pres({ + from: _converse.connection.jid, + to: `${jid}/${args}`, + id: u.getUniqueId() + }).tree() + ); + } + break; + } + case 'owner': + this.setAffiliation(command, args, ['owner']); + break; + case 'op': { + this.setRole(command, args, ['admin', 'owner']); + break; + } + case 'register': { + if (args.length > 1) { + this.model.createMessage({ + 'message': __('Error: invalid number of arguments'), + 'type': 'error' + }); + } else { + this.model.registerNickname().then(err_msg => { + err_msg && this.model.createMessage({ 'message': err_msg, 'type': 'error' }); + }); + } + break; + } + case 'revoke': { + this.setAffiliation(command, args, ['admin', 'owner']); + break; + } + case 'topic': + case 'subject': + this.model.setSubject(args); + break; + case 'voice': { + this.setRole(command, args, [], ['moderator']); + break; + } + default: + return super.parseMessageForCommands(text); + } + return true; + } +} + +api.elements.define('converse-muc-bottom-panel', MUCBottomPanel); diff --git a/src/plugins/muc-views/muc.js b/src/plugins/muc-views/muc.js index f2b4a32f8..29f5a0610 100644 --- a/src/plugins/muc-views/muc.js +++ b/src/plugins/muc-views/muc.js @@ -1,19 +1,18 @@ +import './bottom_panel.js'; import './config-form.js'; import './password-form.js'; import 'shared/autocomplete/index.js'; -import BaseChatView from 'shared/chatview.js'; +import BaseChatView from 'shared/chat/baseview.js'; import MUCInviteModal from 'modals/muc-invite.js'; import ModeratorToolsModal from 'modals/moderator-tools.js'; import RoomDetailsModal from 'modals/muc-details.js'; import log from '@converse/headless/log'; import tpl_muc from './templates/muc.js'; import tpl_muc_head from './templates/muc_head.js'; -import tpl_muc_bottom_panel from './templates/muc_bottom_panel.js'; import tpl_muc_destroyed from './templates/muc_destroyed.js'; import tpl_muc_disconnect from './templates/muc_disconnect.js'; import tpl_muc_nickname_form from './templates/muc_nickname_form.js'; import tpl_spinner from 'templates/spinner.js'; -import { $pres, Strophe } from 'strophe.js/src/strophe'; import { Model } from '@converse/skeletor/src/model.js'; import { __ } from 'i18n'; import { _converse, api, converse } from '@converse/headless/core'; @@ -23,26 +22,6 @@ import { render } from 'lit-html'; const { sizzle } = converse.env; const u = converse.env.utils; -const OWNER_COMMANDS = ['owner']; -const ADMIN_COMMANDS = ['admin', 'ban', 'deop', 'destroy', 'member', 'op', 'revoke']; -const MODERATOR_COMMANDS = ['kick', 'mute', 'voice', 'modtools']; -const VISITOR_COMMANDS = ['nick']; - -const COMMAND_TO_ROLE = { - 'deop': 'participant', - 'kick': 'none', - 'mute': 'visitor', - 'op': 'moderator', - 'voice': 'participant' -}; -const COMMAND_TO_AFFILIATION = { - 'admin': 'admin', - 'ban': 'outcast', - 'member': 'member', - 'owner': 'owner', - 'revoke': 'none' -}; - /** * Mixin which turns a ChatBoxView into a ChatRoomView * @mixin @@ -62,14 +41,7 @@ export default class MUCView extends BaseChatView { 'click .occupant-nick': function (ev) { this.insertIntoTextArea(ev.target.textContent); }, - 'click .send-button': 'onFormSubmitted', - 'dragover .chat-textarea': 'onDragOver', - 'drop .chat-textarea': 'onDrop', - 'input .chat-textarea': 'inputChanged', - 'keydown .chat-textarea': 'onKeyDown', - 'keyup .chat-textarea': 'onKeyUp', 'mousedown .dragresize-occupants-left': 'onStartResizeOccupants', - 'paste .chat-textarea': 'onPaste', 'submit .muc-nickname-form': 'submitNickname' } @@ -88,7 +60,6 @@ export default class MUCView extends BaseChatView { this.listenTo(this.model, 'change:minimized', () => this.afterShown()); this.listenTo(this.model, 'configurationNeeded', this.getAndRenderConfigurationForm); this.listenTo(this.model, 'show', this.show); - this.listenTo(this.model.features, 'change:moderated', this.renderBottomPanel); this.listenTo(this.model.features, 'change:open', this.renderHeading); this.listenTo(this.model.messages, 'change:correcting', this.onMessageCorrecting); this.listenTo(this.model.session, 'change:connection_status', this.onConnectionStatusChanged); @@ -106,7 +77,6 @@ export default class MUCView extends BaseChatView { this.model.occupants.forEach(o => this.onOccupantAdded(o)); this.listenTo(this.model.occupants, 'add', this.onOccupantAdded); this.listenTo(this.model.occupants, 'change:affiliation', this.onOccupantAffiliationChanged); - this.listenTo(this.model.occupants, 'change:role', this.onOccupantRoleChanged); this.listenTo(this.model.occupants, 'change:show', this.showJoinOrLeaveNotification); this.listenTo(this.model.occupants, 'remove', this.onOccupantRemoved); @@ -147,7 +117,6 @@ export default class MUCView extends BaseChatView { this.content = this.querySelector('.chat-content'); this.help_container = this.querySelector('.chat-content__help'); - this.renderBottomPanel(); if ( !api.settings.get('muc_show_logs_before_join') && this.model.session.get('connection_status') !== converse.ROOMSTATUS.ENTERED @@ -187,7 +156,7 @@ export default class MUCView extends BaseChatView { `/voice: ${__('Allow muted user to post messages')}` ] .filter(line => disabled_commands.every(c => !line.startsWith(c + '<', 9))) - .filter(line => this.getAllowedCommands().some(c => line.startsWith(c + '<', 9))); + .filter(line => this.model.getAllowedCommands().some(c => line.startsWith(c + '<', 9))); } /** @@ -201,17 +170,6 @@ export default class MUCView extends BaseChatView { render(tpl, this.querySelector('.chat-head-chatroom')); } - renderBottomPanel () { - const container = this.querySelector('.bottom-panel'); - const entered = this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED; - const can_edit = entered && !(this.model.features.get('moderated') && this.model.getOwnRole() === 'visitor'); - render(tpl_muc_bottom_panel({ can_edit, entered }), container); - if (entered && can_edit) { - this.renderMessageForm(); - this.initMentionAutoComplete(); - } - } - onStartResizeOccupants (ev) { this.resizing = true; this.addEventListener('mousemove', this.onMouseMove); @@ -282,64 +240,6 @@ export default class MUCView extends BaseChatView { return occupants_width; } - getAutoCompleteList () { - return this.model.getAllKnownNicknames().map(nick => ({ 'label': nick, 'value': `@${nick}` })); - } - - getAutoCompleteListItem (text, input) { // eslint-disable-line class-methods-use-this - input = input.trim(); - const element = document.createElement('li'); - element.setAttribute('aria-selected', 'false'); - - if (api.settings.get('muc_mention_autocomplete_show_avatar')) { - const img = document.createElement('img'); - let dataUri = 'data:' + _converse.DEFAULT_IMAGE_TYPE + ';base64,' + _converse.DEFAULT_IMAGE; - - if (_converse.vcards) { - const vcard = _converse.vcards.findWhere({ 'nickname': text }); - if (vcard) dataUri = 'data:' + vcard.get('image_type') + ';base64,' + vcard.get('image'); - } - - img.setAttribute('src', dataUri); - img.setAttribute('width', '22'); - img.setAttribute('class', 'avatar avatar-autocomplete'); - element.appendChild(img); - } - - const regex = new RegExp('(' + input + ')', 'ig'); - const parts = input ? text.split(regex) : [text]; - - parts.forEach(txt => { - if (input && txt.match(regex)) { - const match = document.createElement('mark'); - match.textContent = txt; - element.appendChild(match); - } else { - element.appendChild(document.createTextNode(txt)); - } - }); - - return element; - } - - initMentionAutoComplete () { - this.mention_auto_complete = new _converse.AutoComplete(this, { - 'auto_first': true, - 'auto_evaluate': false, - 'min_chars': api.settings.get('muc_mention_autocomplete_min_chars'), - 'match_current_word': true, - 'list': () => this.getAutoCompleteList(), - 'filter': - api.settings.get('muc_mention_autocomplete_filter') == 'contains' - ? _converse.FILTER_CONTAINS - : _converse.FILTER_STARTSWITH, - 'ac_triggers': ['Tab', '@'], - 'include_triggers': [], - 'item': this.getAutoCompleteListItem - }); - this.mention_auto_complete.on('suggestion-box-selectcomplete', () => (this.auto_completing = false)); - } - /** * Get the nickname value from the form and then join the groupchat with it. * @private @@ -352,20 +252,8 @@ export default class MUCView extends BaseChatView { nick && this.model.join(nick); } - onKeyDown (ev) { - if (this.mention_auto_complete.onKeyDown(ev)) { - return; - } - return _converse.ChatBoxView.prototype.onKeyDown.call(this, ev); - } - - onKeyUp (ev) { - this.mention_auto_complete.evaluate(ev); - return _converse.ChatBoxView.prototype.onKeyUp.call(this, ev); - } - showModeratorToolsModal (affiliation) { - if (!this.verifyRoles(['moderator'])) { + if (!this.model.verifyRoles(['moderator'])) { return; } let modal = api.modal.get(ModeratorToolsModal.id); @@ -408,12 +296,6 @@ export default class MUCView extends BaseChatView { } } - onOccupantRoleChanged (occupant) { - if (occupant.get('jid') === _converse.bare_jid) { - this.renderBottomPanel(); - } - } - /** * Returns a list of objects which represent buttons for the groupchat header. * @emits _converse#getHeadingButtons @@ -469,7 +351,7 @@ export default class MUCView extends BaseChatView { const conn_status = this.model.session.get('connection_status'); if (conn_status === converse.ROOMSTATUS.ENTERED) { - const allowed_commands = this.getAllowedCommands(); + const allowed_commands = this.model.getAllowedCommands(); if (allowed_commands.includes('modtools')) { buttons.push({ 'i18n_text': __('Moderate'), @@ -562,7 +444,6 @@ export default class MUCView extends BaseChatView { } else if (conn_status === converse.ROOMSTATUS.CONNECTING) { this.showSpinner(); } else if (conn_status === converse.ROOMSTATUS.ENTERED) { - this.renderBottomPanel(); this.hideSpinner(); this.maybeFocus(); } else if (conn_status === converse.ROOMSTATUS.DISCONNECTED) { @@ -572,14 +453,6 @@ export default class MUCView extends BaseChatView { } } - getToolbarOptions () { - return Object.assign(_converse.ChatBoxView.prototype.getToolbarOptions.apply(this, arguments), { - 'is_groupchat': true, - 'label_hide_occupants': __('Hide the list of participants'), - 'show_occupants_toggle': _converse.visible_toolbar_buttons.toggle_occupants - }); - } - /** * Closes this chat box, which implies leaving the groupchat as well. * @private @@ -606,193 +479,10 @@ export default class MUCView extends BaseChatView { this.scrollDown(); } - verifyRoles (roles, occupant, show_error = true) { - if (!Array.isArray(roles)) { - throw new TypeError('roles must be an Array'); - } - if (!roles.length) { - return true; - } - occupant = occupant || this.model.occupants.findWhere({ 'jid': _converse.bare_jid }); - if (occupant) { - const role = occupant.get('role'); - if (roles.includes(role)) { - return true; - } - } - if (show_error) { - const message = __('Forbidden: you do not have the necessary role in order to do that.'); - this.model.createMessage({ message, 'type': 'error' }); - } - return false; - } - - verifyAffiliations (affiliations, occupant, show_error = true) { - if (!Array.isArray(affiliations)) { - throw new TypeError('affiliations must be an Array'); - } - if (!affiliations.length) { - return true; - } - occupant = occupant || this.model.occupants.findWhere({ 'jid': _converse.bare_jid }); - if (occupant) { - const a = occupant.get('affiliation'); - if (affiliations.includes(a)) { - return true; - } - } - if (show_error) { - const message = __('Forbidden: you do not have the necessary affiliation in order to do that.'); - this.model.createMessage({ message, 'type': 'error' }); - } - return false; - } - - validateRoleOrAffiliationChangeArgs (command, args) { - if (!args) { - const message = __( - 'Error: the "%1$s" command takes two arguments, the user\'s nickname and optionally a reason.', - command - ); - this.model.createMessage({ message, 'type': 'error' }); - return false; - } - return true; - } - - getNickOrJIDFromCommandArgs (args) { - if (u.isValidJID(args.trim())) { - return args.trim(); - } - if (!args.startsWith('@')) { - args = '@' + args; - } - const [text, references] = this.model.parseTextForReferences(args); // eslint-disable-line no-unused-vars - if (!references.length) { - const message = __("Error: couldn't find a groupchat participant based on your arguments"); - this.model.createMessage({ message, 'type': 'error' }); - return; - } - if (references.length > 1) { - const message = __('Error: found multiple groupchat participant based on your arguments'); - this.model.createMessage({ message, 'type': 'error' }); - return; - } - const nick_or_jid = references.pop().value; - const reason = args.split(nick_or_jid, 2)[1]; - if (reason && !reason.startsWith(' ')) { - const message = __("Error: couldn't find a groupchat participant based on your arguments"); - this.model.createMessage({ message, 'type': 'error' }); - return; - } - return nick_or_jid; - } - - setAffiliation (command, args, required_affiliations) { - const affiliation = COMMAND_TO_AFFILIATION[command]; - if (!affiliation) { - throw Error(`ChatRoomView#setAffiliation called with invalid command: ${command}`); - } - if (!this.verifyAffiliations(required_affiliations)) { - return false; - } - if (!this.validateRoleOrAffiliationChangeArgs(command, args)) { - return false; - } - const nick_or_jid = this.getNickOrJIDFromCommandArgs(args); - if (!nick_or_jid) { - return false; - } - - let jid; - const reason = args.split(nick_or_jid, 2)[1].trim(); - const occupant = this.model.getOccupant(nick_or_jid); - if (occupant) { - jid = occupant.get('jid'); - } else { - if (u.isValidJID(nick_or_jid)) { - jid = nick_or_jid; - } else { - const message = __( - "Couldn't find a participant with that nickname. " + 'They might have left the groupchat.' - ); - this.model.createMessage({ message, 'type': 'error' }); - return; - } - } - const attrs = { jid, reason }; - if (occupant && api.settings.get('auto_register_muc_nickname')) { - attrs['nick'] = occupant.get('nick'); - } - this.model - .setAffiliation(affiliation, [attrs]) - .then(() => this.model.occupants.fetchMembers()) - .catch(err => this.onCommandError(err)); - } - getReason (args) { // eslint-disable-line class-methods-use-this return args.includes(',') ? args.slice(args.indexOf(',') + 1).trim() : null; } - setRole (command, args, required_affiliations = [], required_roles = []) { - /* Check that a command to change a groupchat user's role or - * affiliation has anough arguments. - */ - const role = COMMAND_TO_ROLE[command]; - if (!role) { - throw Error(`ChatRoomView#setRole called with invalid command: ${command}`); - } - if (!this.verifyAffiliations(required_affiliations) || !this.verifyRoles(required_roles)) { - return false; - } - if (!this.validateRoleOrAffiliationChangeArgs(command, args)) { - return false; - } - const nick_or_jid = this.getNickOrJIDFromCommandArgs(args); - if (!nick_or_jid) { - return false; - } - const reason = args.split(nick_or_jid, 2)[1].trim(); - // We're guaranteed to have an occupant due to getNickOrJIDFromCommandArgs - const occupant = this.model.getOccupant(nick_or_jid); - this.model.setRole(occupant, role, reason, undefined, this.onCommandError.bind(this)); - return true; - } - - onCommandError (err) { - log.fatal(err); - const message = - __('Sorry, an error happened while running the command.') + - ' ' + - __("Check your browser's developer console for details."); - this.model.createMessage({ message, 'type': 'error' }); - } - - getAllowedCommands () { - let allowed_commands = ['clear', 'help', 'me', 'nick', 'register']; - if (this.model.config.get('changesubject') || ['owner', 'admin'].includes(this.model.getOwnAffiliation())) { - allowed_commands = [...allowed_commands, ...['subject', 'topic']]; - } - const occupant = this.model.occupants.findWhere({ 'jid': _converse.bare_jid }); - if (this.verifyAffiliations(['owner'], occupant, false)) { - allowed_commands = allowed_commands.concat(OWNER_COMMANDS).concat(ADMIN_COMMANDS); - } else if (this.verifyAffiliations(['admin'], occupant, false)) { - allowed_commands = allowed_commands.concat(ADMIN_COMMANDS); - } - if (this.verifyRoles(['moderator'], occupant, false)) { - allowed_commands = allowed_commands.concat(MODERATOR_COMMANDS).concat(VISITOR_COMMANDS); - } else if (!this.verifyRoles(['visitor', 'participant', 'moderator'], occupant, false)) { - allowed_commands = allowed_commands.concat(VISITOR_COMMANDS); - } - allowed_commands.sort(); - - if (Array.isArray(api.settings.get('muc_disable_slash_commands'))) { - return allowed_commands.filter(c => !api.settings.get('muc_disable_slash_commands').includes(c)); - } else { - return allowed_commands; - } - } - async destroy () { const messages = [__('Are you sure you want to destroy this groupchat?')]; let fields = [ @@ -824,126 +514,6 @@ export default class MUCView extends BaseChatView { } } - parseMessageForCommands (text) { - if ( - api.settings.get('muc_disable_slash_commands') && - !Array.isArray(api.settings.get('muc_disable_slash_commands')) - ) { - return _converse.ChatBoxView.prototype.parseMessageForCommands.apply(this, arguments); - } - text = text.replace(/^\s*/, ''); - const command = (text.match(/^\/([a-zA-Z]*) ?/) || ['']).pop().toLowerCase(); - if (!command) { - return false; - } - const args = text.slice(('/' + command).length + 1).trim(); - if (!this.getAllowedCommands().includes(command)) { - return false; - } - - switch (command) { - case 'admin': { - this.setAffiliation(command, args, ['owner']); - break; - } - case 'ban': { - this.setAffiliation(command, args, ['admin', 'owner']); - break; - } - case 'modtools': { - this.showModeratorToolsModal(args); - break; - } - case 'deop': { - // FIXME: /deop only applies to setting a moderators - // role to "participant" (which only admin/owner can - // do). Moderators can however set non-moderator's role - // to participant (e.g. visitor => participant). - // Currently we don't distinguish between these two - // cases. - this.setRole(command, args, ['admin', 'owner']); - break; - } - case 'destroy': { - if (!this.verifyAffiliations(['owner'])) { - break; - } - this.destroy().catch(e => this.onCommandError(e)); - break; - } - case 'help': { - this.model.set({ 'show_help_messages': true }); - break; - } - case 'kick': { - this.setRole(command, args, [], ['moderator']); - break; - } - case 'mute': { - this.setRole(command, args, [], ['moderator']); - break; - } - case 'member': { - this.setAffiliation(command, args, ['admin', 'owner']); - break; - } - case 'nick': { - if (!this.verifyRoles(['visitor', 'participant', 'moderator'])) { - break; - } else if (args.length === 0) { - // e.g. Your nickname is "coolguy69" - const message = __('Your nickname is "%1$s"', this.model.get('nick')); - this.model.createMessage({ message, 'type': 'error' }); - } else { - const jid = Strophe.getBareJidFromJid(this.model.get('jid')); - api.send( - $pres({ - from: _converse.connection.jid, - to: `${jid}/${args}`, - id: u.getUniqueId() - }).tree() - ); - } - break; - } - case 'owner': - this.setAffiliation(command, args, ['owner']); - break; - case 'op': { - this.setRole(command, args, ['admin', 'owner']); - break; - } - case 'register': { - if (args.length > 1) { - this.model.createMessage({ - 'message': __('Error: invalid number of arguments'), - 'type': 'error' - }); - } else { - this.model.registerNickname().then(err_msg => { - err_msg && this.model.createMessage({ 'message': err_msg, 'type': 'error' }); - }); - } - break; - } - case 'revoke': { - this.setAffiliation(command, args, ['admin', 'owner']); - break; - } - case 'topic': - case 'subject': - this.model.setSubject(args); - break; - case 'voice': { - this.setRole(command, args, [], ['moderator']); - break; - } - default: - return _converse.ChatBoxView.prototype.parseMessageForCommands.apply(this, arguments); - } - return true; - } - /** * Renders a form given an IQ stanza containing the current * groupchat configuration. @@ -973,15 +543,12 @@ export default class MUCView extends BaseChatView { * @method _converse.ChatRoomView#renderNicknameForm */ renderNicknameForm () { - const tpl_result = tpl_muc_nickname_form(this.model.toJSON()); if (api.settings.get('muc_show_logs_before_join')) { this.hideSpinner(); u.showElement(this.querySelector('.chat-area')); - const container = this.querySelector('.muc-bottom-panel'); - render(tpl_result, container); - u.addClass('muc-bottom-panel--nickname', container); } else { const form = this.querySelector('.muc-nickname-form'); + const tpl_result = tpl_muc_nickname_form(this.model.toJSON()); const form_el = u.getElementFromTemplateResult(tpl_result); if (form) { sizzle('.spinner', this).forEach(u.removeElement); @@ -1115,7 +682,6 @@ export default class MUCView extends BaseChatView { onOccupantAdded (occupant) { if (occupant.get('jid') === _converse.bare_jid) { this.renderHeading(); - this.renderBottomPanel(); } } diff --git a/src/plugins/muc-views/templates/muc.js b/src/plugins/muc-views/templates/muc.js index 5bc75a7cc..0ddf7bfdf 100644 --- a/src/plugins/muc-views/templates/muc.js +++ b/src/plugins/muc-views/templates/muc.js @@ -14,12 +14,13 @@ export default (o) => html`
-
+ +
`; diff --git a/src/plugins/muc-views/templates/muc_bottom_panel.js b/src/plugins/muc-views/templates/muc_bottom_panel.js index ed921a3c7..202999efa 100644 --- a/src/plugins/muc-views/templates/muc_bottom_panel.js +++ b/src/plugins/muc-views/templates/muc_bottom_panel.js @@ -1,4 +1,6 @@ +import tpl_muc_nickname_form from './muc_nickname_form.js'; import { __ } from 'i18n'; +import { api, converse } from "@converse/headless/core"; import { html } from "lit-html"; @@ -8,10 +10,15 @@ const tpl_can_edit = () => html` export default (o) => { + const conn_status = o.model.session.get('connection_status'); const i18n_not_allowed = __("You're not allowed to send messages in this room"); - if (o.entered) { - return (o.can_edit) ? tpl_can_edit() : html`
${i18n_not_allowed}
`; + if (conn_status === converse.ROOMSTATUS.ENTERED) { + return (o.can_edit) ? tpl_can_edit() : html`${i18n_not_allowed}`; + } else if (conn_status == converse.ROOMSTATUS.NICKNAME_REQUIRED) { + if (api.settings.get('muc_show_logs_before_join')) { + return html`${tpl_muc_nickname_form(o.model.toJSON())}`; + } } else { - return html`
`; + return ''; } } diff --git a/src/plugins/muc-views/utils.js b/src/plugins/muc-views/utils.js new file mode 100644 index 000000000..67fa5999b --- /dev/null +++ b/src/plugins/muc-views/utils.js @@ -0,0 +1,38 @@ +import { _converse, api } from "@converse/headless/core"; + + +export function getAutoCompleteListItem (text, input) { + input = input.trim(); + const element = document.createElement('li'); + element.setAttribute('aria-selected', 'false'); + + if (api.settings.get('muc_mention_autocomplete_show_avatar')) { + const img = document.createElement('img'); + let dataUri = 'data:' + _converse.DEFAULT_IMAGE_TYPE + ';base64,' + _converse.DEFAULT_IMAGE; + + if (_converse.vcards) { + const vcard = _converse.vcards.findWhere({ 'nickname': text }); + if (vcard) dataUri = 'data:' + vcard.get('image_type') + ';base64,' + vcard.get('image'); + } + + img.setAttribute('src', dataUri); + img.setAttribute('width', '22'); + img.setAttribute('class', 'avatar avatar-autocomplete'); + element.appendChild(img); + } + + const regex = new RegExp('(' + input + ')', 'ig'); + const parts = input ? text.split(regex) : [text]; + + parts.forEach(txt => { + if (input && txt.match(regex)) { + const match = document.createElement('mark'); + match.textContent = txt; + element.appendChild(match); + } else { + element.appendChild(document.createTextNode(txt)); + } + }); + + return element; +} diff --git a/src/shared/chat/baseview.js b/src/shared/chat/baseview.js new file mode 100644 index 000000000..013ad04be --- /dev/null +++ b/src/shared/chat/baseview.js @@ -0,0 +1,278 @@ +import debounce from 'lodash/debounce'; +import log from '@converse/headless/log'; +import tpl_spinner from 'templates/spinner.js'; +import { ElementView } from '@converse/skeletor/src/element.js'; +import { _converse, api, converse } from '@converse/headless/core'; +import { html, render } from 'lit-html'; + +const u = converse.env.utils; + +export default class BaseChatView extends ElementView { + + initDebounced () { + this.markScrolled = debounce(this._markScrolled, 100); + this.debouncedScrollDown = debounce(this.scrollDown, 100); + } + + async renderHeading () { + const tpl = await this.generateHeadingTemplate(); + render(tpl, this.querySelector('.chat-head-chatbox')); + } + + renderHelpMessages () { + render( + html` + + `, + this.help_container + ); + } + + async getHeadingStandaloneButton (promise_or_data) { // eslint-disable-line class-methods-use-this + const data = await promise_or_data; + return html` + + `; + } + + hideNewMessagesIndicator () { + const new_msgs_indicator = this.querySelector('.new-msgs-indicator'); + if (new_msgs_indicator !== null) { + new_msgs_indicator.classList.add('hidden'); + } + } + + maybeFocus () { + api.settings.get('auto_focus') && this.focus(); + } + + focus () { + const textarea_el = this.getElementsByClassName('chat-textarea')[0]; + if (textarea_el && document.activeElement !== textarea_el) { + textarea_el.focus(); + } + return this; + } + + show () { + if (this.model.get('hidden')) { + log.debug(`Not showing chat ${this.model.get('jid')} because it's set as hidden`); + return; + } + if (u.isVisible(this)) { + this.maybeFocus(); + return; + } + this.afterShown(); + } + + /** + * Scroll to the previously saved scrollTop position, or scroll + * down if it wasn't set. + */ + maintainScrollTop () { + const pos = this.model.get('scrollTop'); + if (pos) { + const msgs_container = this.querySelector('.chat-content__messages'); + msgs_container.scrollTop = pos; + } else { + this.scrollDown(); + } + } + + addSpinner (append = false) { + if (this.querySelector('.spinner') === null) { + const el = u.getElementFromTemplateResult(tpl_spinner()); + if (append) { + this.content.insertAdjacentElement('beforeend', el); + this.scrollDown(); + } else { + this.content.insertAdjacentElement('afterbegin', el); + } + } + } + + clearSpinner () { + this.content.querySelectorAll('.spinner').forEach(u.removeElement); + } + + onStatusMessageChanged (item) { + this.renderHeading(); + /** + * When a contact's custom status message has changed. + * @event _converse#contactStatusMessageChanged + * @type {object} + * @property { object } contact - The chat buddy + * @property { string } message - The message text + * @example _converse.api.listen.on('contactStatusMessageChanged', obj => { ... }); + */ + api.trigger('contactStatusMessageChanged', { + 'contact': item.attributes, + 'message': item.get('status') + }); + } + + + async getHeadingDropdownItem (promise_or_data) { // eslint-disable-line class-methods-use-this + const data = await promise_or_data; + return html` + ${data.i18n_text} + `; + } + + showNewMessagesIndicator () { + u.showElement(this.querySelector('.new-msgs-indicator')); + } + + onMessageAdded (message) { + if (u.isNewMessage(message)) { + if (message.get('sender') === 'me') { + // We remove the "scrolled" flag so that the chat area + // gets scrolled down. We always want to scroll down + // when the user writes a message as opposed to when a + // message is received. + this.model.set('scrolled', false); + } else if (this.model.get('scrolled', true)) { + this.showNewMessagesIndicator(); + } + } + } + + onEmojiReceivedFromPicker (emoji) { + const model = this.querySelector('converse-emoji-picker').model; + const autocompleting = model.get('autocompleting'); + const ac_position = model.get('ac_position'); + this.insertIntoTextArea(emoji, autocompleting, false, ac_position); + } + + onMessageCorrecting (message) { + if (message.get('correcting')) { + this.insertIntoTextArea(u.prefixMentions(message), true, true); + } else { + const currently_correcting = this.model.messages.findWhere('correcting'); + if (currently_correcting && currently_correcting !== message) { + this.insertIntoTextArea(u.prefixMentions(message), true, true); + } else { + this.insertIntoTextArea('', true, false); + } + } + } + + /** + * Insert a particular string value into the textarea of this chat box. + * @private + * @method _converse.ChatBoxView#insertIntoTextArea + * @param {string} value - The value to be inserted. + * @param {(boolean|string)} [replace] - Whether an existing value + * should be replaced. If set to `true`, the entire textarea will + * be replaced with the new value. If set to a string, then only + * that string will be replaced *if* a position is also specified. + * @param {integer} [position] - The end index of the string to be + * replaced with the new value. + */ + insertIntoTextArea (value, replace = false, correcting = false, position) { + let bottom_panel; + if (this.model.get('type') === _converse.CHATROOMS_TYPE) { + bottom_panel = this.querySelector('converse-muc-bottom-panel'); + } else { + bottom_panel = this.querySelector('converse-chat-bottom-panel'); + } + bottom_panel.insertIntoTextArea(value, replace, correcting, position); + } + + /** + * Called when the chat content is scrolled up or down. + * We want to record when the user has scrolled away from + * the bottom, so that we don't automatically scroll away + * from what the user is reading when new messages are received. + * + * Don't call this method directly, instead, call `markScrolled`, + * which debounces this method by 100ms. + * @private + */ + _markScrolled (ev) { + let scrolled = true; + let scrollTop = null; + const msgs_container = this.querySelector('.chat-content__messages'); + const is_at_bottom = + msgs_container.scrollTop + msgs_container.clientHeight >= msgs_container.scrollHeight - 62; // sigh... + + if (is_at_bottom) { + scrolled = false; + this.onScrolledDown(); + } else if (msgs_container.scrollTop === 0) { + /** + * Triggered once the chat's message area has been scrolled to the top + * @event _converse#chatBoxScrolledUp + * @property { _converse.ChatBoxView | _converse.ChatRoomView } view + * @example _converse.api.listen.on('chatBoxScrolledUp', obj => { ... }); + */ + api.trigger('chatBoxScrolledUp', this); + } else { + scrollTop = ev.target.scrollTop; + } + u.safeSave(this.model, { scrolled, scrollTop }); + } + + /** + * Scrolls the chat down. + * + * This method will always scroll the chat down, regardless of + * whether the user scrolled up manually or not. + * @param { Event } [ev] - An optional event that is the cause for needing to scroll down. + */ + scrollDown (ev) { + ev?.preventDefault?.(); + ev?.stopPropagation?.(); + if (this.model.get('scrolled')) { + u.safeSave(this.model, { + 'scrolled': false, + 'scrollTop': null + }); + } + this.querySelector('.chat-content__messages').scrollDown(); + this.onScrolledDown(); + } + + onScrolledDown () { + this.hideNewMessagesIndicator(); + if (!this.model.isHidden()) { + this.model.clearUnreadMsgCounter(); + // Clear location hash if set to one of the messages in our history + const hash = window.location.hash; + hash && this.model.messages.get(hash.slice(1)) && _converse.router.history.navigate(); + } + /** + * Triggered once the chat's message area has been scrolled down to the bottom. + * @event _converse#chatBoxScrolledDown + * @type {object} + * @property { _converse.ChatBox | _converse.ChatRoom } chatbox - The chat model + * @example _converse.api.listen.on('chatBoxScrolledDown', obj => { ... }); + */ + api.trigger('chatBoxScrolledDown', { 'chatbox': this.model }); // TODO: clean up + } + + onWindowStateChanged (data) { + if (data.state === 'visible') { + if (!this.model.isHidden() && this.model.get('num_unread', 0)) { + this.model.clearUnreadMsgCounter(); + } + } else if (data.state === 'hidden') { + this.model.setChatState(_converse.INACTIVE, { 'silent': true }); + this.model.sendChatState(); + } + } +} diff --git a/src/templates/chatbox.js b/src/templates/chatbox.js index bc807caad..2416a3149 100644 --- a/src/templates/chatbox.js +++ b/src/templates/chatbox.js @@ -13,9 +13,7 @@ export default (o) => html`
-
-
-
+
`; diff --git a/src/templates/chatbox_message_form.js b/src/templates/chatbox_message_form.js index f512bc8e0..1aad9190b 100644 --- a/src/templates/chatbox_message_form.js +++ b/src/templates/chatbox_message_form.js @@ -15,6 +15,12 @@ export default (o) => html`