Jelajahi Sumber

Create `converse-message-form` component

More work on making bottom panel sub-components declarative

- Handle auto-completion in the converse-muc-message-form element
- Make message limit indicator a component
- Rename template
JC Brand 4 tahun lalu
induk
melakukan
f3efbba26c
41 mengubah file dengan 725 tambahan dan 617 penghapusan
  1. 1 1
      karma.conf.js
  2. 20 20
      spec/emojis.js
  3. 2 2
      spec/mock.js
  4. 6 5
      src/headless/plugins/chat/model.js
  5. 11 2
      src/headless/plugins/muc/muc.js
  6. 22 282
      src/plugins/chatview/bottom-panel.js
  7. 229 0
      src/plugins/chatview/message-form.js
  8. 29 3
      src/plugins/chatview/templates/bottom-panel.js
  9. 29 0
      src/plugins/chatview/templates/message-form.js
  10. 0 28
      src/plugins/chatview/templates/toolbar.js
  11. 24 24
      src/plugins/chatview/tests/chatbox.js
  12. 15 15
      src/plugins/chatview/tests/corrections.js
  13. 2 2
      src/plugins/chatview/tests/markers.js
  14. 3 4
      src/plugins/chatview/tests/messages.js
  15. 3 3
      src/plugins/chatview/tests/receipts.js
  16. 4 4
      src/plugins/chatview/tests/spoilers.js
  17. 12 0
      src/plugins/chatview/utils.js
  18. 0 1
      src/plugins/chatview/view.js
  19. 1 1
      src/plugins/headlines-view/templates/chat-head.js
  20. 11 46
      src/plugins/muc-views/bottom-panel.js
  21. 70 0
      src/plugins/muc-views/message-form.js
  22. 0 1
      src/plugins/muc-views/muc.js
  23. 0 24
      src/plugins/muc-views/styles/index.scss
  24. 2 3
      src/plugins/muc-views/templates/message-form.js
  25. 31 4
      src/plugins/muc-views/templates/muc-bottom-panel.js
  26. 30 30
      src/plugins/muc-views/tests/autocomplete.js
  27. 7 7
      src/plugins/muc-views/tests/corrections.js
  28. 11 11
      src/plugins/muc-views/tests/mentions.js
  29. 6 6
      src/plugins/muc-views/tests/modtools.js
  30. 6 6
      src/plugins/muc-views/tests/muc-messages.js
  31. 2 2
      src/plugins/muc-views/tests/muc-registration.js
  32. 48 49
      src/plugins/muc-views/tests/muc.js
  33. 0 1
      src/plugins/notifications/tests/notification.js
  34. 10 10
      src/plugins/omemo/tests/omemo.js
  35. 9 1
      src/shared/chat/baseview.js
  36. 2 2
      src/shared/chat/chat-content.js
  37. 1 1
      src/shared/chat/emoji-picker.js
  38. 26 0
      src/shared/chat/message-limit.js
  39. 7 0
      src/shared/chat/templates/message-limit.js
  40. 27 9
      src/shared/chat/toolbar.js
  41. 6 7
      src/shared/chat/utils.js

+ 1 - 1
karma.conf.js

@@ -25,7 +25,6 @@ module.exports = function(config) {
       { pattern: "node_modules/sinon/pkg/sinon.js", type: 'module' },
       { pattern: "node_modules/sinon/pkg/sinon.js", type: 'module' },
       { pattern: "spec/mock.js", type: 'module' },
       { pattern: "spec/mock.js", type: 'module' },
 
 
-      { pattern: "spec/emojis.js", type: 'module' },
       { pattern: "spec/protocol.js", type: 'module' },
       { pattern: "spec/protocol.js", type: 'module' },
       { pattern: "spec/push.js", type: 'module' },
       { pattern: "spec/push.js", type: 'module' },
       { pattern: "spec/user-details-modal.js", type: 'module' },
       { pattern: "spec/user-details-modal.js", type: 'module' },
@@ -43,6 +42,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/bookmark-views/tests/bookmarks.js", type: 'module' },
       { pattern: "src/plugins/bookmark-views/tests/bookmarks.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/chatbox.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/chatbox.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/corrections.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/corrections.js", type: 'module' },
+      { pattern: "src/plugins/chatview/tests/emojis.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/http-file-upload.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/http-file-upload.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/markers.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/markers.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/me-messages.js", type: 'module' },
       { pattern: "src/plugins/chatview/tests/me-messages.js", type: 'module' },

+ 20 - 20
spec/emojis.js

@@ -48,8 +48,8 @@ describe("Emojis", function () {
                 'keyCode': 9,
                 'keyCode': 9,
                 'key': 'Tab'
                 'key': 'Tab'
             }
             }
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown(tab_event);
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown(tab_event);
             await u.waitUntil(() => view.querySelector('converse-emoji-picker .emoji-search')?.value === ':gri');
             await u.waitUntil(() => view.querySelector('converse-emoji-picker .emoji-search')?.value === ':gri');
             await u.waitUntil(() =>  sizzle('.emojis-lists__container--search .insert-emoji', view).length === 3, 1000);
             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);
             let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', view);
@@ -89,7 +89,7 @@ describe("Emojis", function () {
             _converse.connection._dataRecv(mock.createRequest(presence));
             _converse.connection._dataRecv(mock.createRequest(presence));
 
 
             textarea.value = ':use';
             textarea.value = ':use';
-            bottom_panel.onKeyDown(tab_event);
+            message_form.onKeyDown(tab_event);
             await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
             await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
             await u.waitUntil(() => input.value === ':use');
             await u.waitUntil(() => input.value === ':use');
             visible_emojis = sizzle('.insert-emoji:not(.hidden)', picker);
             visible_emojis = sizzle('.insert-emoji:not(.hidden)', picker);
@@ -115,8 +115,8 @@ describe("Emojis", function () {
                 'keyCode': 9,
                 'keyCode': 9,
                 'key': 'Tab'
                 'key': 'Tab'
             }
             }
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown(tab_event);
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown(tab_event);
             await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
             await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
 
 
             const picker = view.querySelector('converse-emoji-picker');
             const picker = view.querySelector('converse-emoji-picker');
@@ -134,7 +134,7 @@ describe("Emojis", function () {
             emoji.click();
             emoji.click();
             await u.waitUntil(() => textarea.value === ':grinning: ');
             await u.waitUntil(() => textarea.value === ':grinning: ');
             textarea.value = ':grinning: :';
             textarea.value = ':grinning: :';
-            bottom_panel.onKeyDown(tab_event);
+            message_form.onKeyDown(tab_event);
 
 
             await u.waitUntil(() => input.value === ':');
             await u.waitUntil(() => input.value === ':');
             input.value = ':grimacing';
             input.value = ':grimacing';
@@ -167,8 +167,8 @@ describe("Emojis", function () {
                 'key': 'Tab'
                 'key': 'Tab'
             }
             }
             textarea.value = ':';
             textarea.value = ':';
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown(tab_event);
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown(tab_event);
             await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
             await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
             const picker = view.querySelector('converse-emoji-picker');
             const picker = view.querySelector('converse-emoji-picker');
             const input = picker.querySelector('.emoji-search');
             const input = picker.querySelector('.emoji-search');
@@ -179,7 +179,7 @@ describe("Emojis", function () {
             expect(textarea.value).toBe(':100: ');
             expect(textarea.value).toBe(':100: ');
 
 
             textarea.value = ':';
             textarea.value = ':';
-            bottom_panel.onKeyDown(tab_event);
+            message_form.onKeyDown(tab_event);
             await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
             await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
             await u.waitUntil(() => input.value === ':');
             await u.waitUntil(() => input.value === ':');
             input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
             input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
@@ -285,8 +285,8 @@ describe("Emojis", function () {
             // emojis now renders normally again.
             // emojis now renders normally again.
             const textarea = view.querySelector('textarea.chat-textarea');
             const textarea = view.querySelector('textarea.chat-textarea');
             textarea.value = ':poop: :innocent:';
             textarea.value = ':poop: :innocent:';
-            const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-            bottom_panel.onKeyDown({
+            const message_form = view.querySelector('converse-message-form');
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13 // Enter
                 keyCode: 13 // Enter
@@ -296,7 +296,7 @@ describe("Emojis", function () {
             await u.waitUntil(() => view.querySelector(last_msg_sel).textContent === '💩 😇');
             await u.waitUntil(() => view.querySelector(last_msg_sel).textContent === '💩 😇');
 
 
             expect(textarea.value).toBe('');
             expect(textarea.value).toBe('');
-            bottom_panel.onKeyDown({
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 keyCode: 38 // Up arrow
                 keyCode: 38 // Up arrow
             });
             });
@@ -306,7 +306,7 @@ describe("Emojis", function () {
             await u.waitUntil(() => u.hasClass('correcting', view.querySelector(sel)), 500);
             await u.waitUntil(() => u.hasClass('correcting', view.querySelector(sel)), 500);
             const edited_text = textarea.value += 'This is no longer an emoji-only message';
             const edited_text = textarea.value += 'This is no longer an emoji-only message';
             textarea.value = edited_text;
             textarea.value = edited_text;
-            bottom_panel.onKeyDown({
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13 // Enter
                 keyCode: 13 // Enter
@@ -318,7 +318,7 @@ describe("Emojis", function () {
             expect(u.hasClass('chat-msg__text--larger', message)).toBe(false);
             expect(u.hasClass('chat-msg__text--larger', message)).toBe(false);
 
 
             textarea.value = ':smile: Hello world!';
             textarea.value = ':smile: Hello world!';
-            bottom_panel.onKeyDown({
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13 // Enter
                 keyCode: 13 // Enter
@@ -326,7 +326,7 @@ describe("Emojis", function () {
             await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
 
 
             textarea.value = ':smile: :smiley: :imp:';
             textarea.value = ':smile: :smiley: :imp:';
-            bottom_panel.onKeyDown({
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13 // Enter
                 keyCode: 13 // Enter
@@ -367,8 +367,8 @@ describe("Emojis", function () {
 
 
             const textarea = view.querySelector('textarea.chat-textarea');
             const textarea = view.querySelector('textarea.chat-textarea');
             textarea.value = ':poop: :innocent:';
             textarea.value = ':poop: :innocent:';
-            const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-            bottom_panel.onKeyDown({
+            const message_form = view.querySelector('converse-message-form');
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13 // Enter
                 keyCode: 13 // Enter
@@ -385,7 +385,7 @@ describe("Emojis", function () {
             const sent_stanza = sent_stanzas.filter(s => s.nodeName === 'message').pop();
             const sent_stanza = sent_stanzas.filter(s => s.nodeName === 'message').pop();
             expect(sent_stanza.querySelector('body').innerHTML).toBe('💩 😇');
             expect(sent_stanza.querySelector('body').innerHTML).toBe('💩 😇');
             done()
             done()
-        }));
+                    }));
 
 
         it("can show custom emojis",
         it("can show custom emojis",
             mock.initConverse(
             mock.initConverse(
@@ -419,8 +419,8 @@ describe("Emojis", function () {
 
 
             const textarea = view.querySelector('textarea.chat-textarea');
             const textarea = view.querySelector('textarea.chat-textarea');
             textarea.value = 'Running tests for :converse:';
             textarea.value = 'Running tests for :converse:';
-            const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-            bottom_panel.onKeyDown({
+            const message_form = view.querySelector('converse-message-form');
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13 // Enter
                 keyCode: 13 // Enter

+ 2 - 2
spec/mock.js

@@ -436,8 +436,8 @@ mock.sendMessage = async function (view, message) {
     const promise = new Promise(resolve => view.model.messages.once('rendered', resolve));
     const promise = new Promise(resolve => view.model.messages.once('rendered', resolve));
     const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
     const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
     textarea.value = message;
     textarea.value = message;
-    const bottom_panel = view.querySelector('converse-chat-bottom-panel') || view.querySelector('converse-muc-bottom-panel');
-    bottom_panel.onKeyDown({
+    const message_form = view.querySelector('converse-message-form') || view.querySelector('converse-muc-message-form');
+    message_form.onKeyDown({
         target: view.querySelector('textarea.chat-textarea'),
         target: view.querySelector('textarea.chat-textarea'),
         preventDefault: () => {},
         preventDefault: () => {},
         keyCode: 13
         keyCode: 13

+ 6 - 5
src/headless/plugins/chat/model.js

@@ -64,6 +64,7 @@ const ChatBox = ModelWithContact.extend({
             this.presence.on('change:show', item => this.onPresenceChanged(item));
             this.presence.on('change:show', item => this.onPresenceChanged(item));
         }
         }
         this.on('change:chat_state', this.sendChatState, this);
         this.on('change:chat_state', this.sendChatState, this);
+        this.on('change:scrolled', () => !this.get('scrolled') && this.clearUnreadMsgCounter());
 
 
         await this.fetchMessages();
         await this.fetchMessages();
         /**
         /**
@@ -198,7 +199,7 @@ const ChatBox = ModelWithContact.extend({
      * Queue an incoming `chat` message stanza for processing.
      * Queue an incoming `chat` message stanza for processing.
      * @async
      * @async
      * @private
      * @private
-     * @method _converse.ChatRoom#queueMessage
+     * @method _converse.ChatBox#queueMessage
      * @param { Promise<MessageAttributes> } attrs - A promise which resolves to the message attributes
      * @param { Promise<MessageAttributes> } attrs - A promise which resolves to the message attributes
      */
      */
     queueMessage (attrs) {
     queueMessage (attrs) {
@@ -211,7 +212,7 @@ const ChatBox = ModelWithContact.extend({
     /**
     /**
      * @async
      * @async
      * @private
      * @private
-     * @method _converse.ChatRoom#onMessage
+     * @method _converse.ChatBox#onMessage
      * @param { MessageAttributes } attrs_promse - A promise which resolves to the message attributes.
      * @param { MessageAttributes } attrs_promse - A promise which resolves to the message attributes.
      */
      */
     async onMessage (attrs) {
     async onMessage (attrs) {
@@ -681,7 +682,6 @@ const ChatBox = ModelWithContact.extend({
         return _converse.connection.send(msg);
         return _converse.connection.send(msg);
     },
     },
 
 
-
     /**
     /**
      * Finds the last eligible message and then sends a XEP-0333 chat marker for it.
      * Finds the last eligible message and then sends a XEP-0333 chat marker for it.
      * @param { ('received'|'displayed'|'acknowledged') } [type='displayed']
      * @param { ('received'|'displayed'|'acknowledged') } [type='displayed']
@@ -866,7 +866,7 @@ const ChatBox = ModelWithContact.extend({
      * before the collection has been fetched.
      * before the collection has been fetched.
      * @async
      * @async
      * @private
      * @private
-     * @method _converse.ChatRoom#queueMessageCreation
+     * @method _converse.ChatBox#queueMessageCreation
      * @param { Object } attrs
      * @param { Object } attrs
      */
      */
     async createMessage (attrs, options) {
     async createMessage (attrs, options) {
@@ -1029,6 +1029,7 @@ const ChatBox = ModelWithContact.extend({
      * Given a newly received {@link _converse.Message} instance,
      * Given a newly received {@link _converse.Message} instance,
      * update the unread counter if necessary.
      * update the unread counter if necessary.
      * @private
      * @private
+     * @method _converse.ChatBox#handleUnreadMessage
      * @param {_converse.Message} message
      * @param {_converse.Message} message
      */
      */
     handleUnreadMessage (message) {
     handleUnreadMessage (message) {
@@ -1064,7 +1065,7 @@ const ChatBox = ModelWithContact.extend({
     },
     },
 
 
     isScrolledUp () {
     isScrolledUp () {
-        return this.get('scrolled', true);
+        return this.get('scrolled');
     }
     }
 });
 });
 
 

+ 11 - 2
src/headless/plugins/muc/muc.js

@@ -97,6 +97,7 @@ const ChatRoomMixin = {
 
 
         this.on('change:chat_state', this.sendChatState, this);
         this.on('change:chat_state', this.sendChatState, this);
         this.on('change:hidden', this.onHiddenChange, this);
         this.on('change:hidden', this.onHiddenChange, this);
+        this.on('change:scrolled', () => !this.get('scrolled') && this.clearUnreadMsgCounter());
         this.on('destroy', this.removeHandlers, this);
         this.on('destroy', this.removeHandlers, this);
 
 
         await this.restoreSession();
         await this.restoreSession();
@@ -2562,7 +2563,9 @@ const ChatRoomMixin = {
         }
         }
     },
     },
 
 
-    /* Given a newly received message, update the unread counter if necessary.
+    /**
+     * Given a newly received {@link _converse.Message} instance,
+     * update the unread counter if necessary.
      * @private
      * @private
      * @method _converse.ChatRoom#handleUnreadMessage
      * @method _converse.ChatRoom#handleUnreadMessage
      * @param { XMLElement } - The <messsage> stanza
      * @param { XMLElement } - The <messsage> stanza
@@ -2572,7 +2575,13 @@ const ChatRoomMixin = {
             return;
             return;
         }
         }
         if (u.isNewMessage(message)) {
         if (u.isNewMessage(message)) {
-            if (this.isHidden()) {
+            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.isHidden() || this.get('scrolled')) {
                 const settings = {
                 const settings = {
                     'num_unread_general': this.get('num_unread_general') + 1
                     'num_unread_general': this.get('num_unread_general') + 1
                 };
                 };

+ 22 - 282
src/plugins/chatview/bottom-panel.js

@@ -1,85 +1,42 @@
-import tpl_chatbox_message_form from './templates/chatbox_message_form.js';
-import tpl_toolbar from './templates/toolbar.js';
+import './message-form.js';
+import debounce from 'lodash-es/debounce';
+import tpl_bottom_panel from './templates/bottom-panel.js';
 import { ElementView } from '@converse/skeletor/src/element.js';
 import { ElementView } from '@converse/skeletor/src/element.js';
-import { __ } from 'i18n';
-import { _converse, api, converse } from '@converse/headless/core';
-import { html, render } from 'lit';
-import { clearMessages, parseMessageForCommands } from './utils.js';
+import { _converse, api } from '@converse/headless/core';
+import { clearMessages } from './utils.js';
+import { render } from 'lit';
 
 
 import './styles/chat-bottom-panel.scss';
 import './styles/chat-bottom-panel.scss';
 
 
-const { u } = converse.env;
 
 
 export default class ChatBottomPanel extends ElementView {
 export default class ChatBottomPanel extends ElementView {
     events = {
     events = {
-        'click .send-button': 'onFormSubmitted',
+        'click .send-button': 'sendButtonClicked',
         'click .toggle-clear': 'clearMessages'
         'click .toggle-clear': 'clearMessages'
     };
     };
 
 
     async connectedCallback () {
     async connectedCallback () {
         super.connectedCallback();
         super.connectedCallback();
+        this.debouncedRender = debounce(this.render, 100);
         this.model = _converse.chatboxes.get(this.getAttribute('jid'));
         this.model = _converse.chatboxes.get(this.getAttribute('jid'));
-        this.listenTo(this.model, 'change', o => this.onModelChanged(o.changed));
         await this.model.initialized;
         await this.model.initialized;
-        this.listenTo(this.model.messages, 'change:correcting', this.onMessageCorrecting);
-        this.render();
-    }
+        this.listenTo(this.model, 'change:num_unread', this.debouncedRender)
+        this.listenTo(this.model, 'emoji-picker-autocomplete', this.autocompleteInPicker);
 
 
-    onModelChanged (changed) {
-        if ('composing_spoiler' in changed || 'num_unread' in changed || 'scrolled' in changed) {
-            this.renderMessageForm();
-        }
+        this.addEventListener('focusin', ev => this.emitFocused(ev));
+        this.addEventListener('focusout', ev => this.emitBlurred(ev));
+        this.render();
     }
     }
 
 
     render () {
     render () {
-        render(html`<div class="message-form-container"></div>`, this);
-        this.renderMessageForm();
+        render(tpl_bottom_panel({
+            'model': this.model,
+            'viewUnreadMessages': ev => this.viewUnreadMessages(ev)
+        }), this);
     }
     }
 
 
-    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 () {
-        const form_container = this.querySelector('.message-form-container');
-        render(
-            tpl_chatbox_message_form(
-                Object.assign(this.model.toJSON(), {
-                    'onDrop': ev => this.onDrop(ev),
-                    'hint_value': this.querySelector('.spoiler-hint')?.value,
-                    'inputChanged': ev => this.inputChanged(ev),
-                    'message_value': this.querySelector('.chat-textarea')?.value,
-                    'onChange': ev => this.updateCharCounter(ev.target.value),
-                    'onKeyDown': ev => this.onKeyDown(ev),
-                    'onKeyUp': ev => this.onKeyUp(ev),
-                    'onPaste': ev => this.onPaste(ev),
-                    'viewUnreadMessages': ev => this.viewUnreadMessages(ev)
-                })
-            ),
-            form_container
-        );
-        this.addEventListener('focusin', ev => this.emitFocused(ev));
-        this.addEventListener('focusout', ev => this.emitBlurred(ev));
-        this.renderToolbar();
+    sendButtonClicked (ev) {
+        this.querySelector('converse-message-form')?.onFormSubmitted(ev);
     }
     }
 
 
     viewUnreadMessages (ev) {
     viewUnreadMessages (ev) {
@@ -87,19 +44,6 @@ export default class ChatBottomPanel extends ElementView {
         this.model.save({ 'scrolled': false });
         this.model.save({ 'scrolled': false });
     }
     }
 
 
-    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);
-            }
-        }
-    }
-
     emitFocused (ev) {
     emitFocused (ev) {
         _converse.chatboxviews.get(this.getAttribute('jid'))?.emitFocused(ev);
         _converse.chatboxviews.get(this.getAttribute('jid'))?.emitFocused(ev);
     }
     }
@@ -112,18 +56,6 @@ export default class ChatBottomPanel extends ElementView {
         return {};
         return {};
     }
     }
 
 
-    inputChanged (ev) { // eslint-disable-line class-methods-use-this
-        if (ev.target.value) {
-            const height = ev.target.scrollHeight + 'px';
-            if (ev.target.style.height != height) {
-                ev.target.style.height = 'auto';
-                ev.target.style.height = height;
-            }
-        } else {
-            ev.target.style = '';
-        }
-    }
-
     onDrop (evt) {
     onDrop (evt) {
         if (evt.dataTransfer.files.length == 0) {
         if (evt.dataTransfer.files.length == 0) {
             // There are no files to be dropped, so this isn’t a file
             // There are no files to be dropped, so this isn’t a file
@@ -143,210 +75,18 @@ export default class ChatBottomPanel extends ElementView {
         clearMessages(this.model);
         clearMessages(this.model);
     }
     }
 
 
-    parseMessageForCommands (text) {
-        return parseMessageForCommands(this.model, text);
-    }
-
-    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
-        ) {
-            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 (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);
-
-        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();
-    }
-
-    /**
-     * Insert a particular string value into the textarea of this chat box.
-     * @param {string} value - The value to be inserted.
-     * @param {(boolean|string)} [replace] - Whether an existing value
-     *  should be replaced. If set to `true`, the entire textarea will
-     *  be replaced with the new value. If set to a string, then only
-     *  that string will be replaced *if* a position is also specified.
-     * @param {integer} [position] - The end index of the string to be
-     *  replaced with the new value.
-     */
-    insertIntoTextArea (value, replace = false, correcting = false, position) {
-        const textarea = this.querySelector('.chat-textarea');
-        if (correcting) {
-            u.addClass('correcting', textarea);
-        } else {
-            u.removeClass('correcting', textarea);
-        }
-        if (replace) {
-            if (position && typeof replace == 'string') {
-                textarea.value = textarea.value.replace(new RegExp(replace, 'g'), (match, offset) =>
-                    offset == position - replace.length ? value + ' ' : match
-                );
-            } else {
-                textarea.value = value;
-            }
-        } else {
-            let existing = textarea.value;
-            if (existing && existing[existing.length - 1] !== ' ') {
-                existing = existing + ' ';
-            }
-            textarea.value = existing + value + ' ';
-        }
-        const ev = document.createEvent('HTMLEvents');
-        ev.initEvent('change', false, true);
-        textarea.dispatchEvent(ev);
-        u.placeCaretAtEnd(textarea);
-    }
-
-    onEscapePressed (ev) {
-        ev.preventDefault();
-        const idx = this.model.messages.findLastIndex('correcting');
-        const message = idx >= 0 ? this.model.messages.at(idx) : null;
-        if (message) {
-            message.save('correcting', false);
-        }
-        this.insertIntoTextArea('', true, false);
-    }
-
     async autocompleteInPicker (input, value) {
     async autocompleteInPicker (input, value) {
         await api.emojis.initialize();
         await api.emojis.initialize();
-        const emoji_dropdown = this.querySelector('converse-emoji-dropdown');
         const emoji_picker = this.querySelector('converse-emoji-picker');
         const emoji_picker = this.querySelector('converse-emoji-picker');
-        if (emoji_picker && emoji_dropdown) {
+        if (emoji_picker) {
             emoji_picker.model.set({
             emoji_picker.model.set({
                 'ac_position': input.selectionStart,
                 'ac_position': input.selectionStart,
                 'autocompleting': value,
                 'autocompleting': value,
                 'query': value
                 'query': value
             });
             });
-            emoji_dropdown.showMenu();
-            return true;
-        }
-    }
-
-    onKeyDown (ev) {
-        if (ev.ctrlKey) {
-            // When ctrl is pressed, no chars are entered into the textarea.
-            return;
-        }
-        if (!ev.shiftKey && !ev.altKey && !ev.metaKey) {
-            if (ev.keyCode === converse.keycodes.TAB) {
-                const value = u.getCurrentWord(ev.target, null, /(:.*?:)/g);
-                if (value.startsWith(':') && this.autocompleteInPicker(ev.target, value)) {
-                    ev.preventDefault();
-                    ev.stopPropagation();
-                }
-            } else if (ev.keyCode === converse.keycodes.FORWARD_SLASH) {
-                // Forward slash is used to run commands. Nothing to do here.
-                return;
-            } else if (ev.keyCode === converse.keycodes.ESCAPE) {
-                return this.onEscapePressed(ev, this);
-            } else if (ev.keyCode === converse.keycodes.ENTER) {
-                return this.onFormSubmitted(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.model.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.model.editLaterMessage();
-            }
-        }
-        if (
-            [
-                converse.keycodes.SHIFT,
-                converse.keycodes.META,
-                converse.keycodes.META_RIGHT,
-                converse.keycodes.ESCAPE,
-                converse.keycodes.ALT
-            ].includes(ev.keyCode)
-        ) {
-            return;
-        }
-        if (this.model.get('chat_state') !== _converse.COMPOSING) {
-            // Set chat state to composing if keyCode is not a forward-slash
-            // (which would imply an internal command and not a message).
-            this.model.setChatState(_converse.COMPOSING);
-        }
-    }
-
-    updateCharCounter (chars) {
-        if (api.settings.get('message_limit')) {
-            const message_limit = this.querySelector('.message-limit');
-            const counter = api.settings.get('message_limit') - chars.length;
-            message_limit.textContent = counter;
-            if (counter < 1) {
-                u.addClass('error', message_limit);
-            } else {
-                u.removeClass('error', message_limit);
-            }
-        }
-    }
-
-    onKeyUp (ev) {
-        this.updateCharCounter(ev.target.value);
-    }
-
-    onPaste (ev) {
-        ev.stopPropagation();
-        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;
+            const emoji_dropdown = this.querySelector('converse-emoji-dropdown');
+            emoji_dropdown?.showMenu();
         }
         }
-        this.updateCharCounter(ev.clipboardData.getData('text/plain'));
     }
     }
 }
 }
 
 

+ 229 - 0
src/plugins/chatview/message-form.js

@@ -0,0 +1,229 @@
+import tpl_message_form from './templates/message-form.js';
+import { ElementView } from '@converse/skeletor/src/element.js';
+import { __ } from 'i18n';
+import { _converse, api, converse } from "@converse/headless/core";
+import { parseMessageForCommands } from './utils.js';
+
+const { u } = converse.env;
+
+
+export default class MessageForm extends ElementView {
+
+    async connectedCallback () {
+        super.connectedCallback();
+        this.model = _converse.chatboxes.get(this.getAttribute('jid'));
+        await this.model.initialized;
+        this.listenTo(this.model.messages, 'change:correcting', this.onMessageCorrecting);
+        this.render();
+    }
+
+    toHTML () {
+        return tpl_message_form(
+            Object.assign(this.model.toJSON(), {
+                'onDrop': ev => this.onDrop(ev),
+                'hint_value': this.querySelector('.spoiler-hint')?.value,
+                'message_value': this.querySelector('.chat-textarea')?.value,
+                'onChange': ev => this.model.set({'draft': ev.target.value}),
+                'onKeyDown': ev => this.onKeyDown(ev),
+                'onKeyUp': ev => this.onKeyUp(ev),
+                'onPaste': ev => this.onPaste(ev),
+                'viewUnreadMessages': ev => this.viewUnreadMessages(ev)
+            })
+        );
+    }
+
+    /**
+     * Insert a particular string value into the textarea of this chat box.
+     * @param {string} value - The value to be inserted.
+     * @param {(boolean|string)} [replace] - Whether an existing value
+     *  should be replaced. If set to `true`, the entire textarea will
+     *  be replaced with the new value. If set to a string, then only
+     *  that string will be replaced *if* a position is also specified.
+     * @param {integer} [position] - The end index of the string to be
+     *  replaced with the new value.
+     */
+    insertIntoTextArea (value, replace = false, correcting = false, position) {
+        const textarea = this.querySelector('.chat-textarea');
+        if (correcting) {
+            u.addClass('correcting', textarea);
+        } else {
+            u.removeClass('correcting', textarea);
+        }
+        if (replace) {
+            if (position && typeof replace == 'string') {
+                textarea.value = textarea.value.replace(new RegExp(replace, 'g'), (match, offset) =>
+                    offset == position - replace.length ? value + ' ' : match
+                );
+            } else {
+                textarea.value = value;
+            }
+        } else {
+            let existing = textarea.value;
+            if (existing && existing[existing.length - 1] !== ' ') {
+                existing = existing + ' ';
+            }
+            textarea.value = existing + value + ' ';
+        }
+        const ev = document.createEvent('HTMLEvents');
+        ev.initEvent('change', false, true);
+        textarea.dispatchEvent(ev);
+        u.placeCaretAtEnd(textarea);
+    }
+
+    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);
+            }
+        }
+    }
+
+    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);
+    }
+
+    onPaste (ev) {
+        ev.stopPropagation();
+        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.model.set({'draft': ev.clipboardData.getData('text/plain')});
+    }
+
+    onKeyUp (ev) {
+        this.model.set({'draft': ev.target.value});
+    }
+
+    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(':')) {
+                    ev.preventDefault();
+                    ev.stopPropagation();
+                    this.model.trigger('emoji-picker-autocomplete', ev.target, value);
+                }
+            } 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(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.model.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.model.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);
+        }
+    }
+
+    parseMessageForCommands (text) {
+        // Wrap util so that we can override in the MUC message-form component
+        return parseMessageForCommands(this.model, text);
+    }
+
+    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
+        ) {
+            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.model.set({'draft': ''});
+        }
+        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);
+
+        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();
+    }
+}
+
+api.elements.define('converse-message-form', MessageForm);

+ 29 - 3
src/plugins/chatview/templates/bottom-panel.js

@@ -1,4 +1,30 @@
+import { __ } from 'i18n';
+import { api } from '@converse/headless/core';
+import { html } from 'lit';
 
 
-            <div class="bottom-panel">
-                <div class="message-form-container"></div>
-            </div>
+
+export default (o) => {
+    const unread_msgs = __('You have unread messages');
+    const message_limit = api.settings.get('message_limit');
+    const show_call_button = api.settings.get('visible_toolbar_buttons').call;
+    const show_emoji_button = api.settings.get('visible_toolbar_buttons').emoji;
+    const show_send_button = api.settings.get('show_send_button');
+    const show_spoiler_button = api.settings.get('visible_toolbar_buttons').spoiler;
+    const show_toolbar = api.settings.get('show_toolbar');
+    return html`
+        ${ o.model.get('scrolled') && o.model.get('num_unread') ?
+                html`<div class="new-msgs-indicator" @click=${ev => o.viewUnreadMessages(ev)}>▼ ${ unread_msgs } ▼</div>` : '' }
+        ${api.settings.get('show_toolbar') ? html`
+            <converse-chat-toolbar
+                class="chat-toolbar no-text-select"
+                .model=${o.model}
+                ?composing_spoiler="${o.model.get('composing_spoiler')}"
+                ?show_call_button="${show_call_button}"
+                ?show_emoji_button="${show_emoji_button}"
+                ?show_send_button="${show_send_button}"
+                ?show_spoiler_button="${show_spoiler_button}"
+                ?show_toolbar="${show_toolbar}"
+                message_limit="${message_limit}"></converse-chat-toolbar>` : '' }
+        <converse-message-form jid="${o.model.get('jid')}"></converse-message-form>
+    `;
+}

+ 29 - 0
src/plugins/chatview/templates/message-form.js

@@ -0,0 +1,29 @@
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core";
+import { html } from "lit";
+import { resetElementHeight } from '../utils.js';
+
+
+export default (o) => {
+    const label_message = o.composing_spoiler ? __('Hidden message') : __('Message');
+    const label_spoiler_hint = __('Optional hint');
+    const show_send_button = api.settings.get('show_send_button');
+
+    return html`
+        <form class="sendXMPPMessage">
+            <input type="text" placeholder="${label_spoiler_hint || ''}" value="${o.hint_value || ''}" class="${o.composing_spoiler ? '' : 'hidden'} spoiler-hint"/>
+            <textarea
+                autofocus
+                type="text"
+                @drop=${o.onDrop}
+                @input=${resetElementHeight}
+                @keydown=${o.onKeyDown}
+                @keyup=${o.onKeyUp}
+                @paste=${o.onPaste}
+                @change=${o.onChange}
+                class="chat-textarea
+                    ${ show_send_button ? 'chat-textarea-send-button' : '' }
+                    ${ o.composing_spoiler ? 'spoiler' : '' }"
+                placeholder="${label_message}">${ o.message_value || '' }</textarea>
+        </form>`;
+}

+ 0 - 28
src/plugins/chatview/templates/toolbar.js

@@ -1,28 +0,0 @@
-import 'shared/chat/toolbar.js';
-import { api } from '@converse/headless/core.js';
-import { html } from "lit";
-
-export default (o) => {
-    const message_limit = api.settings.get('message_limit');
-    const show_call_button = api.settings.get('visible_toolbar_buttons').call;
-    const show_emoji_button = api.settings.get('visible_toolbar_buttons').emoji;
-    const show_send_button = api.settings.get('show_send_button');
-    const show_spoiler_button = api.settings.get('visible_toolbar_buttons').spoiler;
-    const show_toolbar = api.settings.get('show_toolbar');
-    return html`
-        <converse-chat-toolbar
-            .chatview=${o.chatview}
-            .model=${o.model}
-            ?composing_spoiler="${o.composing_spoiler}"
-            ?hidden_occupants="${o.hidden_occupants}"
-            ?is_groupchat="${o.is_groupchat}"
-            ?show_call_button="${show_call_button}"
-            ?show_emoji_button="${show_emoji_button}"
-            ?show_occupants_toggle="${o.show_occupants_toggle}"
-            ?show_send_button="${show_send_button}"
-            ?show_spoiler_button="${show_spoiler_button}"
-            ?show_toolbar="${show_toolbar}"
-            message_limit="${message_limit}"
-        ></converse-chat-toolbar>
-    `;
-}

+ 24 - 24
src/plugins/chatview/tests/chatbox.js

@@ -59,8 +59,8 @@ describe("Chatboxes", function () {
 
 
             const textarea = view.querySelector('textarea.chat-textarea');
             const textarea = view.querySelector('textarea.chat-textarea');
             textarea.value = '/clear';
             textarea.value = '/clear';
-            const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-            bottom_panel.onKeyDown({
+            const message_form = view.querySelector('converse-message-form');
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13 // Enter
                 keyCode: 13 // Enter
@@ -264,14 +264,14 @@ describe("Chatboxes", function () {
                 const toolbar = view.querySelector('.chat-toolbar');
                 const toolbar = view.querySelector('.chat-toolbar');
                 const counter = toolbar.querySelector('.message-limit');
                 const counter = toolbar.querySelector('.message-limit');
                 expect(counter.textContent).toBe('200');
                 expect(counter.textContent).toBe('200');
-                view.getBottomPanel().insertIntoTextArea('hello world');
-                expect(counter.textContent).toBe('188');
+                view.getMessageForm().insertIntoTextArea('hello world');
+                await u.waitUntil(() => counter.textContent === '188');
 
 
                 toolbar.querySelector('.toggle-emojis').click();
                 toolbar.querySelector('.toggle-emojis').click();
                 const picker = await u.waitUntil(() => view.querySelector('.emoji-picker__lists'));
                 const picker = await u.waitUntil(() => view.querySelector('.emoji-picker__lists'));
                 const item = await u.waitUntil(() => picker.querySelector('.emoji-picker li.insert-emoji a'));
                 const item = await u.waitUntil(() => picker.querySelector('.emoji-picker li.insert-emoji a'));
                 item.click()
                 item.click()
-                expect(counter.textContent).toBe('179');
+                await u.waitUntil(() => counter.textContent === '179');
 
 
                 const textarea = view.querySelector('.chat-textarea');
                 const textarea = view.querySelector('.chat-textarea');
                 const ev = {
                 const ev = {
@@ -279,15 +279,15 @@ describe("Chatboxes", function () {
                     preventDefault: function preventDefault () {},
                     preventDefault: function preventDefault () {},
                     keyCode: 13 // Enter
                     keyCode: 13 // Enter
                 };
                 };
-                const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-                bottom_panel.onKeyDown(ev);
+                const message_form = view.querySelector('converse-message-form');
+                message_form.onKeyDown(ev);
                 await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
                 await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
-                bottom_panel.onKeyUp(ev);
+                message_form.onKeyUp(ev);
                 expect(counter.textContent).toBe('200');
                 expect(counter.textContent).toBe('200');
 
 
                 textarea.value = 'hello world';
                 textarea.value = 'hello world';
-                bottom_panel.onKeyUp(ev);
-                expect(counter.textContent).toBe('189');
+                message_form.onKeyUp(ev);
+                await u.waitUntil(() => counter.textContent === '189');
                 done();
                 done();
             }));
             }));
 
 
@@ -430,8 +430,8 @@ describe("Chatboxes", function () {
                     spyOn(_converse.connection, 'send');
                     spyOn(_converse.connection, 'send');
                     spyOn(_converse.api, "trigger").and.callThrough();
                     spyOn(_converse.api, "trigger").and.callThrough();
 
 
-                    const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-                    bottom_panel.onKeyDown({
+                    const message_form = view.querySelector('converse-message-form');
+                    message_form.onKeyDown({
                         target: view.querySelector('textarea.chat-textarea'),
                         target: view.querySelector('textarea.chat-textarea'),
                         keyCode: 1
                         keyCode: 1
                     });
                     });
@@ -446,7 +446,7 @@ describe("Chatboxes", function () {
                     expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
                     expect(stanza.childNodes[2].tagName).toBe('no-permanent-store');
 
 
                     // The notification is not sent again
                     // The notification is not sent again
-                    bottom_panel.onKeyDown({
+                    message_form.onKeyDown({
                         target: view.querySelector('textarea.chat-textarea'),
                         target: view.querySelector('textarea.chat-textarea'),
                         keyCode: 1
                         keyCode: 1
                     });
                     });
@@ -470,8 +470,8 @@ describe("Chatboxes", function () {
                     expect(view.model.get('chat_state')).toBe('active');
                     expect(view.model.get('chat_state')).toBe('active');
                     spyOn(_converse.connection, 'send');
                     spyOn(_converse.connection, 'send');
                     spyOn(_converse.api, "trigger").and.callThrough();
                     spyOn(_converse.api, "trigger").and.callThrough();
-                    const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-                    bottom_panel.onKeyDown({
+                    const message_form = view.querySelector('converse-message-form');
+                    message_form.onKeyDown({
                         target: view.querySelector('textarea.chat-textarea'),
                         target: view.querySelector('textarea.chat-textarea'),
                         keyCode: 1
                         keyCode: 1
                     });
                     });
@@ -579,8 +579,8 @@ describe("Chatboxes", function () {
                     const view = _converse.chatboxviews.get(contact_jid);
                     const view = _converse.chatboxviews.get(contact_jid);
                     spyOn(view.model, 'setChatState').and.callThrough();
                     spyOn(view.model, 'setChatState').and.callThrough();
                     expect(view.model.get('chat_state')).toBe('active');
                     expect(view.model.get('chat_state')).toBe('active');
-                    const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-                    bottom_panel.onKeyDown({
+                    const message_form = view.querySelector('converse-message-form');
+                    message_form.onKeyDown({
                         target: view.querySelector('textarea.chat-textarea'),
                         target: view.querySelector('textarea.chat-textarea'),
                         keyCode: 1
                         keyCode: 1
                     });
                     });
@@ -612,14 +612,14 @@ describe("Chatboxes", function () {
                     // Test #359. A paused notification should not be sent
                     // Test #359. A paused notification should not be sent
                     // out if the user simply types longer than the
                     // out if the user simply types longer than the
                     // timeout.
                     // timeout.
-                    bottom_panel.onKeyDown({
+                    message_form.onKeyDown({
                         target: view.querySelector('textarea.chat-textarea'),
                         target: view.querySelector('textarea.chat-textarea'),
                         keyCode: 1
                         keyCode: 1
                     });
                     });
                     expect(view.model.setChatState).toHaveBeenCalled();
                     expect(view.model.setChatState).toHaveBeenCalled();
                     expect(view.model.get('chat_state')).toBe('composing');
                     expect(view.model.get('chat_state')).toBe('composing');
 
 
-                    bottom_panel.onKeyDown({
+                    message_form.onKeyDown({
                         target: view.querySelector('textarea.chat-textarea'),
                         target: view.querySelector('textarea.chat-textarea'),
                         keyCode: 1
                         keyCode: 1
                     });
                     });
@@ -718,8 +718,8 @@ describe("Chatboxes", function () {
                         `</message>`);
                         `</message>`);
 
 
 
 
-                    const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-                    bottom_panel.onKeyDown({
+                    const message_form = view.querySelector('converse-message-form');
+                    message_form.onKeyDown({
                         target: view.querySelector('textarea.chat-textarea'),
                         target: view.querySelector('textarea.chat-textarea'),
                         keyCode: 1
                         keyCode: 1
                     });
                     });
@@ -937,10 +937,10 @@ describe("Chatboxes", function () {
             await u.waitUntil(() => view.querySelector('.chat-msg'));
             await u.waitUntil(() => view.querySelector('.chat-msg'));
 
 
             message = '/clear';
             message = '/clear';
-            const bottom_panel = view.querySelector('converse-chat-bottom-panel');
+            const message_form = view.querySelector('converse-message-form');
             spyOn(window, 'confirm').and.callFake(() => true);
             spyOn(window, 'confirm').and.callFake(() => true);
             view.querySelector('.chat-textarea').value = message;
             view.querySelector('.chat-textarea').value = message;
-            bottom_panel.onKeyDown({
+            message_form.onKeyDown({
                 target: view.querySelector('textarea.chat-textarea'),
                 target: view.querySelector('textarea.chat-textarea'),
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13
                 keyCode: 13
@@ -1191,7 +1191,7 @@ describe("Chatboxes", function () {
             const view = _converse.chatboxviews.get(sender_jid);
             const view = _converse.chatboxviews.get(sender_jid);
             await u.waitUntil(() => view.model.messages.length);
             await u.waitUntil(() => view.model.messages.length);
             expect(select_msgs_indicator().textContent).toBe('1');
             expect(select_msgs_indicator().textContent).toBe('1');
-            const chat_new_msgs_indicator = view.querySelector('.new-msgs-indicator');
+            const chat_new_msgs_indicator = await u.waitUntil(() => view.querySelector('.new-msgs-indicator'));
             chat_new_msgs_indicator.click();
             chat_new_msgs_indicator.click();
             await u.waitUntil(() => select_msgs_indicator() === undefined);
             await u.waitUntil(() => select_msgs_indicator() === undefined);
             done();
             done();

+ 15 - 15
src/plugins/chatview/tests/corrections.js

@@ -14,15 +14,15 @@ describe("A Chat Message", function () {
         const view = _converse.api.chatviews.get(contact_jid);
         const view = _converse.api.chatviews.get(contact_jid);
         const textarea = view.querySelector('textarea.chat-textarea');
         const textarea = view.querySelector('textarea.chat-textarea');
         expect(textarea.value).toBe('');
         expect(textarea.value).toBe('');
-        const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-        bottom_panel.onKeyDown({
+        const message_form = view.querySelector('converse-message-form');
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             keyCode: 38 // Up arrow
             keyCode: 38 // Up arrow
         });
         });
         expect(textarea.value).toBe('');
         expect(textarea.value).toBe('');
 
 
         textarea.value = 'But soft, what light through yonder airlock breaks?';
         textarea.value = 'But soft, what light through yonder airlock breaks?';
-        bottom_panel.onKeyDown({
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
@@ -34,7 +34,7 @@ describe("A Chat Message", function () {
 
 
         const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
         const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
         expect(textarea.value).toBe('');
         expect(textarea.value).toBe('');
-        bottom_panel.onKeyDown({
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             keyCode: 38 // Up arrow
             keyCode: 38 // Up arrow
         });
         });
@@ -46,7 +46,7 @@ describe("A Chat Message", function () {
         spyOn(_converse.connection, 'send');
         spyOn(_converse.connection, 'send');
         let new_text = 'But soft, what light through yonder window breaks?';
         let new_text = 'But soft, what light through yonder window breaks?';
         textarea.value = new_text;
         textarea.value = new_text;
-        bottom_panel.onKeyDown({
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
@@ -80,7 +80,7 @@ describe("A Chat Message", function () {
 
 
         // Test that pressing the down arrow cancels message correction
         // Test that pressing the down arrow cancels message correction
         await u.waitUntil(() => textarea.value === '')
         await u.waitUntil(() => textarea.value === '')
-        bottom_panel.onKeyDown({
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             keyCode: 38 // Up arrow
             keyCode: 38 // Up arrow
         });
         });
@@ -89,7 +89,7 @@ describe("A Chat Message", function () {
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         expect(view.querySelectorAll('.chat-msg').length).toBe(1);
         await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
         await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
         expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
         expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
-        bottom_panel.onKeyDown({
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             keyCode: 40 // Down arrow
             keyCode: 40 // Down arrow
         });
         });
@@ -100,7 +100,7 @@ describe("A Chat Message", function () {
 
 
         new_text = 'It is the east, and Juliet is the one.';
         new_text = 'It is the east, and Juliet is the one.';
         textarea.value = new_text;
         textarea.value = new_text;
-        bottom_panel.onKeyDown({
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
@@ -110,14 +110,14 @@ describe("A Chat Message", function () {
         expect(view.querySelectorAll('.chat-msg').length).toBe(2);
         expect(view.querySelectorAll('.chat-msg').length).toBe(2);
 
 
         textarea.value =  'Arise, fair sun, and kill the envious moon';
         textarea.value =  'Arise, fair sun, and kill the envious moon';
-        bottom_panel.onKeyDown({
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
         });
         });
         await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
 
 
-        bottom_panel.onKeyDown({
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             keyCode: 38 // Up arrow
             keyCode: 38 // Up arrow
         });
         });
@@ -129,7 +129,7 @@ describe("A Chat Message", function () {
 
 
         textarea.selectionEnd = 0; // Happens by pressing up,
         textarea.selectionEnd = 0; // Happens by pressing up,
                                 // but for some reason not in tests, so we set it manually.
                                 // but for some reason not in tests, so we set it manually.
-        bottom_panel.onKeyDown({
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             keyCode: 38 // Up arrow
             keyCode: 38 // Up arrow
         });
         });
@@ -140,7 +140,7 @@ describe("A Chat Message", function () {
         await u.waitUntil(() => u.hasClass('correcting', sizzle('.chat-msg', view)[1]), 500);
         await u.waitUntil(() => u.hasClass('correcting', sizzle('.chat-msg', view)[1]), 500);
 
 
         textarea.value = 'It is the east, and Juliet is the sun.';
         textarea.value = 'It is the east, and Juliet is the sun.';
-        bottom_panel.onKeyDown({
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
@@ -176,8 +176,8 @@ describe("A Chat Message", function () {
         const textarea = view.querySelector('textarea.chat-textarea');
         const textarea = view.querySelector('textarea.chat-textarea');
 
 
         textarea.value = 'But soft, what light through yonder airlock breaks?';
         textarea.value = 'But soft, what light through yonder airlock breaks?';
-        const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-        bottom_panel.onKeyDown({
+        const message_form = view.querySelector('converse-message-form');
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
@@ -204,7 +204,7 @@ describe("A Chat Message", function () {
 
 
         spyOn(_converse.connection, 'send');
         spyOn(_converse.connection, 'send');
         textarea.value = 'But soft, what light through yonder window breaks?';
         textarea.value = 'But soft, what light through yonder window breaks?';
-        bottom_panel.onKeyDown({
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter

+ 2 - 2
src/plugins/chatview/tests/markers.js

@@ -125,8 +125,8 @@ describe("A XEP-0333 Chat Marker", function () {
         const view = _converse.api.chatviews.get(muc_jid);
         const view = _converse.api.chatviews.get(muc_jid);
         const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
         const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
         textarea.value = 'But soft, what light through yonder airlock breaks?';
         textarea.value = 'But soft, what light through yonder airlock breaks?';
-        const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-        bottom_panel.onKeyDown({
+        const message_form = view.querySelector('converse-muc-message-form');
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter

+ 3 - 4
src/plugins/chatview/tests/messages.js

@@ -1213,14 +1213,13 @@ describe("A Chat Message", function () {
             }
             }
             await Promise.all(promises);
             await Promise.all(promises);
 
 
-            const indicator_el = view.querySelector('.new-msgs-indicator');
-            expect(u.isVisible(indicator_el)).toBeTruthy();
+            const indicator_el = await u.waitUntil(() => view.querySelector('.new-msgs-indicator'));
 
 
             expect(view.model.get('scrolled')).toBe(true);
             expect(view.model.get('scrolled')).toBe(true);
             expect(view.querySelector('.chat-content').scrollTop).toBe(0);
             expect(view.querySelector('.chat-content').scrollTop).toBe(0);
             indicator_el.click();
             indicator_el.click();
-            expect(u.isVisible(indicator_el)).toBeFalsy();
-            expect(view.model.get('scrolled')).toBe(false);
+            await u.waitUntil(() => !view.querySelector('.new-msgs-indicator'));
+            await u.waitUntil(() => !view.model.get('scrolled'));
             done();
             done();
         }));
         }));
 
 

+ 3 - 3
src/plugins/chatview/tests/receipts.js

@@ -110,8 +110,8 @@ describe("A delivery receipt", function () {
         const view = _converse.chatboxviews.get(contact_jid);
         const view = _converse.chatboxviews.get(contact_jid);
         const textarea = view.querySelector('textarea.chat-textarea');
         const textarea = view.querySelector('textarea.chat-textarea');
         textarea.value = 'But soft, what light through yonder airlock breaks?';
         textarea.value = 'But soft, what light through yonder airlock breaks?';
-        const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-        bottom_panel.onKeyDown({
+        const message_form = view.querySelector('converse-message-form');
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
@@ -132,7 +132,7 @@ describe("A delivery receipt", function () {
         // Also handle receipts with type 'chat'. See #1353
         // Also handle receipts with type 'chat'. See #1353
         spyOn(_converse, 'handleMessageStanza').and.callThrough();
         spyOn(_converse, 'handleMessageStanza').and.callThrough();
         textarea.value = 'Another message';
         textarea.value = 'Another message';
-        bottom_panel.onKeyDown({
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter

+ 4 - 4
src/plugins/chatview/tests/spoilers.js

@@ -112,8 +112,8 @@ describe("A spoiler message", function () {
 
 
         const textarea = view.querySelector('.chat-textarea');
         const textarea = view.querySelector('.chat-textarea');
         textarea.value = 'This is the spoiler';
         textarea.value = 'This is the spoiler';
-        const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-        bottom_panel.onKeyDown({
+        const message_form = view.querySelector('converse-message-form');
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13
             keyCode: 13
@@ -193,8 +193,8 @@ describe("A spoiler message", function () {
         const hint_input = view.querySelector('.spoiler-hint');
         const hint_input = view.querySelector('.spoiler-hint');
         hint_input.value = 'This is the hint';
         hint_input.value = 'This is the hint';
 
 
-        const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-        bottom_panel.onKeyDown({
+        const message_form = view.querySelector('converse-message-form');
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13
             keyCode: 13

+ 12 - 0
src/plugins/chatview/utils.js

@@ -48,3 +48,15 @@ export function parseMessageForCommands (chat, text) {
         }
         }
     }
     }
 }
 }
+
+export function resetElementHeight (ev) {
+    if (ev.target.value) {
+        const height = ev.target.scrollHeight + 'px';
+        if (ev.target.style.height != height) {
+            ev.target.style.height = 'auto';
+            ev.target.style.height = height;
+        }
+    } else {
+        ev.target.style = '';
+    }
+}

+ 0 - 1
src/plugins/chatview/view.js

@@ -141,7 +141,6 @@ export default class ChatView extends BaseChatView {
     }
     }
 
 
     afterShown () {
     afterShown () {
-        this.model.clearUnreadMsgCounter();
         this.model.setChatState(_converse.ACTIVE);
         this.model.setChatState(_converse.ACTIVE);
         this.scrollDown();
         this.scrollDown();
         this.maybeFocus();
         this.maybeFocus();

+ 1 - 1
src/plugins/headlines-view/templates/chat-head.js

@@ -8,7 +8,7 @@ export default (o) => {
     return html`
     return html`
         <div class="chatbox-title ${ o.status ? '' :  "chatbox-title--no-desc"}">
         <div class="chatbox-title ${ o.status ? '' :  "chatbox-title--no-desc"}">
             <div class="chatbox-title--row">
             <div class="chatbox-title--row">
-                ${ (!_converse.api.settings.get("singleton")) ? html`<div class="chatbox-navback"><i class="fa fa-arrow-left"></i></div>` : '' }
+                ${ (!_converse.api.settings.get("singleton")) ?  html`<converse-controlbox-navback jid="${o.jid}"></converse-controlbox-navback>` : '' }
                 <div class="chatbox-title__text" title="${o.jid}">${ o.display_name }</div>
                 <div class="chatbox-title__text" title="${o.jid}">${ o.display_name }</div>
             </div>
             </div>
             <div class="chatbox-title__buttons row no-gutters">
             <div class="chatbox-title__buttons row no-gutters">

+ 11 - 46
src/plugins/muc-views/bottom-panel.js

@@ -4,7 +4,6 @@ import debounce from 'lodash-es/debounce';
 import tpl_muc_bottom_panel from './templates/muc-bottom-panel.js';
 import tpl_muc_bottom_panel from './templates/muc-bottom-panel.js';
 import { __ } from 'i18n';
 import { __ } from 'i18n';
 import { _converse, api, converse } from "@converse/headless/core";
 import { _converse, api, converse } from "@converse/headless/core";
-import { getAutoCompleteListItem, parseMessageForMUCCommands } from './utils.js';
 import { render } from 'lit';
 import { render } from 'lit';
 
 
 import './styles/muc-bottom-panel.scss';
 import './styles/muc-bottom-panel.scss';
@@ -14,15 +13,15 @@ export default class MUCBottomPanel extends BottomPanel {
 
 
     events = {
     events = {
         'click .hide-occupants': 'hideOccupants',
         'click .hide-occupants': 'hideOccupants',
-        'click .send-button': 'onFormSubmitted',
+        'click .send-button': 'sendButtonClicked',
     }
     }
 
 
     async connectedCallback () {
     async connectedCallback () {
         // this.model gets set in the super method and we also wait there for this.model.initialized
         // this.model gets set in the super method and we also wait there for this.model.initialized
         await super.connectedCallback();
         await super.connectedCallback();
         this.debouncedRender = debounce(this.render, 100);
         this.debouncedRender = debounce(this.render, 100);
-        this.listenTo(this.model, 'change:composing_spoiler', this.renderMessageForm);
         this.listenTo(this.model, 'change:hidden_occupants', this.debouncedRender);
         this.listenTo(this.model, 'change:hidden_occupants', this.debouncedRender);
+        this.listenTo(this.model, 'change:num_unread_general', this.debouncedRender)
         this.listenTo(this.model.features, 'change:moderated', this.debouncedRender);
         this.listenTo(this.model.features, 'change:moderated', this.debouncedRender);
         this.listenTo(this.model.occupants, 'add', this.renderIfOwnOccupant)
         this.listenTo(this.model.occupants, 'add', this.renderIfOwnOccupant)
         this.listenTo(this.model.occupants, 'change:role', this.renderIfOwnOccupant);
         this.listenTo(this.model.occupants, 'change:role', this.renderIfOwnOccupant);
@@ -33,17 +32,21 @@ export default class MUCBottomPanel extends BottomPanel {
     render () {
     render () {
         const entered = this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED;
         const entered = this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED;
         const can_edit = entered && !(this.model.features.get('moderated') && this.model.getOwnRole() === 'visitor');
         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();
-        }
+        render(tpl_muc_bottom_panel({
+            can_edit, entered,
+            'model': this.model,
+            'viewUnreadMessages': ev => this.viewUnreadMessages(ev)
+        }), this);
     }
     }
 
 
     renderIfOwnOccupant (o) {
     renderIfOwnOccupant (o) {
         (o.get('jid') === _converse.bare_jid) && this.debouncedRender();
         (o.get('jid') === _converse.bare_jid) && this.debouncedRender();
     }
     }
 
 
+    sendButtonClicked (ev) {
+        this.querySelector('converse-message-form')?.onFormSubmitted(ev);
+    }
+
     getToolbarOptions () {
     getToolbarOptions () {
         return Object.assign(super.getToolbarOptions(), {
         return Object.assign(super.getToolbarOptions(), {
             'is_groupchat': true,
             'is_groupchat': true,
@@ -52,49 +55,11 @@ export default class MUCBottomPanel extends BottomPanel {
         });
         });
     }
     }
 
 
-    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));
-    }
-
     hideOccupants (ev) {
     hideOccupants (ev) {
         ev?.preventDefault?.();
         ev?.preventDefault?.();
         ev?.stopPropagation?.();
         ev?.stopPropagation?.();
         this.model.save({ 'hidden_occupants': true });
         this.model.save({ 'hidden_occupants': true });
     }
     }
-
-    onKeyDown (ev) {
-        if (this.mention_auto_complete.onKeyDown(ev)) {
-            return;
-        }
-        super.onKeyDown(ev);
-    }
-
-    onKeyUp (ev) {
-        this.mention_auto_complete.evaluate(ev);
-        super.onKeyUp(ev);
-    }
-
-    parseMessageForCommands (text) {
-        return parseMessageForMUCCommands(this.model, text);
-    }
 }
 }
 
 
 api.elements.define('converse-muc-bottom-panel', MUCBottomPanel);
 api.elements.define('converse-muc-bottom-panel', MUCBottomPanel);

+ 70 - 0
src/plugins/muc-views/message-form.js

@@ -0,0 +1,70 @@
+import MessageForm from 'plugins/chatview/message-form.js';
+import tpl_muc_message_form from './templates/message-form.js';
+import { _converse, api, converse } from "@converse/headless/core";
+import { getAutoCompleteListItem, parseMessageForMUCCommands } from './utils.js';
+
+
+export default class MUCMessageForm extends MessageForm {
+
+    toHTML () {
+        return tpl_muc_message_form(
+            Object.assign(this.model.toJSON(), {
+                'onDrop': ev => this.onDrop(ev),
+                'hint_value': this.querySelector('.spoiler-hint')?.value,
+                'message_value': this.querySelector('.chat-textarea')?.value,
+                'onChange': ev => this.model.set({'draft': ev.target.value}),
+                'onKeyDown': ev => this.onKeyDown(ev),
+                'onKeyUp': ev => this.onKeyUp(ev),
+                'onPaste': ev => this.onPaste(ev),
+                'viewUnreadMessages': ev => this.viewUnreadMessages(ev)
+            }));
+    }
+
+    afterRender () {
+        const entered = this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED;
+        const can_edit = entered && !(this.model.features.get('moderated') && this.model.getOwnRole() === 'visitor');
+        if (entered && can_edit) {
+            this.initMentionAutoComplete();
+        }
+    }
+
+    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));
+    }
+
+    parseMessageForCommands (text) {
+        return parseMessageForMUCCommands(this.model, text);
+    }
+
+    getAutoCompleteList () {
+        return this.model.getAllKnownNicknames().map(nick => ({ 'label': nick, 'value': `@${nick}` }));
+    }
+
+    onKeyDown (ev) {
+        if (this.mention_auto_complete.onKeyDown(ev)) {
+            return;
+        }
+        super.onKeyDown(ev);
+    }
+
+    onKeyUp (ev) {
+        this.mention_auto_complete.evaluate(ev);
+        super.onKeyUp(ev);
+    }
+}
+
+api.elements.define('converse-muc-message-form', MUCMessageForm);

+ 0 - 1
src/plugins/muc-views/muc.js

@@ -53,7 +53,6 @@ export default class MUCView extends BaseChatView {
      */
      */
     afterShown () {
     afterShown () {
         if (!this.model.get('hidden') && !this.model.get('minimized')) {
         if (!this.model.get('hidden') && !this.model.get('minimized')) {
-            this.model.clearUnreadMsgCounter();
             this.scrollDown();
             this.scrollDown();
         }
         }
     }
     }

+ 0 - 24
src/plugins/muc-views/styles/index.scss

@@ -159,27 +159,3 @@ converse-muc-destroyed {
         }
         }
     }
     }
 }
 }
-
-
-@include media-breakpoint-down(sm) {
-    .conversejs {
-        converse-chats.converse-mobile,
-        converse-chats.converse-overlayed,
-        converse-chats.converse-fullscreen {
-            .chatbox {
-                .box-flyout {
-                    .chat-head-chatroom {
-                        .chatbox-navback {
-                            margin-right: 0 !important;
-                            .fa-arrow-left {
-                                &:before {
-                                    color: var(--chatroom-head-color);
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        }
-    }
-}

+ 2 - 3
src/plugins/chatview/templates/chatbox_message_form.js → src/plugins/muc-views/templates/message-form.js

@@ -1,6 +1,7 @@
 import { __ } from 'i18n';
 import { __ } from 'i18n';
 import { api } from "@converse/headless/core";
 import { api } from "@converse/headless/core";
 import { html } from "lit";
 import { html } from "lit";
+import { resetElementHeight } from 'plugins/chatview/utils.js';
 
 
 
 
 export default (o) => {
 export default (o) => {
@@ -15,16 +16,14 @@ export default (o) => {
             <input type="submit" class="btn btn-primary" name="join" value="Join"/>
             <input type="submit" class="btn btn-primary" name="join" value="Join"/>
         </form>
         </form>
         <form class="sendXMPPMessage">
         <form class="sendXMPPMessage">
-            <span class="chat-toolbar no-text-select"></span>
             <input type="text" placeholder="${label_spoiler_hint || ''}" value="${o.hint_value || ''}" class="${o.composing_spoiler ? '' : 'hidden'} spoiler-hint"/>
             <input type="text" placeholder="${label_spoiler_hint || ''}" value="${o.hint_value || ''}" class="${o.composing_spoiler ? '' : 'hidden'} spoiler-hint"/>
-
             <div class="suggestion-box">
             <div class="suggestion-box">
                 <ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul>
                 <ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul>
                 <textarea
                 <textarea
                     autofocus
                     autofocus
                     type="text"
                     type="text"
                     @drop=${o.onDrop}
                     @drop=${o.onDrop}
-                    @input=${o.inputChanged}
+                    @input=${resetElementHeight}
                     @keydown=${o.onKeyDown}
                     @keydown=${o.onKeyDown}
                     @keyup=${o.onKeyUp}
                     @keyup=${o.onKeyUp}
                     @paste=${o.onPaste}
                     @paste=${o.onPaste}

+ 31 - 4
src/plugins/muc-views/templates/muc-bottom-panel.js

@@ -1,19 +1,46 @@
+import '../message-form.js';
+import 'shared/chat/toolbar.js';
 import tpl_muc_nickname_form from './muc-nickname-form.js';
 import tpl_muc_nickname_form from './muc-nickname-form.js';
 import { __ } from 'i18n';
 import { __ } from 'i18n';
 import { api, converse } from "@converse/headless/core";
 import { api, converse } from "@converse/headless/core";
 import { html } from "lit";
 import { html } from "lit";
 
 
 
 
-const tpl_can_edit = () => html`
-    <div class="emoji-picker__container dropup"></div>
-    <div class="message-form-container">`;
+const tpl_can_edit = (o) => {
+    const message_limit = api.settings.get('message_limit');
+    const show_call_button = api.settings.get('visible_toolbar_buttons').call;
+    const show_emoji_button = api.settings.get('visible_toolbar_buttons').emoji;
+    const show_send_button = api.settings.get('show_send_button');
+    const show_spoiler_button = api.settings.get('visible_toolbar_buttons').spoiler;
+    const show_toolbar = api.settings.get('show_toolbar');
+    return html`
+        ${show_toolbar ? html`
+            <converse-chat-toolbar
+                class="chat-toolbar no-text-select"
+                .model=${o.model}
+                ?composing_spoiler="${o.model.get('composing_spoiler')}"
+                ?hidden_occupants="${o.model.get('hidden_occupants')}"
+                ?is_groupchat="${o.model.get('is_groupchat')}"
+                ?show_call_button="${show_call_button}"
+                ?show_emoji_button="${show_emoji_button}"
+                ?show_occupants_toggle="${o.model.get('show_occupants_toggle')}"
+                ?show_send_button="${show_send_button}"
+                ?show_spoiler_button="${show_spoiler_button}"
+                ?show_toolbar="${show_toolbar}"
+                message_limit="${message_limit}"></converse-chat-toolbar>` : '' }
+        <converse-muc-message-form jid=${o.model.get('jid')}></converse-muc-message-form>`;
+}
 
 
 
 
 export default (o) => {
 export default (o) => {
+    const unread_msgs = __('You have unread messages');
     const conn_status = o.model.session.get('connection_status');
     const conn_status = o.model.session.get('connection_status');
     const i18n_not_allowed = __("You're not allowed to send messages in this room");
     const i18n_not_allowed = __("You're not allowed to send messages in this room");
     if (conn_status === converse.ROOMSTATUS.ENTERED) {
     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>`;
+        return html`
+            ${ o.model.get('scrolled') && o.model.get('num_unread_general') ?
+                    html`<div class="new-msgs-indicator" @click=${ev => o.viewUnreadMessages(ev)}>▼ ${ unread_msgs } ▼</div>` : '' }
+            ${(o.can_edit) ? tpl_can_edit(o) : html`<span class="muc-bottom-panel muc-bottom-panel--muted">${i18n_not_allowed}</span>`}`;
     } else if (conn_status == converse.ROOMSTATUS.NICKNAME_REQUIRED) {
     } else if (conn_status == converse.ROOMSTATUS.NICKNAME_REQUIRED) {
         if (api.settings.get('muc_show_logs_before_join')) {
         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)}</span>`;
             return html`<span class="muc-bottom-panel muc-bottom-panel--nickname">${tpl_muc_nickname_form(o.model)}</span>`;

+ 30 - 30
src/plugins/muc-views/tests/autocomplete.js

@@ -48,10 +48,10 @@ describe("The nickname autocomplete feature", function () {
             'keyCode': 50,
             'keyCode': 50,
             'key': '@'
             'key': '@'
         };
         };
-        const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-        bottom_panel.onKeyDown(at_event);
+        const message_form = view.querySelector('converse-muc-message-form');
+        message_form.onKeyDown(at_event);
         textarea.value = '@';
         textarea.value = '@';
-        bottom_panel.onKeyUp(at_event);
+        message_form.onKeyUp(at_event);
 
 
         await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4);
         await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4);
         expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick');
         expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick');
@@ -102,11 +102,11 @@ describe("The nickname autocomplete feature", function () {
             'keyCode': 50,
             'keyCode': 50,
             'key': '@'
             'key': '@'
         };
         };
-        const bottom_panel = view.querySelector('converse-muc-bottom-panel');
+        const message_form = view.querySelector('converse-muc-message-form');
         textarea.value = '\n'
         textarea.value = '\n'
-        bottom_panel.onKeyDown(at_event);
+        message_form.onKeyDown(at_event);
         textarea.value = '\n@';
         textarea.value = '\n@';
-        bottom_panel.onKeyUp(at_event);
+        message_form.onKeyUp(at_event);
 
 
         await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4);
         await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4);
         expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick');
         expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick');
@@ -159,10 +159,10 @@ describe("The nickname autocomplete feature", function () {
             'key': '@'
             'key': '@'
         };
         };
         textarea.value = '('
         textarea.value = '('
-        const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-        bottom_panel.onKeyDown(at_event);
+        const message_form = view.querySelector('converse-muc-message-form');
+        message_form.onKeyDown(at_event);
         textarea.value = '(@';
         textarea.value = '(@';
-        bottom_panel.onKeyUp(at_event);
+        message_form.onKeyUp(at_event);
 
 
         await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4);
         await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4);
         expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick');
         expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick');
@@ -201,11 +201,11 @@ describe("The nickname autocomplete feature", function () {
                 'key': '@'
                 'key': '@'
             };
             };
 
 
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
+            const message_form = view.querySelector('converse-muc-message-form');
             // Test that results are sorted by query index
             // Test that results are sorted by query index
-            bottom_panel.onKeyDown(at_event);
+            message_form.onKeyDown(at_event);
             textarea.value = '@ber';
             textarea.value = '@ber';
-            bottom_panel.onKeyUp(at_event);
+            message_form.onKeyUp(at_event);
             await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 3);
             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:first-child').textContent).toBe('bernard');
             expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('naber');
             expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('naber');
@@ -213,7 +213,7 @@ describe("The nickname autocomplete feature", function () {
 
 
             // Test that when the query index is equal, results should be sorted by length
             // Test that when the query index is equal, results should be sorted by length
             textarea.value = '@jo';
             textarea.value = '@jo';
-            bottom_panel.onKeyUp(at_event);
+            message_form.onKeyUp(at_event);
             await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 2);
             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:first-child').textContent).toBe('john');
             expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('jones');
             expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('jones');
@@ -250,9 +250,9 @@ describe("The nickname autocomplete feature", function () {
             'keyCode': 9,
             'keyCode': 9,
             'key': 'Tab'
             'key': 'Tab'
         }
         }
-        const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-        bottom_panel.onKeyDown(tab_event);
-        bottom_panel.onKeyUp(tab_event);
+        const message_form = view.querySelector('converse-muc-message-form');
+        message_form.onKeyDown(tab_event);
+        message_form.onKeyUp(tab_event);
         await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false);
         await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false);
         expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(1);
         expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(1);
         expect(view.querySelector('.suggestion-box__results li').textContent).toBe('some1');
         expect(view.querySelector('.suggestion-box__results li').textContent).toBe('some1');
@@ -264,9 +264,9 @@ describe("The nickname autocomplete feature", function () {
         }
         }
         for (var i=0; i<3; i++) {
         for (var i=0; i<3; i++) {
             // Press backspace 3 times to remove "som"
             // Press backspace 3 times to remove "som"
-            bottom_panel.onKeyDown(backspace_event);
+            message_form.onKeyDown(backspace_event);
             textarea.value = textarea.value.slice(0, textarea.value.length-1)
             textarea.value = textarea.value.slice(0, textarea.value.length-1)
-            bottom_panel.onKeyUp(backspace_event);
+            message_form.onKeyUp(backspace_event);
         }
         }
         await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === true);
         await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === true);
 
 
@@ -283,8 +283,8 @@ describe("The nickname autocomplete feature", function () {
         _converse.connection._dataRecv(mock.createRequest(presence));
         _converse.connection._dataRecv(mock.createRequest(presence));
 
 
         textarea.value = "hello s s";
         textarea.value = "hello s s";
-        bottom_panel.onKeyDown(tab_event);
-        bottom_panel.onKeyUp(tab_event);
+        message_form.onKeyDown(tab_event);
+        message_form.onKeyUp(tab_event);
         await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false);
         await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false);
         expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(2);
         expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(2);
 
 
@@ -294,13 +294,13 @@ describe("The nickname autocomplete feature", function () {
             'stopPropagation': function stopPropagation () {},
             'stopPropagation': function stopPropagation () {},
             'keyCode': 38
             'keyCode': 38
         }
         }
-        bottom_panel.onKeyDown(up_arrow_event);
-        bottom_panel.onKeyUp(up_arrow_event);
+        message_form.onKeyDown(up_arrow_event);
+        message_form.onKeyUp(up_arrow_event);
         expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(2);
         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="false"]').textContent).toBe('some1');
         expect(view.querySelector('.suggestion-box__results li[aria-selected="true"]').textContent).toBe('some2');
         expect(view.querySelector('.suggestion-box__results li[aria-selected="true"]').textContent).toBe('some2');
 
 
-        bottom_panel.onKeyDown({
+        message_form.onKeyDown({
             'target': textarea,
             'target': textarea,
             'preventDefault': function preventDefault () {},
             'preventDefault': function preventDefault () {},
             'stopPropagation': function stopPropagation () {},
             'stopPropagation': function stopPropagation () {},
@@ -321,12 +321,12 @@ describe("The nickname autocomplete feature", function () {
             });
             });
         _converse.connection._dataRecv(mock.createRequest(presence));
         _converse.connection._dataRecv(mock.createRequest(presence));
         textarea.value = "hello z";
         textarea.value = "hello z";
-        bottom_panel.onKeyDown(tab_event);
-        bottom_panel.onKeyUp(tab_event);
+        message_form.onKeyDown(tab_event);
+        message_form.onKeyUp(tab_event);
         await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false);
         await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false);
 
 
-        bottom_panel.onKeyDown(tab_event);
-        bottom_panel.onKeyUp(tab_event);
+        message_form.onKeyDown(tab_event);
+        message_form.onKeyUp(tab_event);
         await u.waitUntil(() => textarea.value === 'hello @z3r0 ');
         await u.waitUntil(() => textarea.value === 'hello @z3r0 ');
         done();
         done();
     }));
     }));
@@ -361,10 +361,10 @@ describe("The nickname autocomplete feature", function () {
             'keyCode': 8,
             'keyCode': 8,
             'key': 'Backspace'
             'key': 'Backspace'
         }
         }
-        const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-        bottom_panel.onKeyDown(backspace_event);
+        const message_form = view.querySelector('converse-muc-message-form');
+        message_form.onKeyDown(backspace_event);
         textarea.value = "hello @some1"; // Mimic backspace
         textarea.value = "hello @some1"; // Mimic backspace
-        bottom_panel.onKeyUp(backspace_event);
+        message_form.onKeyUp(backspace_event);
         await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false);
         await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false);
         expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(1);
         expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(1);
         expect(view.querySelector('.suggestion-box__results li').textContent).toBe('some1');
         expect(view.querySelector('.suggestion-box__results li').textContent).toBe('some1');

+ 7 - 7
src/plugins/muc-views/tests/corrections.js

@@ -169,15 +169,15 @@ describe("A Groupchat Message", function () {
         const view = _converse.api.chatviews.get(muc_jid);
         const view = _converse.api.chatviews.get(muc_jid);
         const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea'));
         const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea'));
         expect(textarea.value).toBe('');
         expect(textarea.value).toBe('');
-        const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-        bottom_panel.onKeyDown({
+        const message_form = view.querySelector('converse-muc-message-form');
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             keyCode: 38 // Up arrow
             keyCode: 38 // Up arrow
         });
         });
         expect(textarea.value).toBe('');
         expect(textarea.value).toBe('');
 
 
         textarea.value = 'But soft, what light through yonder airlock breaks?';
         textarea.value = 'But soft, what light through yonder airlock breaks?';
-        bottom_panel.onKeyDown({
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
@@ -188,7 +188,7 @@ describe("A Groupchat Message", function () {
 
 
         const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
         const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
         expect(textarea.value).toBe('');
         expect(textarea.value).toBe('');
-        bottom_panel.onKeyDown({
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             keyCode: 38 // Up arrow
             keyCode: 38 // Up arrow
         });
         });
@@ -200,7 +200,7 @@ describe("A Groupchat Message", function () {
         spyOn(_converse.connection, 'send');
         spyOn(_converse.connection, 'send');
         const new_text = 'But soft, what light through yonder window breaks?'
         const new_text = 'But soft, what light through yonder window breaks?'
         textarea.value = new_text;
         textarea.value = new_text;
-        bottom_panel.onKeyDown({
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
@@ -245,7 +245,7 @@ describe("A Groupchat Message", function () {
 
 
         // Test that pressing the down arrow cancels message correction
         // Test that pressing the down arrow cancels message correction
         expect(textarea.value).toBe('');
         expect(textarea.value).toBe('');
-        bottom_panel.onKeyDown({
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             keyCode: 38 // Up arrow
             keyCode: 38 // Up arrow
         });
         });
@@ -254,7 +254,7 @@ describe("A Groupchat Message", function () {
         expect(view.querySelectorAll('.chat-msg').length).toBe(2);
         expect(view.querySelectorAll('.chat-msg').length).toBe(2);
         await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
         await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
         expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
         expect(textarea.value).toBe('But soft, what light through yonder window breaks?');
-        bottom_panel.onKeyDown({
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             keyCode: 40 // Down arrow
             keyCode: 40 // Down arrow
         });
         });

+ 11 - 11
src/plugins/muc-views/tests/mentions.js

@@ -109,8 +109,8 @@ describe("An incoming groupchat message", function () {
             'stopPropagation': function stopPropagation () {},
             'stopPropagation': function stopPropagation () {},
             'keyCode': 13 // Enter
             'keyCode': 13 // Enter
         }
         }
-        const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-        bottom_panel.onKeyDown(enter_event);
+        const message_form = view.querySelector('converse-muc-message-form');
+        message_form.onKeyDown(enter_event);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
 
 
         await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
@@ -363,8 +363,8 @@ describe("A sent groupchat message", function () {
                 'stopPropagation': function stopPropagation () {},
                 'stopPropagation': function stopPropagation () {},
                 'keyCode': 13 // Enter
                 'keyCode': 13 // Enter
             }
             }
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown(enter_event);
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown(enter_event);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
             const sent_stanzas = _converse.connection.sent_stanzas;
             const sent_stanzas = _converse.connection.sent_stanzas;
             const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop());
             const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop());
@@ -423,8 +423,8 @@ describe("A sent groupchat message", function () {
                 'stopPropagation': function stopPropagation () {},
                 'stopPropagation': function stopPropagation () {},
                 'keyCode': 13 // Enter
                 'keyCode': 13 // Enter
             }
             }
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown(enter_event);
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown(enter_event);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
 
 
             const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
             const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
@@ -457,7 +457,7 @@ describe("A sent groupchat message", function () {
             await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
             await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500);
 
 
             textarea.value = 'hello @z3r0 @gibson @sw0rdf1sh, how are you?';
             textarea.value = 'hello @z3r0 @gibson @sw0rdf1sh, how are you?';
-            bottom_panel.onKeyDown(enter_event);
+            message_form.onKeyDown(enter_event);
             await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent ===
             await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent ===
                 'hello z3r0 gibson sw0rdf1sh, how are you?', 500);
                 'hello z3r0 gibson sw0rdf1sh, how are you?', 500);
 
 
@@ -507,8 +507,8 @@ describe("A sent groupchat message", function () {
                 'stopPropagation': function stopPropagation () {},
                 'stopPropagation': function stopPropagation () {},
                 'keyCode': 13 // Enter
                 'keyCode': 13 // Enter
             }
             }
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown(enter_event);
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown(enter_event);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
 
 
             const msg = _converse.connection.send.calls.all()[0].args[0];
             const msg = _converse.connection.send.calls.all()[0].args[0];
@@ -542,8 +542,8 @@ describe("A sent groupchat message", function () {
             'stopPropagation': function stopPropagation () {},
             'stopPropagation': function stopPropagation () {},
             'keyCode': 13 // Enter
             'keyCode': 13 // Enter
         }
         }
-        const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-        bottom_panel.onKeyDown(enter_event);
+        const message_form = view.querySelector('converse-muc-message-form');
+        message_form.onKeyDown(enter_event);
         const message = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         const message = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         expect(message.innerHTML.replace(/<!-.*?->/g, '')).toEqual(
         expect(message.innerHTML.replace(/<!-.*?->/g, '')).toEqual(
             `Welcome <span class="mention">gibson</span> <span title=":poop:">💩</span> `+
             `Welcome <span class="mention">gibson</span> <span title=":poop:">💩</span> `+

+ 6 - 6
src/plugins/muc-views/tests/modtools.js

@@ -11,8 +11,8 @@ async function openModtools (_converse, view) {
     const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
     const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
     textarea.value = '/modtools';
     textarea.value = '/modtools';
     const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
     const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
-    const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-    bottom_panel.onKeyDown(enter);
+    const message_form = view.querySelector('converse-muc-message-form');
+    message_form.onKeyDown(enter);
     const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal'));
     const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal'));
     await u.waitUntil(() => u.isVisible(modal.el), 1000);
     await u.waitUntil(() => u.isVisible(modal.el), 1000);
     return modal;
     return modal;
@@ -256,8 +256,8 @@ describe("The groupchat moderator tool", function () {
         const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
         const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
         textarea.value = '/modtools';
         textarea.value = '/modtools';
         const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
         const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
-        const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-        bottom_panel.onKeyDown(enter);
+        const message_form = view.querySelector('converse-muc-message-form');
+        message_form.onKeyDown(enter);
 
 
         const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal'));
         const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal'));
         await u.waitUntil(() => u.isVisible(modal.el), 1000);
         await u.waitUntil(() => u.isVisible(modal.el), 1000);
@@ -455,8 +455,8 @@ describe("The groupchat moderator tool", function () {
         const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
         const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
         textarea.value = '/modtools';
         textarea.value = '/modtools';
         const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
         const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
-        const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-        bottom_panel.onKeyDown(enter);
+        const message_form = view.querySelector('converse-muc-message-form');
+        message_form.onKeyDown(enter);
 
 
         const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal'));
         const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal'));
         const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});
         const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid});

+ 6 - 6
src/plugins/muc-views/tests/muc-messages.js

@@ -25,8 +25,8 @@ describe("A Groupchat Message", function () {
                 'stopPropagation': function stopPropagation () {},
                 'stopPropagation': function stopPropagation () {},
                 'keyCode': 13 // Enter
                 'keyCode': 13 // Enter
             }
             }
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown(enter_event);
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown(enter_event);
             await new Promise(resolve => view.model.messages.once('rendered', resolve));
             await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
 
             const msg = view.model.messages.at(0);
             const msg = view.model.messages.at(0);
@@ -514,8 +514,8 @@ describe("A Groupchat Message", function () {
         const view = _converse.api.chatviews.get(muc_jid);
         const view = _converse.api.chatviews.get(muc_jid);
         const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
         const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
         textarea.value = 'But soft, what light through yonder airlock breaks?';
         textarea.value = 'But soft, what light through yonder airlock breaks?';
-        const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-        bottom_panel.onKeyDown({
+        const message_form = view.querySelector('converse-muc-message-form');
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
@@ -589,8 +589,8 @@ describe("A Groupchat Message", function () {
         const view = _converse.api.chatviews.get(muc_jid);
         const view = _converse.api.chatviews.get(muc_jid);
         const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
         const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
         textarea.value = 'But soft, what light through yonder airlock breaks?';
         textarea.value = 'But soft, what light through yonder airlock breaks?';
-        const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-        bottom_panel.onKeyDown({
+        const message_form = view.querySelector('converse-muc-message-form');
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter

+ 2 - 2
src/plugins/muc-views/tests/muc-registration.js

@@ -15,8 +15,8 @@ describe("Chatrooms", function () {
             const view = _converse.chatboxviews.get(muc_jid);
             const view = _converse.chatboxviews.get(muc_jid);
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             textarea.value = '/register';
             textarea.value = '/register';
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown({
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13
                 keyCode: 13

+ 48 - 49
src/plugins/muc-views/tests/muc.js

@@ -281,8 +281,7 @@ describe("Groupchats", function () {
             _converse.connection._dataRecv(mock.createRequest(message));
             _converse.connection._dataRecv(mock.createRequest(message));
 
 
             await u.waitUntil(() => view.model.messages.length);
             await u.waitUntil(() => view.model.messages.length);
-            const chat_new_msgs_indicator = view.querySelector('.new-msgs-indicator');
-            await u.waitUntil(() => u.isVisible(chat_new_msgs_indicator));
+            const chat_new_msgs_indicator = await u.waitUntil(() => view.querySelector('.new-msgs-indicator'));
             chat_new_msgs_indicator.click();
             chat_new_msgs_indicator.click();
             expect(view.model.get('scrolled')).toBeFalsy();
             expect(view.model.get('scrolled')).toBeFalsy();
             await u.waitUntil(() => !u.isVisible(chat_new_msgs_indicator));
             await u.waitUntil(() => !u.isVisible(chat_new_msgs_indicator));
@@ -1895,8 +1894,8 @@ describe("Groupchats", function () {
             const text = 'This is a sent message';
             const text = 'This is a sent message';
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             textarea.value = text;
             textarea.value = text;
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown({
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13
                 keyCode: 13
@@ -2753,8 +2752,8 @@ describe("Groupchats", function () {
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
             const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 };
             textarea.value = '/help';
             textarea.value = '/help';
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown(enter);
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown(enter);
 
 
             await u.waitUntil(() => sizzle('converse-chat-help .chat-info', view).length);
             await u.waitUntil(() => sizzle('converse-chat-help .chat-info', view).length);
             let chat_help_el = view.querySelector('converse-chat-help');
             let chat_help_el = view.querySelector('converse-chat-help');
@@ -2788,7 +2787,7 @@ describe("Groupchats", function () {
             await u.waitUntil(() => view.querySelector('converse-chat-help') === null);
             await u.waitUntil(() => view.querySelector('converse-chat-help') === null);
 
 
             textarea.value = '/help';
             textarea.value = '/help';
-            bottom_panel.onKeyDown(enter);
+            message_form.onKeyDown(enter);
             chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
             chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
             info_messages = sizzle('.chat-info', chat_help_el);
             info_messages = sizzle('.chat-info', chat_help_el);
             expect(info_messages.length).toBe(18);
             expect(info_messages.length).toBe(18);
@@ -2803,7 +2802,7 @@ describe("Groupchats", function () {
             await u.waitUntil(() => view.querySelector('converse-chat-help') === null);
             await u.waitUntil(() => view.querySelector('converse-chat-help') === null);
 
 
             textarea.value = '/help';
             textarea.value = '/help';
-            bottom_panel.onKeyDown(enter);
+            message_form.onKeyDown(enter);
             chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
             chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
             info_messages = sizzle('.chat-info', chat_help_el);
             info_messages = sizzle('.chat-info', chat_help_el);
             expect(info_messages.length).toBe(9);
             expect(info_messages.length).toBe(9);
@@ -2818,7 +2817,7 @@ describe("Groupchats", function () {
             // Role changes causes rerender, so we need to get the new textarea
             // Role changes causes rerender, so we need to get the new textarea
 
 
             textarea.value = '/help';
             textarea.value = '/help';
-            bottom_panel.onKeyDown(enter);
+            message_form.onKeyDown(enter);
             await u.waitUntil(() => view.model.get('show_help_messages'));
             await u.waitUntil(() => view.model.get('show_help_messages'));
             chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
             chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
             info_messages = sizzle('.chat-info', chat_help_el);
             info_messages = sizzle('.chat-info', chat_help_el);
@@ -2833,7 +2832,7 @@ describe("Groupchats", function () {
             await u.waitUntil(() => view.querySelector('converse-chat-help') === null);
             await u.waitUntil(() => view.querySelector('converse-chat-help') === null);
 
 
             textarea.value = '/help';
             textarea.value = '/help';
-            bottom_panel.onKeyDown(enter);
+            message_form.onKeyDown(enter);
             chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
             chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help'));
             info_messages = sizzle('.chat-info', chat_help_el);
             info_messages = sizzle('.chat-info', chat_help_el);
             expect(info_messages.length).toBe(7);
             expect(info_messages.length).toBe(7);
@@ -2851,10 +2850,10 @@ describe("Groupchats", function () {
             const enter = { 'target': textarea, 'preventDefault': function () {}, 'keyCode': 13 };
             const enter = { 'target': textarea, 'preventDefault': function () {}, 'keyCode': 13 };
             spyOn(window, 'confirm').and.callFake(() => true);
             spyOn(window, 'confirm').and.callFake(() => true);
             textarea.value = '/clear';
             textarea.value = '/clear';
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown(enter);
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown(enter);
             textarea.value = '/help';
             textarea.value = '/help';
-            bottom_panel.onKeyDown(enter);
+            message_form.onKeyDown(enter);
 
 
             await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view).length);
             await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view).length);
             const info_messages = sizzle('.chat-info:not(.chat-event)', view);
             const info_messages = sizzle('.chat-info:not(.chat-event)', view);
@@ -2910,8 +2909,8 @@ describe("Groupchats", function () {
             // First check that an error message appears when a
             // First check that an error message appears when a
             // non-existent nick is used.
             // non-existent nick is used.
             textarea.value = '/member chris Welcome to the club!';
             textarea.value = '/member chris Welcome to the club!';
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown({
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13
                 keyCode: 13
@@ -2923,7 +2922,7 @@ describe("Groupchats", function () {
 
 
             // Now test with an existing nick
             // Now test with an existing nick
             textarea.value = '/member marc Welcome to the club!';
             textarea.value = '/member marc Welcome to the club!';
-            bottom_panel.onKeyDown({
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13
                 keyCode: 13
@@ -3030,8 +3029,8 @@ describe("Groupchats", function () {
             // Check the alias /topic
             // Check the alias /topic
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             textarea.value = '/topic This is the groupchat subject';
             textarea.value = '/topic This is the groupchat subject';
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown({
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13
                 keyCode: 13
@@ -3041,7 +3040,7 @@ describe("Groupchats", function () {
 
 
             // Check /subject
             // Check /subject
             textarea.value = '/subject This is a new subject';
             textarea.value = '/subject This is a new subject';
-            bottom_panel.onKeyDown({
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13
                 keyCode: 13
@@ -3055,7 +3054,7 @@ describe("Groupchats", function () {
 
 
             // Check case insensitivity
             // Check case insensitivity
             textarea.value = '/Subject This is yet another subject';
             textarea.value = '/Subject This is yet another subject';
-            bottom_panel.onKeyDown({
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13
                 keyCode: 13
@@ -3068,7 +3067,7 @@ describe("Groupchats", function () {
 
 
             // Check unsetting the topic
             // Check unsetting the topic
             textarea.value = '/topic';
             textarea.value = '/topic';
-            bottom_panel.onKeyDown({
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13
                 keyCode: 13
@@ -3085,9 +3084,9 @@ describe("Groupchats", function () {
             const view = _converse.chatboxviews.get('lounge@montague.lit');
             const view = _converse.chatboxviews.get('lounge@montague.lit');
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             textarea.value = '/clear';
             textarea.value = '/clear';
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
             spyOn(window, 'confirm').and.callFake(() => false);
             spyOn(window, 'confirm').and.callFake(() => false);
-            bottom_panel.onKeyDown({
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13
                 keyCode: 13
@@ -3123,8 +3122,8 @@ describe("Groupchats", function () {
 
 
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             textarea.value = '/owner';
             textarea.value = '/owner';
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown({
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13
                 keyCode: 13
@@ -3142,7 +3141,7 @@ describe("Groupchats", function () {
             // again via triggering Event doesn't work for some weird
             // again via triggering Event doesn't work for some weird
             // reason.
             // reason.
             textarea.value = '/owner nobody You\'re responsible';
             textarea.value = '/owner nobody You\'re responsible';
-            bottom_panel.onFormSubmitted(new Event('submit'));
+            message_form.onFormSubmitted(new Event('submit'));
             await u.waitUntil(() => view.querySelectorAll('.chat-error').length === 2);
             await u.waitUntil(() => view.querySelectorAll('.chat-error').length === 2);
             expect(Array.from(view.querySelectorAll('.chat-error')).pop().textContent.trim()).toBe(
             expect(Array.from(view.querySelectorAll('.chat-error')).pop().textContent.trim()).toBe(
                 "Error: couldn't find a groupchat participant based on your arguments");
                 "Error: couldn't find a groupchat participant based on your arguments");
@@ -3154,7 +3153,7 @@ describe("Groupchats", function () {
             // again via triggering Event doesn't work for some weird
             // again via triggering Event doesn't work for some weird
             // reason.
             // reason.
             textarea.value = '/owner annoyingGuy You\'re responsible';
             textarea.value = '/owner annoyingGuy You\'re responsible';
-            bottom_panel.onFormSubmitted(new Event('submit'));
+            message_form.onFormSubmitted(new Event('submit'));
 
 
             expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3);
             expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3);
             // Check that the member list now gets updated
             // Check that the member list now gets updated
@@ -3213,8 +3212,8 @@ describe("Groupchats", function () {
 
 
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             textarea.value = '/ban';
             textarea.value = '/ban';
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown({
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13
                 keyCode: 13
@@ -3232,7 +3231,7 @@ describe("Groupchats", function () {
             // again via triggering Event doesn't work for some weird
             // again via triggering Event doesn't work for some weird
             // reason.
             // reason.
             textarea.value = '/ban annoyingGuy You\'re annoying';
             textarea.value = '/ban annoyingGuy You\'re annoying';
-            bottom_panel.onFormSubmitted(new Event('submit'));
+            message_form.onFormSubmitted(new Event('submit'));
 
 
             expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
             expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
             // Check that the member list now gets updated
             // Check that the member list now gets updated
@@ -3277,7 +3276,7 @@ describe("Groupchats", function () {
             _converse.connection._dataRecv(mock.createRequest(presence));
             _converse.connection._dataRecv(mock.createRequest(presence));
 
 
             textarea.value = '/ban joe22';
             textarea.value = '/ban joe22';
-            bottom_panel.onFormSubmitted(new Event('submit'));
+            message_form.onFormSubmitted(new Event('submit'));
             await u.waitUntil(() => view.querySelector('converse-chat-message:last-child')?.textContent?.trim() ===
             await u.waitUntil(() => view.querySelector('converse-chat-message:last-child')?.textContent?.trim() ===
                 "Error: couldn't find a groupchat participant based on your arguments");
                 "Error: couldn't find a groupchat participant based on your arguments");
             done();
             done();
@@ -3313,8 +3312,8 @@ describe("Groupchats", function () {
 
 
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             textarea.value = '/kick';
             textarea.value = '/kick';
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown({
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13
                 keyCode: 13
@@ -3328,7 +3327,7 @@ describe("Groupchats", function () {
             // again via triggering Event doesn't work for some weird
             // again via triggering Event doesn't work for some weird
             // reason.
             // reason.
             textarea.value = '/kick @annoying guy You\'re annoying';
             textarea.value = '/kick @annoying guy You\'re annoying';
-            bottom_panel.onFormSubmitted(new Event('submit'));
+            message_form.onFormSubmitted(new Event('submit'));
 
 
             expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
             expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
             expect(view.model.setRole).toHaveBeenCalled();
             expect(view.model.setRole).toHaveBeenCalled();
@@ -3415,8 +3414,8 @@ describe("Groupchats", function () {
 
 
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             textarea.value = '/op';
             textarea.value = '/op';
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown({
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13
                 keyCode: 13
@@ -3432,7 +3431,7 @@ describe("Groupchats", function () {
             // again via triggering Event doesn't work for some weird
             // again via triggering Event doesn't work for some weird
             // reason.
             // reason.
             textarea.value = '/op trustworthyguy You\'re trustworthy';
             textarea.value = '/op trustworthyguy You\'re trustworthy';
-            bottom_panel.onFormSubmitted(new Event('submit'));
+            message_form.onFormSubmitted(new Event('submit'));
 
 
             expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
             expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
             expect(view.model.setRole).toHaveBeenCalled();
             expect(view.model.setRole).toHaveBeenCalled();
@@ -3476,7 +3475,7 @@ describe("Groupchats", function () {
             // again via triggering Event doesn't work for some weird
             // again via triggering Event doesn't work for some weird
             // reason.
             // reason.
             textarea.value = '/deop trustworthyguy Perhaps not';
             textarea.value = '/deop trustworthyguy Perhaps not';
-            bottom_panel.onFormSubmitted(new Event('submit'));
+            message_form.onFormSubmitted(new Event('submit'));
 
 
             expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3);
             expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3);
             expect(view.model.setRole).toHaveBeenCalled();
             expect(view.model.setRole).toHaveBeenCalled();
@@ -3555,8 +3554,8 @@ describe("Groupchats", function () {
 
 
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             textarea.value = '/mute';
             textarea.value = '/mute';
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown({
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown({
                 target: textarea,
                 target: textarea,
                 preventDefault: function preventDefault () {},
                 preventDefault: function preventDefault () {},
                 keyCode: 13
                 keyCode: 13
@@ -3571,7 +3570,7 @@ describe("Groupchats", function () {
             // again via triggering Event doesn't work for some weird
             // again via triggering Event doesn't work for some weird
             // reason.
             // reason.
             textarea.value = '/mute annoyingGuy You\'re annoying';
             textarea.value = '/mute annoyingGuy You\'re annoying';
-            bottom_panel.onFormSubmitted(new Event('submit'));
+            message_form.onFormSubmitted(new Event('submit'));
 
 
             expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
             expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(2);
             expect(view.model.setRole).toHaveBeenCalled();
             expect(view.model.setRole).toHaveBeenCalled();
@@ -3612,7 +3611,7 @@ describe("Groupchats", function () {
             // again via triggering Event doesn't work for some weird
             // again via triggering Event doesn't work for some weird
             // reason.
             // reason.
             textarea.value = '/voice annoyingGuy Now you can talk again';
             textarea.value = '/voice annoyingGuy Now you can talk again';
-            bottom_panel.onFormSubmitted(new Event('submit'));
+            message_form.onFormSubmitted(new Event('submit'));
 
 
             expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3);
             expect(view.model.validateRoleOrAffiliationChangeArgs.calls.count()).toBe(3);
             expect(view.model.setRole).toHaveBeenCalled();
             expect(view.model.setRole).toHaveBeenCalled();
@@ -3660,8 +3659,8 @@ describe("Groupchats", function () {
             spyOn(_converse.api, 'confirm').and.callThrough();
             spyOn(_converse.api, 'confirm').and.callThrough();
             let textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             let textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             textarea.value = '/destroy';
             textarea.value = '/destroy';
-            let bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onFormSubmitted(new Event('submit'));
+            let message_form = view.querySelector('converse-muc-message-form');
+            message_form.onFormSubmitted(new Event('submit'));
             let modal = await u.waitUntil(() => document.querySelector('.modal-dialog'));
             let modal = await u.waitUntil(() => document.querySelector('.modal-dialog'));
             await u.waitUntil(() => u.isVisible(modal));
             await u.waitUntil(() => u.isVisible(modal));
 
 
@@ -3711,8 +3710,8 @@ describe("Groupchats", function () {
             view = _converse.api.chatviews.get(new_muc_jid);
             view = _converse.api.chatviews.get(new_muc_jid);
             textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
             textarea.value = '/destroy';
             textarea.value = '/destroy';
-            bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onFormSubmitted(new Event('submit'));
+            message_form = view.querySelector('converse-muc-message-form');
+            message_form.onFormSubmitted(new Event('submit'));
             modal = await u.waitUntil(() => document.querySelector('.modal-dialog'));
             modal = await u.waitUntil(() => document.querySelector('.modal-dialog'));
             await u.waitUntil(() => u.isVisible(modal));
             await u.waitUntil(() => u.isVisible(modal));
 
 
@@ -4988,8 +4987,8 @@ describe("Groupchats", function () {
             const view = _converse.api.chatviews.get(muc_jid);
             const view = _converse.api.chatviews.get(muc_jid);
             const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea'));
             const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea'));
             textarea.value = 'Hello world';
             textarea.value = 'Hello world';
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onFormSubmitted(new Event('submit'));
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onFormSubmitted(new Event('submit'));
             await new Promise(resolve => view.model.messages.once('rendered', resolve));
             await new Promise(resolve => view.model.messages.once('rendered', resolve));
 
 
             let stanza = u.toStanza(`
             let stanza = u.toStanza(`
@@ -5006,7 +5005,7 @@ describe("Groupchats", function () {
                 "Your message was not delivered because you weren't allowed to send it.");
                 "Your message was not delivered because you weren't allowed to send it.");
 
 
             textarea.value = 'Hello again';
             textarea.value = 'Hello again';
-            bottom_panel.onFormSubmitted(new Event('submit'));
+            message_form.onFormSubmitted(new Event('submit'));
             await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
             await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
 
 
             stanza = u.toStanza(`
             stanza = u.toStanza(`

+ 0 - 1
src/plugins/notifications/tests/notification.js

@@ -79,7 +79,6 @@ describe("Notifications", function () {
                 }));
                 }));
 
 
                 it("is shown for headline messages", mock.initConverse([], {}, async (done, _converse) => {
                 it("is shown for headline messages", mock.initConverse([], {}, async (done, _converse) => {
-
                     const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']);
                     const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']);
                     spyOn(window, 'Notification').and.returnValue(stub);
                     spyOn(window, 'Notification').and.returnValue(stub);
 
 

+ 10 - 10
src/plugins/omemo/tests/omemo.js

@@ -112,8 +112,8 @@ describe("The OMEMO module", function() {
 
 
         const textarea = view.querySelector('.chat-textarea');
         const textarea = view.querySelector('.chat-textarea');
         textarea.value = 'This message will be encrypted';
         textarea.value = 'This message will be encrypted';
-        const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-        bottom_panel.onKeyDown({
+        const message_form = view.querySelector('converse-message-form');
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
@@ -294,8 +294,8 @@ describe("The OMEMO module", function() {
 
 
         const textarea = view.querySelector('.chat-textarea');
         const textarea = view.querySelector('.chat-textarea');
         textarea.value = 'This message will be encrypted';
         textarea.value = 'This message will be encrypted';
-        const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-        bottom_panel.onKeyDown({
+        const message_form = view.querySelector('converse-muc-message-form');
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
@@ -459,8 +459,8 @@ describe("The OMEMO module", function() {
 
 
         const textarea = view.querySelector('.chat-textarea');
         const textarea = view.querySelector('.chat-textarea');
         textarea.value = 'This is an encrypted message from this device';
         textarea.value = 'This is an encrypted message from this device';
-        const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-        bottom_panel.onKeyDown({
+        const message_form = view.querySelector('converse-message-form');
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
@@ -515,8 +515,8 @@ describe("The OMEMO module", function() {
 
 
         const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
         const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea'));
         textarea.value = 'This message will be encrypted';
         textarea.value = 'This message will be encrypted';
-        const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-        bottom_panel.onKeyDown({
+        const message_form = view.querySelector('converse-muc-message-form');
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13 // Enter
             keyCode: 13 // Enter
@@ -1232,8 +1232,8 @@ describe("The OMEMO module", function() {
 
 
         const textarea = view.querySelector('.chat-textarea');
         const textarea = view.querySelector('.chat-textarea');
         textarea.value = 'This message will be sent encrypted';
         textarea.value = 'This message will be sent encrypted';
-        const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-        bottom_panel.onKeyDown({
+        const message_form = view.querySelector('converse-message-form');
+        message_form.onKeyDown({
             target: textarea,
             target: textarea,
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13
             keyCode: 13

+ 9 - 1
src/shared/chat/baseview.js

@@ -89,6 +89,14 @@ export default class BaseChatView extends ElementView {
         }
         }
     }
     }
 
 
+    getMessageForm () {
+        if (this.model.get('type') === _converse.CHATROOMS_TYPE) {
+            return this.querySelector('converse-muc-message-form');
+        } else {
+            return this.querySelector('converse-message-form');
+        }
+    }
+
     /**
     /**
      * Scrolls the chat down.
      * Scrolls the chat down.
      *
      *
@@ -107,7 +115,7 @@ export default class BaseChatView extends ElementView {
 
 
     onWindowStateChanged (data) {
     onWindowStateChanged (data) {
         if (data.state === 'visible') {
         if (data.state === 'visible') {
-            if (!this.model.isHidden() && this.model.get('num_unread', 0)) {
+            if (!this.model.isHidden()) {
                 this.model.clearUnreadMsgCounter();
                 this.model.clearUnreadMsgCounter();
             }
             }
         } else if (data.state === 'hidden') {
         } else if (data.state === 'hidden') {

+ 2 - 2
src/shared/chat/chat-content.js

@@ -48,7 +48,7 @@ export default class ChatContent extends CustomElement {
 
 
     render () {
     render () {
         return html`
         return html`
-            ${ this.model.ui.get('chat-content-spinner-top') ? html`<span class="spinner fa fa-spinner centered"></span>` : '' }
+            ${ this.model.ui?.get('chat-content-spinner-top') ? html`<span class="spinner fa fa-spinner centered"></span>` : '' }
             <converse-message-history
             <converse-message-history
                 .model=${this.model}
                 .model=${this.model}
                 .observer=${this.observer}
                 .observer=${this.observer}
@@ -105,7 +105,7 @@ export default class ChatContent extends CustomElement {
     }
     }
 
 
     setAnchoredMessage (entries) {
     setAnchoredMessage (entries) {
-        if (this.model.ui.get('chat-content-spinner-top')) {
+        if (!this.model?.ui || this.model.ui.get('chat-content-spinner-top')) {
             return;
             return;
         }
         }
         entries = entries.filter(e => e.isIntersecting);
         entries = entries.filter(e => e.isIntersecting);

+ 1 - 1
src/shared/chat/emoji-picker.js

@@ -153,7 +153,7 @@ export default class EmojiPicker extends CustomElement {
     insertIntoTextArea (value) {
     insertIntoTextArea (value) {
         const autocompleting = this.model.get('autocompleting');
         const autocompleting = this.model.get('autocompleting');
         const ac_position = this.model.get('ac_position');
         const ac_position = this.model.get('ac_position');
-        this.chatview.getBottomPanel().insertIntoTextArea(value, autocompleting, false, ac_position);
+        this.chatview.getMessageForm().insertIntoTextArea(value, autocompleting, false, ac_position);
         this.model.set({'autocompleting': null, 'query': '', 'ac_position': null});
         this.model.set({'autocompleting': null, 'query': '', 'ac_position': null});
     }
     }
 
 

+ 26 - 0
src/shared/chat/message-limit.js

@@ -0,0 +1,26 @@
+import tpl_message_limit from './templates/message-limit.js';
+import { CustomElement } from 'shared/components/element.js';
+import { api } from '@converse/headless/core';
+
+export default class MessageLimitIndicator extends CustomElement {
+
+    static get properties () {
+        return {
+            model: { type: Object }
+        }
+    }
+
+    connectedCallback () {
+        super.connectedCallback();
+        this.listenTo(this.model, 'change:draft', this.requestUpdate);
+    }
+
+    render () {
+        const limit = api.settings.get('message_limit');
+        if (!limit) return '';
+        const chars = this.model.get('draft') || '';
+        return tpl_message_limit(limit - chars.length);
+    }
+}
+
+api.elements.define('converse-message-limit-indicator', MessageLimitIndicator);

+ 7 - 0
src/shared/chat/templates/message-limit.js

@@ -0,0 +1,7 @@
+import { __ } from 'i18n';
+import { html } from 'lit';
+
+export default (counter) => {
+    const i18n_chars_remaining = __('Message characters remaining');
+    return html`<span class="message-limit ${counter < 1 ? 'error' : ''}" title="${i18n_chars_remaining}">${counter}</span>`;
+}

+ 27 - 9
src/shared/chat/toolbar.js

@@ -1,7 +1,8 @@
-import "./emoji-picker.js";
+import './emoji-picker.js';
+import 'shared/chat/message-limit.js';
 import { CustomElement } from 'shared/components/element.js';
 import { CustomElement } from 'shared/components/element.js';
 import { __ } from 'i18n';
 import { __ } from 'i18n';
-import { _converse, api, converse } from "@converse/headless/core";
+import { _converse, api, converse } from '@converse/headless/core';
 import { html } from 'lit';
 import { html } from 'lit';
 import { until } from 'lit/directives/until.js';
 import { until } from 'lit/directives/until.js';
 
 
@@ -14,7 +15,6 @@ export class ChatToolbar extends CustomElement {
 
 
     static get properties () {
     static get properties () {
         return {
         return {
-            chatview: { type: Object }, // Used by getToolbarButtons hooks
             composing_spoiler: { type: Boolean },
             composing_spoiler: { type: Boolean },
             hidden_occupants: { type: Boolean },
             hidden_occupants: { type: Boolean },
             is_groupchat: { type: Boolean },
             is_groupchat: { type: Boolean },
@@ -25,23 +25,38 @@ export class ChatToolbar extends CustomElement {
             show_occupants_toggle: { type: Boolean },
             show_occupants_toggle: { type: Boolean },
             show_send_button: { type: Boolean },
             show_send_button: { type: Boolean },
             show_spoiler_button: { type: Boolean },
             show_spoiler_button: { type: Boolean },
-            show_toolbar: { type: Boolean }
         }
         }
     }
     }
 
 
+    connectedCallback () {
+        super.connectedCallback();
+        this.listenTo(this.model, 'change:composing_spoiler', this.requestUpdate);
+    }
+
     render () {
     render () {
         const i18n_send_message = __('Send the message');
         const i18n_send_message = __('Send the message');
         return html`
         return html`
-            ${ this.show_toolbar ? html`<span class="toolbar-buttons">${until(this.getButtons(), '')}</span>` : '' }
+            <span class="toolbar-buttons">${until(this.getButtons(), '')}</span>
             ${ this.show_send_button ? html`<button type="submit" class="btn send-button fa fa-paper-plane" title="${ i18n_send_message }"></button>` : '' }
             ${ this.show_send_button ? html`<button type="submit" class="btn send-button fa fa-paper-plane" title="${ i18n_send_message }"></button>` : '' }
         `;
         `;
     }
     }
 
 
+    firstUpdated () {
+        /**
+         * 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);
+    }
+
     getButtons () {
     getButtons () {
         const buttons = [];
         const buttons = [];
 
 
         if (this.show_emoji_button) {
         if (this.show_emoji_button) {
-            buttons.push(html`<converse-emoji-dropdown .chatview=${this.chatview}></converse-dropdown>`);
+            const chatview = _converse.chatboxviews.get(this.model.get('jid'));
+            buttons.push(html`<converse-emoji-dropdown .chatview=${chatview}></converse-dropdown>`);
         }
         }
 
 
         if (this.show_call_button) {
         if (this.show_call_button) {
@@ -52,10 +67,13 @@ export class ChatToolbar extends CustomElement {
                 </button>`
                 </button>`
             );
             );
         }
         }
-        const i18n_chars_remaining = __('Message characters remaining');
+
         const message_limit = api.settings.get('message_limit');
         const message_limit = api.settings.get('message_limit');
         if (message_limit) {
         if (message_limit) {
-            buttons.push(html`<span class="right message-limit" title="${i18n_chars_remaining}">${this.message_limit}</span>`);
+            buttons.push(html`
+                <converse-message-limit-indicator .model=${this.model} class="right">
+                </converse-message-limit-indicator>`
+            );
         }
         }
 
 
         if (this.show_spoiler_button) {
         if (this.show_spoiler_button) {
@@ -109,7 +127,7 @@ export class ChatToolbar extends CustomElement {
 
 
     getSpoilerButton () {
     getSpoilerButton () {
         const model = this.model;
         const model = this.model;
-        if (!this.is_groupchat && model.presence.resources.length === 0) {
+        if (!this.is_groupchat && !model.presence?.resources.length) {
             return;
             return;
         }
         }
 
 

+ 6 - 7
src/shared/chat/utils.js

@@ -1,12 +1,11 @@
 import { _converse, api } from '@converse/headless/core';
 import { _converse, api } from '@converse/headless/core';
 
 
 export function onScrolledDown (model) {
 export function onScrolledDown (model) {
-if (!model.isHidden()) {
-    model.clearUnreadMsgCounter();
-    if (api.settings.get('allow_url_history_change')) {
-        // Clear location hash if set to one of the messages in our history
-        const hash = window.location.hash;
-        hash && model.messages.get(hash.slice(1)) && _converse.router.history.navigate();
+    if (!model.isHidden()) {
+        if (api.settings.get('allow_url_history_change')) {
+            // Clear location hash if set to one of the messages in our history
+            const hash = window.location.hash;
+            hash && model.messages.get(hash.slice(1)) && _converse.router.history.navigate();
+        }
     }
     }
 }
 }
-}