Ver código fonte

Turn the bottom panel into a custom element

JC Brand 4 anos atrás
pai
commit
9ce4092a7c

+ 9 - 0
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;

+ 36 - 30
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');

+ 23 - 16
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();

+ 23 - 20
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
         });

+ 19 - 13
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

+ 3 - 2
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

+ 17 - 13
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(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
                         `to="lounge@montague.lit" type="groupchat" `+
@@ -365,7 +366,7 @@ describe("A sent groupchat message", function () {
             });
             await u.waitUntil(() => 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 <span class="mention">z3r0</span> <span class="mention">gibson</span> <span class="mention">mr.robot</span>, 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(`<message from="romeo@montague.lit/orchard" id="${msg.nodeTree.getAttribute("id")}" `+
                         `to="lounge@montague.lit" type="groupchat" `+
@@ -404,14 +406,14 @@ describe("A sent groupchat message", function () {
             expect(view.model.messages.at(0).get('correcting')).toBe(true);
             expect(view.querySelectorAll('.chat-msg').length).toBe(1);
             await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
-            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(`<message from="romeo@montague.lit/orchard" id="${correction.nodeTree.getAttribute("id")}" `+
                         `to="lounge@montague.lit" type="groupchat" `+
@@ -449,7 +451,7 @@ describe("A sent groupchat message", function () {
             await u.waitUntil(() => 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 <span class="mention">gibson</span> <span title=":poop:">💩</span> `+

+ 12 - 12
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: '+
             '<a target="_blank" rel="noopener" href="https://www.opkode.com/?id=0&amp;utm_content=1&amp;s=1">https://www.opkode.com/?id=0&amp;utm_content=1&amp;s=1</a>');
@@ -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 () {
             `</a>`);
 
         message += "?param1=val1&param2=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');

+ 1 - 1
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();

+ 5 - 3
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

+ 9 - 6
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');

+ 88 - 84
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(
                 `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
@@ -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
             /* <presence
@@ -3618,15 +3620,16 @@ describe("Groupchats", function () {
             await u.waitUntil(() => 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(
                 `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
@@ -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(
                 `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
@@ -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
             /* <presence
@@ -3757,15 +3760,16 @@ describe("Groupchats", function () {
             await u.waitUntil(() => 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(
                 `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
@@ -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(
                 `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+
@@ -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(`
                 <presence
@@ -5271,12 +5278,12 @@ describe("Groupchats", function () {
             // the textarea becomes visible when the room's
             // configuration changes to be non-moderated
             view.model.features.set('moderated', false);
-            expect(view.querySelector('.muc-bottom-panel')).toBe(null);
-            let textarea = view.querySelector('.chat-textarea');
+            await u.waitUntil(() => view.querySelector('.muc-bottom-panel') === null);
+            const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea'));
             expect(textarea === null).toBe(false);
 
             view.model.features.set('moderated', true);
-            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 () {
                 </presence>`);
             _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();

+ 9 - 6
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

+ 14 - 9
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);

+ 3 - 2
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

+ 3 - 2
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

+ 4 - 2
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

+ 126 - 0
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

+ 260 - 369
src/shared/chatview.js → 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`
-                <converse-chat-help
-                    .model=${this.model}
-                    .messages=${this.getHelpMessages()}
-                    ?hidden=${!this.model.get('show_help_messages')}
-                    type="info"
-                    chat_type="${this.model.get('type')}"
-                ></converse-chat-help>
-            `,
-            this.help_container
+    render () {
+        render(html`<div class="message-form-container"></div>`, 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,74 +75,27 @@ export default class BaseChatView extends ElementView {
         this.renderToolbar();
     }
 
-    renderToolbar () {
-        if (!api.settings.get('show_toolbar')) {
-            return this;
+    emitFocused (ev) {
+        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;
         }
-        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 => { ... });
+         * Triggered when the focus has been moved to a particular chat.
+         * @event _converse#chatBoxFocused
+         * @type { _converse.ChatBoxView | _converse.ChatRoomView }
+         * @example _converse.api.listen.on('chatBoxFocused', (view, event) => { ... });
          */
-        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`
-            <a
-                href="#"
-                class="chatbox-btn ${data.a_class} fa ${data.icon_class}"
-                @click=${data.handler}
-                title="${data.i18n_title}"
-            ></a>
-        `;
-    }
-
-    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;
+        api.trigger('chatBoxFocused', this, ev);
     }
 
-    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();
+    emitBlurred (ev) {
+        const chatview = _converse.chatboxviews.get(this.getAttribute('jid'));
+        if (!chatview) {
             return;
         }
-        this.afterShown();
-    }
-
-    emitBlurred (ev) {
-        if (this.contains(document.activeElement) || this.contains(ev.relatedTarget)) {
+        if (chatview.contains(document.activeElement) || chatview.contains(ev.relatedTarget)) {
             // Something else in this chatbox is still focused
             return;
         }
@@ -138,80 +108,168 @@ export default class BaseChatView extends ElementView {
         api.trigger('chatBoxBlurred', this, ev);
     }
 
-    emitFocused (ev) {
-        if (this.contains(ev.relatedTarget)) {
-            // Something else in this chatbox was already focused
-            return;
-        }
-        /**
-         * Triggered when the focus has been moved to a particular chat.
-         * @event _converse#chatBoxFocused
-         * @type { _converse.ChatBoxView | _converse.ChatRoomView }
-         * @example _converse.api.listen.on('chatBoxFocused', (view, event) => { ... });
-         */
-        api.trigger('chatBoxFocused', this, ev);
+    getToolbarOptions () { // eslint-disable-line class-methods-use-this
+        return {};
     }
 
-    /**
-     * 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();
+    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;
         }
     }
 
-    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);
-            }
+    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);
     }
 
-    clearSpinner () {
-        this.content.querySelectorAll('.spinner').forEach(u.removeElement);
+    onDragOver (ev) { // eslint-disable-line class-methods-use-this
+        ev.preventDefault();
     }
 
-    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 clearMessages (ev) {
+        ev?.preventDefault?.();
+        const result = confirm(__('Are you sure you want to clear the messages from this conversation?'));
+        if (result === true) {
+            await this.model.clearMessages();
+        }
+        return this;
     }
 
+    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;
+            }
+        }
+    }
+
+    async onFormSubmitted () {
+        const textarea = this.querySelector('.chat-textarea');
+        const message_text = textarea.value.trim();
+        if (
+            (api.settings.get('message_limit') && message_text.length > api.settings.get('message_limit')) ||
+            !message_text.replace(/\s/g, '').length
+        ) {
+            return;
+        }
+        if (!_converse.connection.authenticated) {
+            const err_msg = __('Sorry, the connection has been lost, and your message could not be sent');
+            api.alert('error', __('Error'), err_msg);
+            api.connection.reconnect();
+            return;
+        }
+        let spoiler_hint,
+            hint_el = {};
+        if (this.model.get('composing_spoiler')) {
+            hint_el = this.querySelector('form.sendXMPPMessage input.spoiler-hint');
+            spoiler_hint = hint_el.value;
+        }
+        u.addClass('disabled', textarea);
+        textarea.setAttribute('disabled', 'disabled');
+        this.querySelector('converse-emoji-dropdown')?.hideMenu();
+
+        const is_command = this.parseMessageForCommands(message_text);
+        const message = is_command ? null : await this.model.sendMessage(message_text, spoiler_hint);
+        if (is_command || message) {
+            hint_el.value = '';
+            textarea.value = '';
+            u.removeClass('correcting', textarea);
+            textarea.style.height = 'auto';
+            this.updateCharCounter(textarea.value);
+        }
+        if (message) {
+            /**
+             * Triggered whenever a message is sent by the user
+             * @event _converse#messageSend
+             * @type { _converse.Message }
+             * @example _converse.api.listen.on('messageSend', message => { ... });
+             */
+            api.trigger('messageSend', message);
+        }
+        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 chatview = _converse.chatboxviews.get(this.getAttribute('jid'));
+            const msgs_container = chatview.querySelector('.chat-content__messages');
+            msgs_container.parentElement.style.display = 'none';
+        }
+        textarea.removeAttribute('disabled');
+        u.removeClass('disabled', textarea);
 
-    getOwnMessages () {
-        return this.model.messages.filter({ 'sender': 'me' });
+        if (api.settings.get('view_mode') === 'overlayed') {
+            // XXX: Chrome flexbug workaround.
+            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
+        // immediately after the message, causing rate-limiting issues.
+        this.model.setChatState(_converse.ACTIVE, { 'silent': true });
+        textarea.focus();
     }
 
-    async clearMessages (ev) {
-        if (ev && ev.preventDefault) {
-            ev.preventDefault();
+    /**
+     * 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);
         }
-        const result = confirm(__('Are you sure you want to clear the messages from this conversation?'));
-        if (result === true) {
-            await this.model.clearMessages();
+        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 + ' ';
         }
-        return this;
+        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 () {
@@ -230,7 +288,7 @@ export default class BaseChatView extends ElementView {
         }
         message =
             message ||
-            this.getOwnMessages()
+            this.model.messages.filter({ 'sender': 'me' })
                 .reverse()
                 .find(m => m.get('editable'));
         if (message) {
@@ -260,15 +318,6 @@ export default class BaseChatView extends ElementView {
         }
     }
 
-    async getHeadingDropdownItem (promise_or_data) { // eslint-disable-line class-methods-use-this
-        const data = await promise_or_data;
-        return html`
-            <a href="#" class="dropdown-item ${data.a_class}" @click=${data.handler} title="${data.i18n_title}"
-                ><i class="fa ${data.icon_class}"></i>${data.i18n_text}</a
-            >
-        `;
-    }
-
     autocompleteInPicker (input, value) {
         const emoji_dropdown = this.querySelector('converse-emoji-dropdown');
         const emoji_picker = this.querySelector('converse-emoji-picker');
@@ -283,231 +332,54 @@ export default class BaseChatView extends ElementView {
         }
     }
 
-    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) {
-        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();
+    onKeyDown (ev) {
+        if (ev.ctrlKey) {
+            // When ctrl is pressed, no chars are entered into the textarea.
+            return;
         }
-        /**
-         * 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();
+        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();
             }
-        } else if (data.state === 'hidden') {
-            this.model.setChatState(_converse.INACTIVE, { 'silent': true });
-            this.model.sendChatState();
         }
-    }
-
-    async onFormSubmitted (ev) {
-        ev.preventDefault();
-        const textarea = this.querySelector('.chat-textarea');
-        const message_text = textarea.value.trim();
         if (
-            (api.settings.get('message_limit') && message_text.length > api.settings.get('message_limit')) ||
-            !message_text.replace(/\s/g, '').length
+            [
+                converse.keycodes.SHIFT,
+                converse.keycodes.META,
+                converse.keycodes.META_RIGHT,
+                converse.keycodes.ESCAPE,
+                converse.keycodes.ALT
+            ].includes(ev.keyCode)
         ) {
             return;
         }
-        if (!_converse.connection.authenticated) {
-            const err_msg = __('Sorry, the connection has been lost, and your message could not be sent');
-            api.alert('error', __('Error'), err_msg);
-            api.connection.reconnect();
-            return;
-        }
-        let spoiler_hint,
-            hint_el = {};
-        if (this.model.get('composing_spoiler')) {
-            hint_el = this.querySelector('form.sendXMPPMessage input.spoiler-hint');
-            spoiler_hint = hint_el.value;
+        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);
         }
-        u.addClass('disabled', textarea);
-        textarea.setAttribute('disabled', 'disabled');
-        this.querySelector('converse-emoji-dropdown')?.hideMenu();
-
-        const is_command = this.parseMessageForCommands(message_text);
-        const message = is_command ? null : await this.model.sendMessage(message_text, spoiler_hint);
-        if (is_command || message) {
-            hint_el.value = '';
-            textarea.value = '';
-            u.removeClass('correcting', textarea);
-            textarea.style.height = 'auto';
-            this.updateCharCounter(textarea.value);
-        }
-        if (message) {
-            /**
-             * Triggered whenever a message is sent by the user
-             * @event _converse#messageSend
-             * @type { _converse.Message }
-             * @example _converse.api.listen.on('messageSend', message => { ... });
-             */
-            api.trigger('messageSend', message);
-        }
-        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');
-            msgs_container.parentElement.style.display = 'none';
-        }
-        textarea.removeAttribute('disabled');
-        u.removeClass('disabled', textarea);
-
-        if (api.settings.get('view_mode') === 'overlayed') {
-            // XXX: Chrome flexbug workaround.
-            const msgs_container = this.querySelector('.chat-content__messages');
-            msgs_container.parentElement.style.display = '';
-        }
-        // Suppress events, otherwise superfluous CSN gets set
-        // immediately after the message, causing rate-limiting issues.
-        this.model.setChatState(_converse.ACTIVE, { 'silent': true });
-        textarea.focus();
-    }
-
-    onEnterPressed (ev) {
-        return this.onFormSubmitted(ev);
     }
 
     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);

+ 4 - 0
src/plugins/chatview/templates/bottom_panel.js

@@ -0,0 +1,4 @@
+
+            <div class="bottom-panel">
+                <div class="message-form-container"></div>
+            </div>

+ 2 - 140
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();

+ 1 - 1
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';

+ 310 - 0
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);

+ 6 - 440
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 {
             `<strong>/voice</strong>: ${__('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();
         }
     }
 

+ 2 - 1
src/plugins/muc-views/templates/muc.js

@@ -14,12 +14,13 @@ export default (o) => html`
 
                     <div class="chat-content__help"></div>
                 </div>
-                <div class="bottom-panel"></div>
+                <converse-muc-bottom-panel jid=${o.model.get('jid')} class="bottom-panel"></converse-muc-bottom-panel>
             </div>
             <div class="disconnect-container hidden"></div>
             <converse-muc-sidebar class="occupants col-md-3 col-4 ${o.sidebar_hidden ? 'hidden' : ''}"
                 .occupants=${o.occupants}
                 .chatroom=${o.model}></converse-muc-sidebar>
+            <div class="nickname-form-container"></div>
         </div>
     </div>
 `;

+ 10 - 3
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`<div class="muc-bottom-panel">${i18n_not_allowed}</div>`;
+    if (conn_status === converse.ROOMSTATUS.ENTERED) {
+        return (o.can_edit) ? tpl_can_edit() : html`<span class="muc-bottom-panel muc-bottom-panel--muted">${i18n_not_allowed}</span>`;
+    } else if (conn_status == converse.ROOMSTATUS.NICKNAME_REQUIRED) {
+        if (api.settings.get('muc_show_logs_before_join')) {
+            return html`<span class="muc-bottom-panel muc-bottom-panel--nickname">${tpl_muc_nickname_form(o.model.toJSON())}</span>`;
+        }
     } else {
-        return html`<div class="muc-bottom-panel"></div>`;
+        return '';
     }
 }

+ 38 - 0
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;
+}

+ 278 - 0
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`
+                <converse-chat-help
+                    .model=${this.model}
+                    .messages=${this.getHelpMessages()}
+                    ?hidden=${!this.model.get('show_help_messages')}
+                    type="info"
+                    chat_type="${this.model.get('type')}"
+                ></converse-chat-help>
+            `,
+            this.help_container
+        );
+    }
+
+    async getHeadingStandaloneButton (promise_or_data) { // eslint-disable-line class-methods-use-this
+        const data = await promise_or_data;
+        return html`
+            <a
+                href="#"
+                class="chatbox-btn ${data.a_class} fa ${data.icon_class}"
+                @click=${data.handler}
+                title="${data.i18n_title}"
+            ></a>
+        `;
+    }
+
+    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`
+            <a href="#" class="dropdown-item ${data.a_class}" @click=${data.handler} title="${data.i18n_title}"
+                ><i class="fa ${data.icon_class}"></i>${data.i18n_text}</a
+            >
+        `;
+    }
+
+    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();
+        }
+    }
+}

+ 1 - 3
src/templates/chatbox.js

@@ -13,9 +13,7 @@ export default (o) => html`
 
                 <div class="chat-content__help"></div>
             </div>
-            <div class="bottom-panel">
-                <div class="message-form-container">
-            </div>
+            <converse-chat-bottom-panel jid=${o.jid} class="bottom-panel"> </converse-chat-bottom-panel>
         </div>
     </div>
 `;

+ 6 - 0
src/templates/chatbox_message_form.js

@@ -15,6 +15,12 @@ export default (o) => html`
             <textarea
                 autofocus
                 type="text"
+                @drop=${o.onDrop}
+                @input=${o.inputChanged}
+                @keydown=${o.onKeyDown}
+                @keyup=${o.onKeyUp}
+                @paste=${o.onPaste}
+                @change=${o.onChange}
                 class="chat-textarea suggestion-box__input
                     ${ o.show_send_button ? 'chat-textarea-send-button' : '' }
                     ${ o.composing_spoile ? 'spoiler' : '' }"