浏览代码

Merge branch 'jcbrand/declarative-scrolling'

JC Brand 4 年之前
父节点
当前提交
ff233a5b1c
共有 63 个文件被更改,包括 1527 次插入1368 次删除
  1. 4 3
      CHANGES.md
  2. 7 2
      docs/source/configuration.rst
  3. 5 2
      karma.conf.js
  4. 0 435
      spec/emojis.js
  5. 4 3
      spec/mock.js
  6. 32 7
      src/headless/plugins/chat/model.js
  7. 46 15
      src/headless/plugins/muc/muc.js
  8. 116 0
      src/headless/plugins/muc/tests/registration.js
  9. 5 2
      src/headless/utils/core.js
  10. 27 291
      src/plugins/chatview/bottom-panel.js
  11. 229 0
      src/plugins/chatview/message-form.js
  12. 29 3
      src/plugins/chatview/templates/bottom-panel.js
  13. 1 2
      src/plugins/chatview/templates/chat.js
  14. 0 31
      src/plugins/chatview/templates/chatbox_message_form.js
  15. 29 0
      src/plugins/chatview/templates/message-form.js
  16. 0 28
      src/plugins/chatview/templates/toolbar.js
  17. 24 24
      src/plugins/chatview/tests/chatbox.js
  18. 15 15
      src/plugins/chatview/tests/corrections.js
  19. 214 0
      src/plugins/chatview/tests/emojis.js
  20. 2 2
      src/plugins/chatview/tests/markers.js
  21. 4 6
      src/plugins/chatview/tests/messages.js
  22. 3 3
      src/plugins/chatview/tests/receipts.js
  23. 4 4
      src/plugins/chatview/tests/spoilers.js
  24. 12 0
      src/plugins/chatview/utils.js
  25. 8 9
      src/plugins/chatview/view.js
  26. 1 1
      src/plugins/headlines-view/templates/chat-head.js
  27. 1 2
      src/plugins/headlines-view/templates/headlines.js
  28. 0 2
      src/plugins/headlines-view/view.js
  29. 3 2
      src/plugins/mam-views/utils.js
  30. 0 10
      src/plugins/minimize/utils.js
  31. 11 47
      src/plugins/muc-views/bottom-panel.js
  32. 7 30
      src/plugins/muc-views/chatarea.js
  33. 70 0
      src/plugins/muc-views/message-form.js
  34. 2 5
      src/plugins/muc-views/muc.js
  35. 0 2
      src/plugins/muc-views/sidebar.js
  36. 0 24
      src/plugins/muc-views/styles/index.scss
  37. 38 0
      src/plugins/muc-views/templates/message-form.js
  38. 31 4
      src/plugins/muc-views/templates/muc-bottom-panel.js
  39. 1 2
      src/plugins/muc-views/templates/muc-chatarea.js
  40. 30 30
      src/plugins/muc-views/tests/autocomplete.js
  41. 7 7
      src/plugins/muc-views/tests/corrections.js
  42. 228 0
      src/plugins/muc-views/tests/emojis.js
  43. 11 11
      src/plugins/muc-views/tests/mentions.js
  44. 6 6
      src/plugins/muc-views/tests/modtools.js
  45. 6 6
      src/plugins/muc-views/tests/muc-messages.js
  46. 8 64
      src/plugins/muc-views/tests/muc-registration.js
  47. 48 50
      src/plugins/muc-views/tests/muc.js
  48. 0 1
      src/plugins/notifications/tests/notification.js
  49. 10 10
      src/plugins/omemo/tests/omemo.js
  50. 0 0
      src/plugins/rosterview/tests/protocol.js
  51. 8 138
      src/shared/chat/baseview.js
  52. 90 7
      src/shared/chat/chat-content.js
  53. 3 3
      src/shared/chat/emoji-picker-content.js
  54. 1 1
      src/shared/chat/emoji-picker.js
  55. 4 2
      src/shared/chat/message-history.js
  56. 26 0
      src/shared/chat/message-limit.js
  57. 6 1
      src/shared/chat/message.js
  58. 7 0
      src/shared/chat/templates/message-limit.js
  59. 27 9
      src/shared/chat/toolbar.js
  60. 2 2
      src/shared/chat/unfurl.js
  61. 11 0
      src/shared/chat/utils.js
  62. 1 1
      src/shared/directives/rich-text.js
  63. 2 1
      webpack.html

+ 4 - 3
CHANGES.md

@@ -8,6 +8,7 @@
 - #2400: Fixes infinite loop bug when appending .png to allowed image urls
 - #2400: Fixes infinite loop bug when appending .png to allowed image urls
 - #2409: Integrate App Badging API for unread messages
 - #2409: Integrate App Badging API for unread messages
 - #2464: New configuration setting [allow-url-history-change](https://conversejs.org/docs/html/configuration.html#allow-url-history-change)
 - #2464: New configuration setting [allow-url-history-change](https://conversejs.org/docs/html/configuration.html#allow-url-history-change)
+- #2497: Bugfix /nick command is not working
 - Add support for XEP-0437 Room Activity Indicators see [muc-subscribe-to-rai](https://conversejs.org/docs/html/configuration.html#muc-subscribe-to-rai)
 - Add support for XEP-0437 Room Activity Indicators see [muc-subscribe-to-rai](https://conversejs.org/docs/html/configuration.html#muc-subscribe-to-rai)
 - Bugfix: Use real JID in XEP-0372 references only when the MUC is non-anonymous
 - Bugfix: Use real JID in XEP-0372 references only when the MUC is non-anonymous
 - Bugfix: Connection protocol not updated based on XEP-0156 connection methods
 - Bugfix: Connection protocol not updated based on XEP-0156 connection methods
@@ -24,7 +25,7 @@
 - Use the MUC stanza id when sending XEP-0333 markers
 - Use the MUC stanza id when sending XEP-0333 markers
 - Add support for rendering unfurls via [mod_ogp](https://modules.prosody.im/mod_ogp.html)
 - Add support for rendering unfurls via [mod_ogp](https://modules.prosody.im/mod_ogp.html)
 - Add a Description Of A Project (DOAP) file
 - Add a Description Of A Project (DOAP) file
-- #2497: Bugfix /nick command is not working
+- Add ability to deregister nickname when closing a MUC by setting `auto_register_muc_nickname` to `'unregister'`.
 
 
 ### Breaking Changes
 ### Breaking Changes
 
 
@@ -38,8 +39,8 @@ Removed events:
 * `rosterGroupsFetched`
 * `rosterGroupsFetched`
 * `messageSend` (use `sendMessage` instead)
 * `messageSend` (use `sendMessage` instead)
 
 
-The `chatBoxMaximized` and `chatBoxMinimized` events now have the `model` as
-payload and not the `view` since it might not be exist at that time.
+The `chatBoxClosed`, `chatBoxMaximized` and `chatBoxMinimized` events now have the `model` as
+payload and not the `view`.
 
 
 ## 7.0.5 (Unreleased)
 ## 7.0.5 (Unreleased)
 
 

+ 7 - 2
docs/source/configuration.rst

@@ -435,12 +435,17 @@ auto_register_muc_nickname
 --------------------------
 --------------------------
 
 
 * Default: ``false``
 * Default: ``false``
+* Allowed values: ``false``, ``true``, ``'unregister'``
 
 
-Determines whether Converse should automatically register a user's nickname
-when they enter a groupchat.
+If truthy, Converse will automatically register a user's nickname upon entering
+a groupchat.
 
 
 See here fore more details: https://xmpp.org/extensions/xep-0045.html#register
 See here fore more details: https://xmpp.org/extensions/xep-0045.html#register
 
 
+If set to ``'unregister'``, then the user's nickname will be registered
+(because it's a truthy value) and also be unregistered when the user
+permanently leaves the MUC by closing it.
+
 auto_subscribe
 auto_subscribe
 --------------
 --------------
 
 

+ 5 - 2
karma.conf.js

@@ -25,14 +25,13 @@ 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/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' },
       { pattern: "src/headless/plugins/caps/tests/caps.js", type: 'module' },
       { pattern: "src/headless/plugins/caps/tests/caps.js", type: 'module' },
       { pattern: "src/headless/plugins/chat/tests/api.js", type: 'module' },
       { pattern: "src/headless/plugins/chat/tests/api.js", type: 'module' },
       { pattern: "src/headless/plugins/disco/tests/disco.js", type: 'module' },
       { pattern: "src/headless/plugins/disco/tests/disco.js", type: 'module' },
       { pattern: "src/headless/plugins/muc/tests/affiliations.js", type: 'module' },
       { pattern: "src/headless/plugins/muc/tests/affiliations.js", type: 'module' },
+      { pattern: "src/headless/plugins/muc/tests/registration.js", type: 'module' },
       { pattern: "src/headless/plugins/ping/tests/ping.js", type: 'module' },
       { pattern: "src/headless/plugins/ping/tests/ping.js", type: 'module' },
       { pattern: "src/headless/plugins/roster/tests/presence.js", type: 'module' },
       { pattern: "src/headless/plugins/roster/tests/presence.js", type: 'module' },
       { pattern: "src/headless/plugins/smacks/tests/smacks.js", type: 'module' },
       { pattern: "src/headless/plugins/smacks/tests/smacks.js", type: 'module' },
@@ -41,7 +40,9 @@ module.exports = function(config) {
       { pattern: "src/headless/tests/eventemitter.js", type: 'module' },
       { pattern: "src/headless/tests/eventemitter.js", type: 'module' },
       { 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/emojis.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' },
@@ -58,6 +59,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/muc-views/tests/autocomplete.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/autocomplete.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/component.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/component.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/corrections.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/corrections.js", type: 'module' },
+      { pattern: "src/plugins/muc-views/tests/emojis.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/hats.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/hats.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/http-file-upload.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/http-file-upload.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/mentions.js", type: 'module' },
       { pattern: "src/plugins/muc-views/tests/mentions.js", type: 'module' },
@@ -78,6 +80,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/register/tests/register.js", type: 'module' },
       { pattern: "src/plugins/register/tests/register.js", type: 'module' },
       { pattern: "src/plugins/rootview/tests/root.js", type: 'module' },
       { pattern: "src/plugins/rootview/tests/root.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/presence.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/presence.js", type: 'module' },
+      { pattern: "src/plugins/rosterview/tests/protocol.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/roster.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/roster.js", type: 'module' },
       { pattern: "src/shared/chat/tests/styling.js", type: 'module' },
       { pattern: "src/shared/chat/tests/styling.js", type: 'module' },
     ],
     ],

+ 0 - 435
spec/emojis.js

@@ -1,435 +0,0 @@
-/*global mock, converse */
-
-const { Promise, $msg, $pres, sizzle } = converse.env;
-const u = converse.env.utils;
-const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
-
-
-describe("Emojis", function () {
-    describe("The emoji picker", function () {
-
-        beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000));
-        afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
-
-        it("can be opened by clicking a button in the chat toolbar",
-                mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
-
-            const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            await mock.waitForRoster(_converse, 'current');
-            await mock.openControlBox(_converse);
-            await mock.openChatBoxFor(_converse, contact_jid);
-            const view = _converse.chatboxviews.get(contact_jid);
-            const toolbar = await u.waitUntil(() => view.querySelector('converse-chat-toolbar'));
-            toolbar.querySelector('.toggle-emojis').click();
-            await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')), 1000);
-            const item = view.querySelector('.emoji-picker li.insert-emoji a');
-            item.click()
-            expect(view.querySelector('textarea.chat-textarea').value).toBe(':smiley: ');
-            toolbar.querySelector('.toggle-emojis').click(); // Close the panel again
-            done();
-        }));
-
-        it("is opened to autocomplete emojis in the textarea",
-                mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
-
-            await mock.waitForRoster(_converse, 'current', 0);
-            const muc_jid = 'lounge@montague.lit';
-            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.chatboxviews.get(muc_jid);
-            await u.waitUntil(() => view.querySelector('converse-emoji-dropdown'));
-            const textarea = view.querySelector('textarea.chat-textarea');
-            textarea.value = ':gri';
-
-            // Press tab
-            const tab_event = {
-                'target': textarea,
-                'preventDefault': function preventDefault () {},
-                'stopPropagation': function stopPropagation () {},
-                'keyCode': 9,
-                'key': 'Tab'
-            }
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown(tab_event);
-            await u.waitUntil(() => view.querySelector('converse-emoji-picker .emoji-search').value === ':gri');
-            await u.waitUntil(() =>  sizzle('.emojis-lists__container--search .insert-emoji', view).length === 3, 1000);
-            let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', view);
-            expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:');
-            expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':grin:');
-            expect(visible_emojis[2].getAttribute('data-emoji')).toBe(':grinning:');
-
-            const picker = view.querySelector('converse-emoji-picker');
-            const input = picker.querySelector('.emoji-search');
-            // Test that TAB autocompletes the to first match
-            input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
-
-            await u.waitUntil(() => sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker).length === 1, 1000);
-            visible_emojis = sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker);
-            expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:');
-            expect(input.value).toBe(':grimacing:');
-
-            // Check that ENTER now inserts the match
-            const enter_event = Object.assign({}, tab_event, {'keyCode': 13, 'key': 'Enter', 'target': input});
-            input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
-
-            await u.waitUntil(() => input.value === '');
-            await u.waitUntil(() => textarea.value === ':grimacing: ');
-
-            // Test that username starting with : doesn't cause issues
-            const presence = $pres({
-                    'from': `${muc_jid}/:username`,
-                    'id': '27C55F89-1C6A-459A-9EB5-77690145D624',
-                    'to': _converse.jid
-                })
-                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
-                    .c('item', {
-                        'jid': 'some1@montague.lit',
-                        'affiliation': 'member',
-                        'role': 'participant'
-                    });
-            _converse.connection._dataRecv(mock.createRequest(presence));
-
-            textarea.value = ':use';
-            bottom_panel.onKeyDown(tab_event);
-            await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
-            await u.waitUntil(() => input.value === ':use');
-            visible_emojis = sizzle('.insert-emoji:not(.hidden)', picker);
-            expect(visible_emojis.length).toBe(0);
-            done();
-        }));
-
-        it("is focused to autocomplete emojis in the textarea",
-                mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
-
-            const muc_jid = 'lounge@montague.lit';
-            await mock.waitForRoster(_converse, 'current', 0);
-            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.chatboxviews.get(muc_jid);
-            await u.waitUntil(() => view.querySelector('converse-emoji-dropdown'));
-            const textarea = view.querySelector('textarea.chat-textarea');
-            textarea.value = ':';
-            // Press tab
-            const tab_event = {
-                'target': textarea,
-                'preventDefault': function preventDefault () {},
-                'stopPropagation': function stopPropagation () {},
-                'keyCode': 9,
-                'key': 'Tab'
-            }
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown(tab_event);
-            await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
-
-            const picker = view.querySelector('converse-emoji-picker');
-            const input = picker.querySelector('.emoji-search');
-            expect(input.value).toBe(':');
-            input.value = ':gri';
-            const event = {
-                'target': input,
-                'preventDefault': function preventDefault () {},
-                'stopPropagation': function stopPropagation () {}
-            };
-            input.dispatchEvent(new KeyboardEvent('keydown', event));
-            await u.waitUntil(() =>  sizzle('.emojis-lists__container--search .insert-emoji', view).length === 3, 1000);
-            let emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop();
-            emoji.click();
-            await u.waitUntil(() => textarea.value === ':grinning: ');
-            textarea.value = ':grinning: :';
-            bottom_panel.onKeyDown(tab_event);
-
-            await u.waitUntil(() => input.value === ':');
-            input.value = ':grimacing';
-            input.dispatchEvent(new KeyboardEvent('keydown', event));
-            await u.waitUntil(() =>  sizzle('.emojis-lists__container--search .insert-emoji', view).length === 1, 1000);
-            emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop();
-            emoji.click();
-            await u.waitUntil(() => textarea.value === ':grinning: :grimacing: ');
-            done();
-        }));
-
-
-        it("properly inserts emojis into the chat textarea",
-                mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
-
-            const muc_jid = 'lounge@montague.lit';
-            await mock.waitForRoster(_converse, 'current', 0);
-            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.chatboxviews.get(muc_jid);
-            await u.waitUntil(() => view.querySelector('converse-emoji-dropdown'));
-            const textarea = view.querySelector('textarea.chat-textarea');
-            textarea.value = ':gri';
-
-            // Press tab
-            const tab_event = {
-                'target': textarea,
-                'preventDefault': function preventDefault () {},
-                'stopPropagation': function stopPropagation () {},
-                'keyCode': 9,
-                'key': 'Tab'
-            }
-            textarea.value = ':';
-            const bottom_panel = view.querySelector('converse-muc-bottom-panel');
-            bottom_panel.onKeyDown(tab_event);
-            await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
-            const picker = view.querySelector('converse-emoji-picker');
-            const input = picker.querySelector('.emoji-search');
-            input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
-            await u.waitUntil(() => input.value === ':100:');
-            const enter_event = Object.assign({}, tab_event, {'keyCode': 13, 'key': 'Enter', 'target': input});
-            input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
-            expect(textarea.value).toBe(':100: ');
-
-            textarea.value = ':';
-            bottom_panel.onKeyDown(tab_event);
-            await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
-            await u.waitUntil(() => input.value === ':');
-            input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
-            await u.waitUntil(() => input.value === ':100:');
-            await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 1, 1000);
-            const emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop();
-            emoji.click();
-            expect(textarea.value).toBe(':100: ');
-            done();
-        }));
-
-
-        it("allows you to search for particular emojis",
-                mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
-
-            const muc_jid = 'lounge@montague.lit';
-            await mock.waitForRoster(_converse, 'current', 0);
-            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.chatboxviews.get(muc_jid);
-            await u.waitUntil(() => view.querySelector('converse-emoji-dropdown'));
-            const toolbar = view.querySelector('converse-chat-toolbar');
-            toolbar.querySelector('.toggle-emojis').click();
-            await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
-            await u.waitUntil(() => sizzle('converse-chat-toolbar .insert-emoji:not(.hidden)', view).length === 1589);
-
-            const input = view.querySelector('.emoji-search');
-            input.value = 'smiley';
-            const event = {
-                'target': input,
-                'preventDefault': function preventDefault () {},
-                'stopPropagation': function stopPropagation () {}
-            };
-            input.dispatchEvent(new KeyboardEvent('keydown', event));
-
-            await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 2, 1000);
-            let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view);
-            expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
-            expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':smiley_cat:');
-
-            // Check that pressing enter without an unambiguous match does nothing
-            const enter_event = Object.assign({}, event, {'keyCode': 13});
-            input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
-            expect(input.value).toBe('smiley');
-
-            // Check that search results update when chars are deleted
-            input.value = 'sm';
-            input.dispatchEvent(new KeyboardEvent('keydown', event));
-            await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 25, 1000);
-
-            input.value = 'smiley';
-            input.dispatchEvent(new KeyboardEvent('keydown', event));
-            await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 2, 1000);
-
-            // Test that TAB autocompletes the to first match
-            const tab_event = Object.assign({}, event, {'keyCode': 9, 'key': 'Tab'});
-            input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
-
-            await u.waitUntil(() => input.value === ':smiley:');
-            await u.waitUntil(() => sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", view).length === 1, 1000);
-            visible_emojis = sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", view);
-            expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
-
-            // Check that ENTER now inserts the match
-            input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
-            await u.waitUntil(() => input.value === '');
-            expect(view.querySelector('textarea.chat-textarea').value).toBe(':smiley: ');
-            done();
-        }));
-    });
-
-    describe("A Chat Message", function () {
-
-        it("will display larger if it's only emojis",
-                mock.initConverse(['chatBoxesFetched'], {'use_system_emojis': true}, async function (done, _converse) {
-
-            await mock.waitForRoster(_converse, 'current');
-            const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            _converse.handleMessageStanza($msg({
-                    'from': sender_jid,
-                    'to': _converse.connection.jid,
-                    'type': 'chat',
-                    'id': _converse.connection.getUniqueId()
-                }).c('body').t('😇').up()
-                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-            await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
-            const view = _converse.api.chatviews.get(sender_jid);
-            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
-            await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.querySelector('.chat-msg__text')));
-
-            _converse.handleMessageStanza($msg({
-                    'from': sender_jid,
-                    'to': _converse.connection.jid,
-                    'type': 'chat',
-                    'id': _converse.connection.getUniqueId()
-                }).c('body').t('😇 Hello world! 😇 😇').up()
-                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
-
-            let sel = '.message:last-child .chat-msg__text';
-            await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.querySelector(sel)));
-
-            // Test that a modified message that no longer contains only
-            // emojis now renders normally again.
-            const textarea = view.querySelector('textarea.chat-textarea');
-            textarea.value = ':poop: :innocent:';
-            const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-            bottom_panel.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
-            const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
-            await u.waitUntil(() => view.querySelector(last_msg_sel).textContent === '💩 😇');
-
-            expect(textarea.value).toBe('');
-            bottom_panel.onKeyDown({
-                target: textarea,
-                keyCode: 38 // Up arrow
-            });
-            expect(textarea.value).toBe('💩 😇');
-            expect(view.model.messages.at(2).get('correcting')).toBe(true);
-            sel = 'converse-chat-message:last-child .chat-msg'
-            await u.waitUntil(() => u.hasClass('correcting', view.querySelector(sel)), 500);
-            const edited_text = textarea.value += 'This is no longer an emoji-only message';
-            textarea.value = edited_text;
-            bottom_panel.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text'))
-                .filter(el => el.textContent === edited_text).length);
-            expect(view.model.messages.models.length).toBe(3);
-            let message = view.querySelector(last_msg_sel);
-            expect(u.hasClass('chat-msg__text--larger', message)).toBe(false);
-
-            textarea.value = ':smile: Hello world!';
-            bottom_panel.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
-
-            textarea.value = ':smile: :smiley: :imp:';
-            bottom_panel.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 5);
-
-            message = view.querySelector('.message:last-child .chat-msg__text');
-            expect(u.hasClass('chat-msg__text--larger', message)).toBe(true);
-            done()
-        }));
-
-        it("can render emojis as images",
-                mock.initConverse(
-                    ['chatBoxesFetched'], {'use_system_emojis': false},
-                    async function (done, _converse) {
-
-            await mock.waitForRoster(_converse, 'current');
-            const contact_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            _converse.handleMessageStanza($msg({
-                    'from': contact_jid,
-                    'to': _converse.connection.jid,
-                    'type': 'chat',
-                    'id': _converse.connection.getUniqueId()
-                }).c('body').t('😇').up()
-                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
-            await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
-            const view = _converse.api.chatviews.get(contact_jid);
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
-            await u.waitUntil(() => view.querySelector('.chat-msg__text').innerHTML.replace(/<!-.*?->/g, '') ===
-                '<img class="emoji" draggable="false" title=":innocent:" alt="😇" src="https://twemoji.maxcdn.com/v/12.1.6//72x72/1f607.png">');
-
-            const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
-            let message = view.querySelector(last_msg_sel);
-            await u.waitUntil(() => u.isVisible(message.querySelector('.emoji')), 1000);
-            let imgs = message.querySelectorAll('.emoji');
-            expect(imgs.length).toBe(1);
-            expect(imgs[0].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f607.png');
-
-            const textarea = view.querySelector('textarea.chat-textarea');
-            textarea.value = ':poop: :innocent:';
-            const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-            bottom_panel.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
-            message = view.querySelector(last_msg_sel);
-            await u.waitUntil(() => u.isVisible(message.querySelector('.emoji')), 1000);
-            imgs = message.querySelectorAll('.emoji');
-            expect(imgs.length).toBe(2);
-            expect(imgs[0].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f4a9.png');
-            expect(imgs[1].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f607.png');
-
-            const sent_stanzas = _converse.connection.sent_stanzas;
-            const sent_stanza = sent_stanzas.filter(s => s.nodeName === 'message').pop();
-            expect(sent_stanza.querySelector('body').innerHTML).toBe('💩 😇');
-            done()
-        }));
-
-        it("can show custom emojis",
-            mock.initConverse(
-                ['chatBoxesFetched'],
-                { emoji_categories: {
-                    "smileys": ":grinning:",
-                    "people": ":thumbsup:",
-                    "activity": ":soccer:",
-                    "travel": ":motorcycle:",
-                    "objects": ":bomb:",
-                    "nature": ":rainbow:",
-                    "food": ":hotdog:",
-                    "symbols": ":musical_note:",
-                    "flags": ":flag_ac:",
-                    "custom": ':xmpp:'
-                } },
-                async function (done, _converse) {
-
-            await mock.waitForRoster(_converse, 'current', 1);
-            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
-            await mock.openChatBoxFor(_converse, contact_jid);
-            const view = _converse.api.chatviews.get(contact_jid);
-
-            const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar'));
-            toolbar.querySelector('.toggle-emojis').click();
-            await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')), 1000);
-            const picker = await u.waitUntil(() => view.querySelector('converse-emoji-picker'), 1000);
-            const custom_category = picker.querySelector('.pick-category[data-category="custom"]');
-            expect(custom_category.innerHTML.replace(/<!-.*?->/g, '').trim()).toBe(
-                '<img class="emoji" draggable="false" title=":xmpp:" alt=":xmpp:" src="/dist/images/custom_emojis/xmpp.png">');
-
-            const textarea = view.querySelector('textarea.chat-textarea');
-            textarea.value = 'Running tests for :converse:';
-            const bottom_panel = view.querySelector('converse-chat-bottom-panel');
-            bottom_panel.onKeyDown({
-                target: textarea,
-                preventDefault: function preventDefault () {},
-                keyCode: 13 // Enter
-            });
-            await new Promise(resolve => view.model.messages.once('rendered', resolve));
-            const body = view.querySelector('converse-chat-message-body');
-            await u.waitUntil(() => body.innerHTML.replace(/<!-.*?->/g, '').trim() ===
-                'Running tests for <img class="emoji" draggable="false" title=":converse:" alt=":converse:" src="/dist/images/custom_emojis/converse.png">');
-            done();
-        }));
-    });
-});

+ 4 - 3
spec/mock.js

@@ -322,7 +322,8 @@ mock.openAndEnterChatRoom = async function (_converse, muc_jid, nick, features=[
     const affs = _converse.muc_fetch_members;
     const affs = _converse.muc_fetch_members;
     const all_affiliations = Array.isArray(affs) ? affs :  (affs ? ['member', 'admin', 'owner'] : []);
     const all_affiliations = Array.isArray(affs) ? affs :  (affs ? ['member', 'admin', 'owner'] : []);
     await mock.returnMemberLists(_converse, muc_jid, members, all_affiliations);
     await mock.returnMemberLists(_converse, muc_jid, members, all_affiliations);
-    return model.messages.fetched;
+    await model.messages.fetched;
+    return model;
 };
 };
 
 
 mock.createContact = async function (_converse, name, ask, requesting, subscription) {
 mock.createContact = async function (_converse, name, ask, requesting, subscription) {
@@ -435,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

+ 32 - 7
src/headless/plugins/chat/model.js

@@ -56,6 +56,7 @@ const ChatBox = ModelWithContact.extend({
         this.set({'box_id': `box-${jid}`});
         this.set({'box_id': `box-${jid}`});
         this.initNotifications();
         this.initNotifications();
         this.initMessages();
         this.initMessages();
+        this.initUI();
 
 
         if (this.get('type') === _converse.PRIVATE_CHAT_TYPE) {
         if (this.get('type') === _converse.PRIVATE_CHAT_TYPE) {
             this.presence = _converse.presences.findWhere({'jid': jid}) || _converse.presences.create({'jid': jid});
             this.presence = _converse.presences.findWhere({'jid': jid}) || _converse.presences.create({'jid': jid});
@@ -63,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();
         /**
         /**
@@ -106,6 +108,10 @@ const ChatBox = ModelWithContact.extend({
         });
         });
     },
     },
 
 
+    initUI () {
+        this.ui = new Model();
+    },
+
     initNotifications () {
     initNotifications () {
         this.notifications = new Model();
         this.notifications = new Model();
     },
     },
@@ -193,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) {
@@ -206,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) {
@@ -250,6 +256,12 @@ const ChatBox = ModelWithContact.extend({
     },
     },
 
 
     async close () {
     async close () {
+        if (api.connection.connected()) {
+            // Immediately sending the chat state, because the
+            // model is going to be destroyed afterwards.
+            this.setChatState(_converse.INACTIVE);
+            this.sendChatState();
+        }
         try {
         try {
             await new Promise((success, reject) => {
             await new Promise((success, reject) => {
                 return this.destroy({success, 'error': (m, e) => reject(e)})
                 return this.destroy({success, 'error': (m, e) => reject(e)})
@@ -261,6 +273,13 @@ const ChatBox = ModelWithContact.extend({
                 await this.clearMessages();
                 await this.clearMessages();
             }
             }
         }
         }
+        /**
+         * Triggered once a chatbox has been closed.
+         * @event _converse#chatBoxClosed
+         * @type {_converse.ChatBox | _converse.ChatRoom}
+         * @example _converse.api.listen.on('chatBoxClosed', chat => { ... });
+         */
+        api.trigger('chatBoxClosed', this);
     },
     },
 
 
     announceReconnection () {
     announceReconnection () {
@@ -268,7 +287,7 @@ const ChatBox = ModelWithContact.extend({
          * Triggered whenever a `_converse.ChatBox` instance has reconnected after an outage
          * Triggered whenever a `_converse.ChatBox` instance has reconnected after an outage
          * @event _converse#onChatReconnected
          * @event _converse#onChatReconnected
          * @type {_converse.ChatBox | _converse.ChatRoom}
          * @type {_converse.ChatBox | _converse.ChatRoom}
-         * @example _converse.api.listen.on('onChatReconnected', chatbox => { ... });
+         * @example _converse.api.listen.on('onChatReconnected', chat => { ... });
          */
          */
         api.trigger('chatReconnected', this);
         api.trigger('chatReconnected', this);
     },
     },
@@ -663,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']
@@ -848,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) {
@@ -1011,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) {
@@ -1018,7 +1037,13 @@ const ChatBox = ModelWithContact.extend({
             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': this.get('num_unread') + 1
                     'num_unread': this.get('num_unread') + 1
                 };
                 };
@@ -1040,7 +1065,7 @@ const ChatBox = ModelWithContact.extend({
     },
     },
 
 
     isScrolledUp () {
     isScrolledUp () {
-        return this.get('scrolled', true);
+        return this.get('scrolled');
     }
     }
 });
 });
 
 

+ 46 - 15
src/headless/plugins/muc/muc.js

@@ -90,12 +90,14 @@ const ChatRoomMixin = {
         this.set('box_id', `box-${this.get('jid')}`);
         this.set('box_id', `box-${this.get('jid')}`);
         this.initNotifications();
         this.initNotifications();
         this.initMessages();
         this.initMessages();
+        this.initUI();
         this.initOccupants();
         this.initOccupants();
         this.initDiscoModels(); // sendChatState depends on this.features
         this.initDiscoModels(); // sendChatState depends on this.features
         this.registerHandlers();
         this.registerHandlers();
 
 
         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();
@@ -845,6 +847,12 @@ const ChatRoomMixin = {
 
 
     async close (ev) {
     async close (ev) {
         await this.leave();
         await this.leave();
+        if (
+            api.settings.get('auto_register_muc_nickname') === 'unregister' &&
+            (await api.disco.supports(Strophe.NS.MUC_REGISTER, this.get('jid')))
+        ) {
+            this.unregisterNickname();
+        }
         this.occupants.clearStore();
         this.occupants.clearStore();
 
 
         if (ev?.name !== 'closeAllChatBoxes' && api.settings.get('muc_clear_messages_on_leave')) {
         if (ev?.name !== 'closeAllChatBoxes' && api.settings.get('muc_clear_messages_on_leave')) {
@@ -1533,7 +1541,7 @@ const ChatRoomMixin = {
 
 
     async registerNickname () {
     async registerNickname () {
         // See https://xmpp.org/extensions/xep-0045.html#register
         // See https://xmpp.org/extensions/xep-0045.html#register
-        const __ = _converse.__;
+        const { __ } = _converse;
         const nick = this.get('nick');
         const nick = this.get('nick');
         const jid = this.get('jid');
         const jid = this.get('jid');
         let iq, err_msg;
         let iq, err_msg;
@@ -1541,7 +1549,6 @@ const ChatRoomMixin = {
             iq = await api.sendIQ(
             iq = await api.sendIQ(
                 $iq({
                 $iq({
                     'to': jid,
                     'to': jid,
-                    'from': _converse.connection.jid,
                     'type': 'get'
                     'type': 'get'
                 }).c('query', { 'xmlns': Strophe.NS.MUC_REGISTER })
                 }).c('query', { 'xmlns': Strophe.NS.MUC_REGISTER })
             );
             );
@@ -1562,19 +1569,13 @@ const ChatRoomMixin = {
             await api.sendIQ(
             await api.sendIQ(
                 $iq({
                 $iq({
                     'to': jid,
                     'to': jid,
-                    'from': _converse.connection.jid,
                     'type': 'set'
                     'type': 'set'
-                })
-                    .c('query', { 'xmlns': Strophe.NS.MUC_REGISTER })
+                }).c('query', { 'xmlns': Strophe.NS.MUC_REGISTER })
                     .c('x', { 'xmlns': Strophe.NS.XFORM, 'type': 'submit' })
                     .c('x', { 'xmlns': Strophe.NS.XFORM, 'type': 'submit' })
-                    .c('field', { 'var': 'FORM_TYPE' })
-                    .c('value')
-                    .t('http://jabber.org/protocol/muc#register')
-                    .up()
-                    .up()
-                    .c('field', { 'var': 'muc#register_roomnick' })
-                    .c('value')
-                    .t(nick)
+                        .c('field', { 'var': 'FORM_TYPE' })
+                            .c('value').t('http://jabber.org/protocol/muc#register').up().up()
+                        .c('field', { 'var': 'muc#register_roomnick' })
+                            .c('value').t(nick)
             );
             );
         } catch (e) {
         } catch (e) {
             if (sizzle(`service-unavailable[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
             if (sizzle(`service-unavailable[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
@@ -1588,6 +1589,28 @@ const ChatRoomMixin = {
         }
         }
     },
     },
 
 
+    async unregisterNickname () {
+        const jid = this.get('jid');
+        let iq;
+        try {
+            iq = await api.sendIQ(
+                $iq({
+                    'to': jid,
+                    'type': 'set'
+                }).c('query', { 'xmlns': Strophe.NS.MUC_REGISTER })
+            );
+        } catch (e) {
+            log.error(e);
+            return e;
+        }
+        if (sizzle(`query[xmlns="${Strophe.NS.MUC_REGISTER}"] registered`, iq).pop()) {
+            const iq = $iq({ 'to': jid, 'type': 'set' })
+                .c('query', { 'xmlns': Strophe.NS.MUC_REGISTER })
+                .c('remove');
+            return api.sendIQ(iq).catch(e => log.error(e));
+        }
+    },
+
     /**
     /**
      * Given a presence stanza, update the occupant model based on its contents.
      * Given a presence stanza, update the occupant model based on its contents.
      * @private
      * @private
@@ -2540,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
@@ -2550,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
                 };
                 };

+ 116 - 0
src/headless/plugins/muc/tests/registration.js

@@ -0,0 +1,116 @@
+/*global mock, converse */
+
+const { $iq, Strophe, sizzle, u } = converse.env;
+
+describe("Chatrooms", function () {
+
+    describe("The auto_register_muc_nickname option", function () {
+
+        it("allows you to automatically register your nickname when joining a room",
+                mock.initConverse(['chatBoxesFetched'], {'auto_register_muc_nickname': true},
+                async function (done, _converse) {
+
+            const muc_jid = 'coven@chat.shakespeare.lit';
+            const room = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+
+            let stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
+                iq => sizzle(`iq[to="${muc_jid}"][type="get"] query[xmlns="jabber:iq:register"]`, iq).length
+            ).pop());
+
+            expect(Strophe.serialize(stanza))
+            .toBe(`<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" `+
+                        `type="get" xmlns="jabber:client">`+
+                    `<query xmlns="jabber:iq:register"/></iq>`);
+            const result = $iq({
+                'from': room.get('jid'),
+                'id': stanza.getAttribute('id'),
+                'to': _converse.bare_jid,
+                'type': 'result',
+            }).c('query', {'xmlns': 'jabber:iq:register'})
+                .c('x', {'xmlns': 'jabber:x:data', 'type': 'form'})
+                    .c('field', {
+                        'label': 'Desired Nickname',
+                        'type': 'text-single',
+                        'var': 'muc#register_roomnick'
+                    }).c('required');
+            _converse.connection._dataRecv(mock.createRequest(result));
+            stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
+                iq => sizzle(`iq[to="${muc_jid}"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
+            ).pop());
+
+            expect(Strophe.serialize(stanza)).toBe(
+                `<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="jabber:iq:register">`+
+                        `<x type="submit" xmlns="jabber:x:data">`+
+                            `<field var="FORM_TYPE"><value>http://jabber.org/protocol/muc#register</value></field>`+
+                            `<field var="muc#register_roomnick"><value>romeo</value></field>`+
+                        `</x>`+
+                    `</query>`+
+                `</iq>`);
+            done();
+        }));
+
+        it("allows you to automatically deregister your nickname when closing a room",
+                mock.initConverse(['chatBoxesFetched'], {'auto_register_muc_nickname': 'unregister'},
+                async function (done, _converse) {
+
+            const muc_jid = 'coven@chat.shakespeare.lit';
+            const room = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+
+            let stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
+                iq => sizzle(`iq[to="${muc_jid}"][type="get"] query[xmlns="jabber:iq:register"]`, iq).length
+            ).pop());
+            let result = $iq({
+                'from': room.get('jid'),
+                'id': stanza.getAttribute('id'),
+                'to': _converse.bare_jid,
+                'type': 'result',
+            }).c('query', {'xmlns': 'jabber:iq:register'})
+                .c('x', {'xmlns': 'jabber:x:data', 'type': 'form'})
+                    .c('field', {
+                        'label': 'Desired Nickname',
+                        'type': 'text-single',
+                        'var': 'muc#register_roomnick'
+                    }).c('required');
+            _converse.connection._dataRecv(mock.createRequest(result));
+            await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
+                iq => sizzle(`iq[to="${muc_jid}"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
+            ).pop());
+
+            _converse.connection.IQ_stanzas = [];
+            room.close();
+            stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
+                iq => sizzle(`iq[to="${muc_jid}"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
+            ).pop());
+            _converse.connection.IQ_stanzas = [];
+
+            result = $iq({
+                'from': room.get('jid'),
+                'id': stanza.getAttribute('id'),
+                'to': _converse.bare_jid,
+                'type': 'result',
+            }).c('query', {'xmlns': 'jabber:iq:register'})
+                .c('registered').up()
+                .c('username').t('romeo');
+            _converse.connection._dataRecv(mock.createRequest(result));
+
+            stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
+                iq => sizzle(`iq[to="${muc_jid}"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
+            ).pop());
+            expect(Strophe.serialize(stanza)).toBe(
+                `<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+
+                    `<query xmlns="jabber:iq:register"><remove/></query>`+
+                `</iq>`);
+
+            result = $iq({
+                'from': room.get('jid'),
+                'id': stanza.getAttribute('id'),
+                'to': _converse.bare_jid,
+                'type': 'result',
+            }).c('query', {'xmlns': 'jabber:iq:register'});
+            _converse.connection._dataRecv(mock.createRequest(result));
+
+            done();
+        }));
+    });
+});

+ 5 - 2
src/headless/utils/core.js

@@ -362,13 +362,16 @@ u.onMultipleEvents = function (events=[], callback) {
     events.forEach(e => e.object.on(e.event, handler));
     events.forEach(e => e.object.on(e.event, handler));
 };
 };
 
 
-u.safeSave = function (model, attributes, options) {
+
+export function safeSave (model, attributes, options) {
     if (u.isPersistableModel(model)) {
     if (u.isPersistableModel(model)) {
         model.save(attributes, options);
         model.save(attributes, options);
     } else {
     } else {
         model.set(attributes, options);
         model.set(attributes, options);
     }
     }
-};
+}
+
+u.safeSave = safeSave;
 
 
 u.siblingIndex = function (el) {
 u.siblingIndex = function (el) {
     /* eslint-disable no-cond-assign */
     /* eslint-disable no-cond-assign */

+ 27 - 291
src/plugins/chatview/bottom-panel.js

@@ -1,108 +1,47 @@
-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 .toggle-clear': 'clearMessages',
-    }
+        'click .send-button': 'sendButtonClicked',
+        '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:composing_spoiler', this.renderMessageForm);
         await this.model.initialized;
         await this.model.initialized;
-        this.listenTo(this.model.messages, 'change:correcting', this.onMessageCorrecting);
+        this.listenTo(this.model, 'change:num_unread', this.debouncedRender)
+        this.listenTo(this.model, 'emoji-picker-autocomplete', this.autocompleteInPicker);
+
+        this.addEventListener('focusin', ev => this.emitFocused(ev));
+        this.addEventListener('focusout', ev => this.emitBlurred(ev));
         this.render();
         this.render();
-        api.listen.on('chatBoxScrolledDown', () => this.hideNewMessagesIndicator());
     }
     }
 
 
     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),
-                    'label_message': this.model.get('composing_spoiler') ? __('Hidden message') : __('Message'),
-                    'label_spoiler_hint': __('Optional hint'),
-                    '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),
-                    'show_send_button': api.settings.get('show_send_button'),
-                    'show_toolbar': api.settings.get('show_toolbar'),
-                    'unread_msgs': __('You have unread messages'),
-                    '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) {
         ev?.preventDefault?.();
         ev?.preventDefault?.();
-        this.model.save({ 'scrolled': false, 'scrollTop': null });
-        _converse.chatboxviews.get(this.getAttribute('jid'))?.scrollDown();
-    }
-
-    hideNewMessagesIndicator () {
-        this.querySelector('.new-msgs-indicator')?.classList.add('hidden');
-    }
-
-    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);
-            }
-        }
+        this.model.save({ 'scrolled': false });
     }
     }
 
 
     emitFocused (ev) {
     emitFocused (ev) {
@@ -117,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
@@ -148,209 +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);
-    }
-
-    autocompleteInPicker (input, value) {
-        const emoji_dropdown = this.querySelector('converse-emoji-dropdown');
+    async autocompleteInPicker (input, value) {
+        await api.emojis.initialize();
         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>
+    `;
+}

+ 1 - 2
src/plugins/chatview/templates/chat.js

@@ -8,8 +8,7 @@ export default (o) => html`
             <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
             <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
                 <converse-chat-content
                 <converse-chat-content
                     class="chat-content__messages"
                     class="chat-content__messages"
-                    jid="${o.jid}"
-                    @scroll=${o.markScrolled}></converse-chat-content>
+                    jid="${o.jid}"></converse-chat-content>
 
 
                 <div class="chat-content__help"></div>
                 <div class="chat-content__help"></div>
             </div>
             </div>

+ 0 - 31
src/plugins/chatview/templates/chatbox_message_form.js

@@ -1,31 +0,0 @@
-import { html } from "lit";
-
-
-export default (o) => html`
-    <div class="new-msgs-indicator hidden" @click=${ev => o.viewUnreadMessages(ev)}>▼ ${ o.unread_msgs } ▼</div>
-    <form class="setNicknameButtonForm hidden">
-        <input type="submit" class="btn btn-primary" name="join" value="Join"/>
-    </form>
-    <form class="sendXMPPMessage">
-        <span class="chat-toolbar no-text-select"></span>
-        <input type="text" placeholder="${o.label_spoiler_hint || ''}" value="${o.hint_value || ''}" class="${o.composing_spoiler ? '' : 'hidden'} spoiler-hint"/>
-
-        <div class="suggestion-box">
-            <ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul>
-            <textarea
-                autofocus
-                type="text"
-                @drop=${o.onDrop}
-                @input=${o.inputChanged}
-                @keydown=${o.onKeyDown}
-                @keyup=${o.onKeyUp}
-                @paste=${o.onPaste}
-                @change=${o.onChange}
-                class="chat-textarea suggestion-box__input
-                    ${ o.show_send_button ? 'chat-textarea-send-button' : '' }
-                    ${ o.composing_spoile ? 'spoiler' : '' }"
-                placeholder="${o.label_message}">${ o.message_value || '' }</textarea>
-            <span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span>
-        </div>
-    </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

+ 214 - 0
src/plugins/chatview/tests/emojis.js

@@ -0,0 +1,214 @@
+/*global mock, converse */
+
+const { Promise, $msg } = converse.env;
+const u = converse.env.utils;
+const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
+
+describe("Emojis", function () {
+    describe("The emoji picker", function () {
+
+        beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000));
+        afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout));
+
+        it("can be opened by clicking a button in the chat toolbar",
+                mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
+
+            const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.waitForRoster(_converse, 'current');
+            await mock.openControlBox(_converse);
+            await mock.openChatBoxFor(_converse, contact_jid);
+            const view = _converse.chatboxviews.get(contact_jid);
+            const toolbar = await u.waitUntil(() => view.querySelector('converse-chat-toolbar'));
+            toolbar.querySelector('.toggle-emojis').click();
+            await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')), 1000);
+            const item = view.querySelector('.emoji-picker li.insert-emoji a');
+            item.click()
+            expect(view.querySelector('textarea.chat-textarea').value).toBe(':smiley: ');
+            toolbar.querySelector('.toggle-emojis').click(); // Close the panel again
+            done();
+        }));
+    });
+
+    describe("A Chat Message", function () {
+
+        it("will display larger if it's only emojis",
+                mock.initConverse(['chatBoxesFetched'], {'use_system_emojis': true}, async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current');
+            const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            _converse.handleMessageStanza($msg({
+                    'from': sender_jid,
+                    'to': _converse.connection.jid,
+                    'type': 'chat',
+                    'id': _converse.connection.getUniqueId()
+                }).c('body').t('😇').up()
+                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+            await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
+            const view = _converse.api.chatviews.get(sender_jid);
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+            await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.querySelector('.chat-msg__text')));
+
+            _converse.handleMessageStanza($msg({
+                    'from': sender_jid,
+                    'to': _converse.connection.jid,
+                    'type': 'chat',
+                    'id': _converse.connection.getUniqueId()
+                }).c('body').t('😇 Hello world! 😇 😇').up()
+                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+
+            let sel = '.message:last-child .chat-msg__text';
+            await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.querySelector(sel)));
+
+            // Test that a modified message that no longer contains only
+            // emojis now renders normally again.
+            const textarea = view.querySelector('textarea.chat-textarea');
+            textarea.value = ':poop: :innocent:';
+            const message_form = view.querySelector('converse-message-form');
+            message_form.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13 // Enter
+            });
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
+            const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
+            await u.waitUntil(() => view.querySelector(last_msg_sel).textContent === '💩 😇');
+
+            expect(textarea.value).toBe('');
+            message_form.onKeyDown({
+                target: textarea,
+                keyCode: 38 // Up arrow
+            });
+            expect(textarea.value).toBe('💩 😇');
+            expect(view.model.messages.at(2).get('correcting')).toBe(true);
+            sel = 'converse-chat-message:last-child .chat-msg'
+            await u.waitUntil(() => u.hasClass('correcting', view.querySelector(sel)), 500);
+            const edited_text = textarea.value += 'This is no longer an emoji-only message';
+            textarea.value = edited_text;
+            message_form.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13 // Enter
+            });
+            await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text'))
+                .filter(el => el.textContent === edited_text).length);
+            expect(view.model.messages.models.length).toBe(3);
+            let message = view.querySelector(last_msg_sel);
+            expect(u.hasClass('chat-msg__text--larger', message)).toBe(false);
+
+            textarea.value = ':smile: Hello world!';
+            message_form.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13 // Enter
+            });
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
+
+            textarea.value = ':smile: :smiley: :imp:';
+            message_form.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13 // Enter
+            });
+            await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 5);
+
+            message = view.querySelector('.message:last-child .chat-msg__text');
+            expect(u.hasClass('chat-msg__text--larger', message)).toBe(true);
+            done()
+        }));
+
+        it("can render emojis as images",
+                mock.initConverse(
+                    ['chatBoxesFetched'], {'use_system_emojis': false},
+                    async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current');
+            const contact_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            _converse.handleMessageStanza($msg({
+                    'from': contact_jid,
+                    'to': _converse.connection.jid,
+                    'type': 'chat',
+                    'id': _converse.connection.getUniqueId()
+                }).c('body').t('😇').up()
+                .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
+            await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve));
+            const view = _converse.api.chatviews.get(contact_jid);
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+            await u.waitUntil(() => view.querySelector('.chat-msg__text').innerHTML.replace(/<!-.*?->/g, '') ===
+                '<img class="emoji" draggable="false" title=":innocent:" alt="😇" src="https://twemoji.maxcdn.com/v/12.1.6//72x72/1f607.png">');
+
+            const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text';
+            let message = view.querySelector(last_msg_sel);
+            await u.waitUntil(() => u.isVisible(message.querySelector('.emoji')), 1000);
+            let imgs = message.querySelectorAll('.emoji');
+            expect(imgs.length).toBe(1);
+            expect(imgs[0].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f607.png');
+
+            const textarea = view.querySelector('textarea.chat-textarea');
+            textarea.value = ':poop: :innocent:';
+            const message_form = view.querySelector('converse-message-form');
+            message_form.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13 // Enter
+            });
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+            message = view.querySelector(last_msg_sel);
+            await u.waitUntil(() => u.isVisible(message.querySelector('.emoji')), 1000);
+            imgs = message.querySelectorAll('.emoji');
+            expect(imgs.length).toBe(2);
+            expect(imgs[0].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f4a9.png');
+            expect(imgs[1].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f607.png');
+
+            const sent_stanzas = _converse.connection.sent_stanzas;
+            const sent_stanza = sent_stanzas.filter(s => s.nodeName === 'message').pop();
+            expect(sent_stanza.querySelector('body').innerHTML).toBe('💩 😇');
+            done()
+                    }));
+
+        it("can show custom emojis",
+            mock.initConverse(
+                ['chatBoxesFetched'],
+                { emoji_categories: {
+                    "smileys": ":grinning:",
+                    "people": ":thumbsup:",
+                    "activity": ":soccer:",
+                    "travel": ":motorcycle:",
+                    "objects": ":bomb:",
+                    "nature": ":rainbow:",
+                    "food": ":hotdog:",
+                    "symbols": ":musical_note:",
+                    "flags": ":flag_ac:",
+                    "custom": ':xmpp:'
+                } },
+                async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current', 1);
+            const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit';
+            await mock.openChatBoxFor(_converse, contact_jid);
+            const view = _converse.api.chatviews.get(contact_jid);
+
+            const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar'));
+            toolbar.querySelector('.toggle-emojis').click();
+            await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')), 1000);
+            const picker = await u.waitUntil(() => view.querySelector('converse-emoji-picker'), 1000);
+            const custom_category = picker.querySelector('.pick-category[data-category="custom"]');
+            expect(custom_category.innerHTML.replace(/<!-.*?->/g, '').trim()).toBe(
+                '<img class="emoji" draggable="false" title=":xmpp:" alt=":xmpp:" src="/dist/images/custom_emojis/xmpp.png">');
+
+            const textarea = view.querySelector('textarea.chat-textarea');
+            textarea.value = 'Running tests for :converse:';
+            const message_form = view.querySelector('converse-message-form');
+            message_form.onKeyDown({
+                target: textarea,
+                preventDefault: function preventDefault () {},
+                keyCode: 13 // Enter
+            });
+            await new Promise(resolve => view.model.messages.once('rendered', resolve));
+            const body = view.querySelector('converse-chat-message-body');
+            await u.waitUntil(() => body.innerHTML.replace(/<!-.*?->/g, '').trim() ===
+                'Running tests for <img class="emoji" draggable="false" title=":converse:" alt=":converse:" src="/dist/images/custom_emojis/converse.png">');
+            done();
+        }));
+    });
+});

+ 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

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

@@ -188,7 +188,6 @@ describe("A Chat Message", function () {
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 7);
         await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 7);
 
 
-        view.clearSpinner(); //cleanup
         expect(view.querySelectorAll('.date-separator').length).toEqual(4);
         expect(view.querySelectorAll('.date-separator').length).toEqual(4);
 
 
         let day = sizzle('.date-separator:first', view).pop();
         let day = sizzle('.date-separator:first', view).pop();
@@ -704,7 +703,7 @@ describe("A Chat Message", function () {
         jasmine.clock().tick(1*ONE_MINUTE_LATER);
         jasmine.clock().tick(1*ONE_MINUTE_LATER);
         await mock.sendMessage(view, "Another message within 10 minutes, but from a different person");
         await mock.sendMessage(view, "Another message within 10 minutes, but from a different person");
 
 
-        expect(view.querySelectorAll('.message').length).toBe(6);
+        await u.waitUntil(() => view.querySelectorAll('.message').length === 6);
         expect(view.querySelectorAll('.chat-msg').length).toBe(5);
         expect(view.querySelectorAll('.chat-msg').length).toBe(5);
 
 
         const nth_child = (n) => `converse-chat-message:nth-child(${n}) .chat-msg`;
         const nth_child = (n) => `converse-chat-message:nth-child(${n}) .chat-msg`;
@@ -1214,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 = '';
+    }
+}

+ 8 - 9
src/plugins/chatview/view.js

@@ -22,17 +22,13 @@ export default class ChatView extends BaseChatView {
     async initialize () {
     async initialize () {
         const jid = this.getAttribute('jid');
         const jid = this.getAttribute('jid');
         _converse.chatboxviews.add(jid, this);
         _converse.chatboxviews.add(jid, this);
-
         this.model = _converse.chatboxes.get(jid);
         this.model = _converse.chatboxes.get(jid);
-        this.initDebounced();
-
         this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged);
         this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged);
         this.listenTo(this.model, 'change:hidden', () => !this.model.get('hidden') && this.afterShown());
         this.listenTo(this.model, 'change:hidden', () => !this.model.get('hidden') && this.afterShown());
         this.listenTo(this.model, 'change:status', this.onStatusMessageChanged);
         this.listenTo(this.model, 'change:status', this.onStatusMessageChanged);
         this.render();
         this.render();
 
 
         // Need to be registered after render has been called.
         // Need to be registered after render has been called.
-        this.listenTo(this.model.messages, 'add', this.onMessageAdded);
         this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages);
         this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages);
 
 
         await this.model.messages.fetched;
         await this.model.messages.fetched;
@@ -47,9 +43,7 @@ export default class ChatView extends BaseChatView {
     }
     }
 
 
     render () {
     render () {
-        const result = tpl_chat(Object.assign(
-            this.model.toJSON(), { 'markScrolled': ev => this.markScrolled(ev) })
-        );
+        const result = tpl_chat(this.model.toJSON());
         render(result, this);
         render(result, this);
         this.help_container = this.querySelector('.chat-content__help');
         this.help_container = this.querySelector('.chat-content__help');
         return this;
         return this;
@@ -133,15 +127,20 @@ export default class ChatView extends BaseChatView {
         }
         }
     }
     }
 
 
+    /**
+     * Closes this chat
+     * @private
+     * @method _converse.ChatBoxView#close
+     */
     close (ev) {
     close (ev) {
+        ev?.preventDefault?.();
         if (_converse.router.history.getFragment() === 'converse/chat?jid=' + this.model.get('jid')) {
         if (_converse.router.history.getFragment() === 'converse/chat?jid=' + this.model.get('jid')) {
             _converse.router.navigate('');
             _converse.router.navigate('');
         }
         }
-        return super.close(ev);
+        return this.model.close(ev);
     }
     }
 
 
     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">

+ 1 - 2
src/plugins/headlines-view/templates/headlines.js

@@ -9,8 +9,7 @@ export default (o) => html`
             <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
             <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
                 <converse-chat-content
                 <converse-chat-content
                     class="chat-content__messages"
                     class="chat-content__messages"
-                    jid="${o.jid}"
-                    @scroll=${o.markScrolled}></converse-chat-content>
+                    jid="${o.jid}"></converse-chat-content>
 
 
                 <div class="chat-content__help"></div>
                 <div class="chat-content__help"></div>
             </div>
             </div>

+ 0 - 2
src/plugins/headlines-view/view.js

@@ -11,8 +11,6 @@ class HeadlinesView extends BaseChatView {
         _converse.chatboxviews.add(jid, this);
         _converse.chatboxviews.add(jid, this);
 
 
         this.model = _converse.chatboxes.get(jid);
         this.model = _converse.chatboxes.get(jid);
-        this.initDebounced();
-
         this.model.disable_mam = true; // Don't do MAM queries for this box
         this.model.disable_mam = true; // Don't do MAM queries for this box
         this.listenTo(this.model, 'change:hidden', () => this.afterShown());
         this.listenTo(this.model, 'change:hidden', () => this.afterShown());
         this.listenTo(this.model, 'destroy', this.remove);
         this.listenTo(this.model, 'destroy', this.remove);

+ 3 - 2
src/plugins/mam-views/utils.js

@@ -8,16 +8,17 @@ export async function fetchMessagesOnScrollUp (view) {
         if (oldest_message) {
         if (oldest_message) {
             const by_jid = is_groupchat ? view.model.get('jid') : _converse.bare_jid;
             const by_jid = is_groupchat ? view.model.get('jid') : _converse.bare_jid;
             const stanza_id = oldest_message && oldest_message.get(`stanza_id ${by_jid}`);
             const stanza_id = oldest_message && oldest_message.get(`stanza_id ${by_jid}`);
-            view.addSpinner();
+            view.model.ui.set('chat-content-spinner-top', true);
             if (stanza_id) {
             if (stanza_id) {
                 await fetchArchivedMessages(view.model, { 'before': stanza_id });
                 await fetchArchivedMessages(view.model, { 'before': stanza_id });
             } else {
             } else {
                 await fetchArchivedMessages(view.model, { 'end': oldest_message.get('time') });
                 await fetchArchivedMessages(view.model, { 'end': oldest_message.get('time') });
             }
             }
-            view.clearSpinner();
             if (api.settings.get('allow_url_history_change')) {
             if (api.settings.get('allow_url_history_change')) {
                 _converse.router.history.navigate(`#${oldest_message.get('msgid')}`);
                 _converse.router.history.navigate(`#${oldest_message.get('msgid')}`);
             }
             }
+
+            setTimeout(() => view.model.ui.set('chat-content-spinner-top', false), 250);
         }
         }
     }
     }
 }
 }

+ 0 - 10
src/plugins/minimize/utils.js

@@ -151,16 +151,6 @@ export function minimize (ev, model) {
     } else {
     } else {
         model = ev;
         model = ev;
     }
     }
-    // save the scroll position to restore it on maximize
-    const view = _converse.chatboxviews.get(model.get('jid'));
-    const scroll = view.querySelector('.chat-content__messages')?.scrollTop;
-    if (scroll) {
-        if (model.collection && model.collection.browserStorage) {
-            model.save({ scroll });
-        } else {
-            model.set({ scroll });
-        }
-    }
     model.setChatState(_converse.INACTIVE);
     model.setChatState(_converse.INACTIVE);
     u.safeSave(model, {
     u.safeSave(model, {
         'hidden': true,
         'hidden': true,

+ 11 - 47
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,10 @@ 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 });
-        _converse.chatboxviews.get(this.getAttribute('jid'))?.scrollDown();
-    }
-
-    onKeyDown (ev) {
-        if (this.mention_auto_complete.onKeyDown(ev)) {
-            return;
-        }
-        super.onKeyDown(ev);
-    }
-
-    onKeyUp (ev) {
-        this.mention_auto_complete.evaluate(ev);
-        super.onKeyUp(ev);
-    }
-
-    parseMessageForCommands (text) {
-        return parseMessageForMUCCommands(this.model, text);
     }
     }
 }
 }
 
 

+ 7 - 30
src/plugins/muc-views/chatarea.js

@@ -3,6 +3,8 @@ import tpl_muc_chatarea from './templates/muc-chatarea.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 { onScrolledDown } from 'shared/chat/utils.js';
+import { safeSave } from '@converse/headless/utils/core.js';
 
 
 
 
 const { u } = converse.env;
 const { u } = converse.env;
@@ -93,17 +95,13 @@ export default class MUCChatArea extends CustomElement {
      * which debounces this method by 100ms.
      * which debounces this method by 100ms.
      * @private
      * @private
      */
      */
-    _markScrolled (ev) {
+    _markScrolled () {
         let scrolled = true;
         let scrolled = true;
-        let scrollTop = null;
-        const msgs_container = this.querySelector('.chat-content__messages');
-        const is_at_bottom =
-            msgs_container.scrollTop + msgs_container.clientHeight >= msgs_container.scrollHeight - 62; // sigh...
-
+        const is_at_bottom = this.scrollTop + this.clientHeight >= this.scrollHeight;
         if (is_at_bottom) {
         if (is_at_bottom) {
             scrolled = false;
             scrolled = false;
-            this.onScrolledDown();
-        } else if (msgs_container.scrollTop === 0) {
+            onScrolledDown(this.model);
+        } else if (this.scrollTop === 0) {
             /**
             /**
              * Triggered once the chat's message area has been scrolled to the top
              * Triggered once the chat's message area has been scrolled to the top
              * @event _converse#chatBoxScrolledUp
              * @event _converse#chatBoxScrolledUp
@@ -111,29 +109,8 @@ export default class MUCChatArea extends CustomElement {
              * @example _converse.api.listen.on('chatBoxScrolledUp', obj => { ... });
              * @example _converse.api.listen.on('chatBoxScrolledUp', obj => { ... });
              */
              */
             api.trigger('chatBoxScrolledUp', this);
             api.trigger('chatBoxScrolledUp', this);
-        } else {
-            scrollTop = ev.target.scrollTop;
-        }
-        u.safeSave(this.model, { scrolled, scrollTop });
-    }
-
-    onScrolledDown () {
-        if (!this.model.isHidden()) {
-            this.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 && this.model.messages.get(hash.slice(1)) && _converse.router.history.navigate();
-            }
         }
         }
-        /**
-         * Triggered once the chat's message area has been scrolled down to the bottom.
-         * @event _converse#chatBoxScrolledDown
-         * @type {object}
-         * @property { _converse.ChatBox | _converse.ChatRoom } chatbox - The chat model
-         * @example _converse.api.listen.on('chatBoxScrolledDown', obj => { ... });
-         */
-        api.trigger('chatBoxScrolledDown', { 'chatbox': this.model });
+        safeSave(this.model, { scrolled });
     }
     }
 
 
     onMousedown (ev) {
     onMousedown (ev) {

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

+ 2 - 5
src/plugins/muc-views/muc.js

@@ -14,8 +14,6 @@ export default class MUCView extends BaseChatView {
         const jid = this.getAttribute('jid');
         const jid = this.getAttribute('jid');
         this.model = await api.rooms.get(jid);
         this.model = await api.rooms.get(jid);
         _converse.chatboxviews.add(jid, this);
         _converse.chatboxviews.add(jid, this);
-        this.initDebounced();
-
         this.setAttribute('id', this.model.get('box_id'));
         this.setAttribute('id', this.model.get('box_id'));
 
 
         this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged);
         this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged);
@@ -29,7 +27,6 @@ export default class MUCView extends BaseChatView {
         await this.render();
         await this.render();
 
 
         // Need to be registered after render has been called.
         // Need to be registered after render has been called.
-        this.listenTo(this.model.messages, 'add', this.onMessageAdded);
         this.listenTo(this.model.occupants, 'change:show', this.showJoinOrLeaveNotification);
         this.listenTo(this.model.occupants, 'change:show', this.showJoinOrLeaveNotification);
 
 
         this.updateAfterTransition();
         this.updateAfterTransition();
@@ -56,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();
         }
         }
     }
     }
@@ -67,10 +63,11 @@ export default class MUCView extends BaseChatView {
      * @method _converse.ChatRoomView#close
      * @method _converse.ChatRoomView#close
      */
      */
     close (ev) {
     close (ev) {
+        ev?.preventDefault?.();
         if (_converse.router.history.getFragment() === 'converse/room?jid=' + this.model.get('jid')) {
         if (_converse.router.history.getFragment() === 'converse/room?jid=' + this.model.get('jid')) {
             _converse.router.navigate('');
             _converse.router.navigate('');
         }
         }
-        return super.close(ev);
+        return this.model.close(ev);
     }
     }
 
 
     async destroy () {
     async destroy () {

+ 0 - 2
src/plugins/muc-views/sidebar.js

@@ -39,8 +39,6 @@ export default class MUCSidebar extends CustomElement {
         ev?.preventDefault?.();
         ev?.preventDefault?.();
         ev?.stopPropagation?.();
         ev?.stopPropagation?.();
         u.safeSave(this.model, { 'hidden_occupants': true });
         u.safeSave(this.model, { 'hidden_occupants': true });
-        // FIXME: do this declaratively
-        _converse.chatboxviews.get(this.jid)?.scrollDown();
     }
     }
 
 
     onOccupantClicked (ev) {
     onOccupantClicked (ev) {

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

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

@@ -0,0 +1,38 @@
+import { __ } from 'i18n';
+import { api } from "@converse/headless/core";
+import { html } from "lit";
+import { resetElementHeight } from 'plugins/chatview/utils.js';
+
+
+export default (o) => {
+    const unread_msgs = __('You have unread messages');
+    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`
+        ${ (o.scrolled && o.num_unread) ? html`<div class="new-msgs-indicator" @click=${ev => o.viewUnreadMessages(ev)}>▼ ${ unread_msgs } ▼</div>` : '' }
+        <form class="setNicknameButtonForm hidden">
+            <input type="submit" class="btn btn-primary" name="join" value="Join"/>
+        </form>
+        <form class="sendXMPPMessage">
+            <input type="text" placeholder="${label_spoiler_hint || ''}" value="${o.hint_value || ''}" class="${o.composing_spoiler ? '' : 'hidden'} spoiler-hint"/>
+            <div class="suggestion-box">
+                <ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul>
+                <textarea
+                    autofocus
+                    type="text"
+                    @drop=${o.onDrop}
+                    @input=${resetElementHeight}
+                    @keydown=${o.onKeyDown}
+                    @keyup=${o.onKeyUp}
+                    @paste=${o.onPaste}
+                    @change=${o.onChange}
+                    class="chat-textarea suggestion-box__input
+                        ${ show_send_button ? 'chat-textarea-send-button' : '' }
+                        ${ o.composing_spoiler ? 'spoiler' : '' }"
+                    placeholder="${label_message}">${ o.message_value || '' }</textarea>
+                <span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span>
+            </div>
+        </form>`;
+}

+ 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>`;

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

@@ -10,8 +10,7 @@ export default (o) => html`
         <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
         <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
             <converse-chat-content
             <converse-chat-content
                 class="chat-content__messages"
                 class="chat-content__messages"
-                jid="${o.jid}"
-                @scroll=${o.markScrolled}></converse-chat-content>
+                jid="${o.jid}"></converse-chat-content>
 
 
             ${o.show_help_messages ? html`<div class="chat-content__help">
             ${o.show_help_messages ? html`<div class="chat-content__help">
                     <converse-chat-help
                     <converse-chat-help

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

+ 228 - 0
src/plugins/muc-views/tests/emojis.js

@@ -0,0 +1,228 @@
+/*global mock, converse */
+
+const { $pres, sizzle } = converse.env;
+const u = converse.env.utils;
+
+describe("Emojis", function () {
+    describe("The emoji picker", function () {
+        it("is opened to autocomplete emojis in the textarea",
+                mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
+
+            await mock.waitForRoster(_converse, 'current', 0);
+            const muc_jid = 'lounge@montague.lit';
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.chatboxviews.get(muc_jid);
+            await u.waitUntil(() => view.querySelector('converse-emoji-dropdown'));
+            const textarea = view.querySelector('textarea.chat-textarea');
+            textarea.value = ':gri';
+
+            // Press tab
+            const tab_event = {
+                'target': textarea,
+                'preventDefault': function preventDefault () {},
+                'stopPropagation': function stopPropagation () {},
+                'keyCode': 9,
+                'key': 'Tab'
+            }
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown(tab_event);
+            await u.waitUntil(() => view.querySelector('converse-emoji-picker .emoji-search')?.value === ':gri');
+            await u.waitUntil(() =>  sizzle('.emojis-lists__container--search .insert-emoji', view).length === 3, 1000);
+            let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', view);
+            expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:');
+            expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':grin:');
+            expect(visible_emojis[2].getAttribute('data-emoji')).toBe(':grinning:');
+
+            const picker = view.querySelector('converse-emoji-picker');
+            const input = picker.querySelector('.emoji-search');
+            // Test that TAB autocompletes the to first match
+            input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
+
+            await u.waitUntil(() => sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker).length === 1, 1000);
+            visible_emojis = sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker);
+            expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:');
+            expect(input.value).toBe(':grimacing:');
+
+            // Check that ENTER now inserts the match
+            const enter_event = Object.assign({}, tab_event, {'keyCode': 13, 'key': 'Enter', 'target': input});
+            input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
+
+            await u.waitUntil(() => input.value === '');
+            await u.waitUntil(() => textarea.value === ':grimacing: ');
+
+            // Test that username starting with : doesn't cause issues
+            const presence = $pres({
+                    'from': `${muc_jid}/:username`,
+                    'id': '27C55F89-1C6A-459A-9EB5-77690145D624',
+                    'to': _converse.jid
+                })
+                .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'})
+                    .c('item', {
+                        'jid': 'some1@montague.lit',
+                        'affiliation': 'member',
+                        'role': 'participant'
+                    });
+            _converse.connection._dataRecv(mock.createRequest(presence));
+
+            textarea.value = ':use';
+            message_form.onKeyDown(tab_event);
+            await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
+            await u.waitUntil(() => input.value === ':use');
+            visible_emojis = sizzle('.insert-emoji:not(.hidden)', picker);
+            expect(visible_emojis.length).toBe(0);
+            done();
+        }));
+
+        it("is focused to autocomplete emojis in the textarea",
+                mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.chatboxviews.get(muc_jid);
+            await u.waitUntil(() => view.querySelector('converse-emoji-dropdown'));
+            const textarea = view.querySelector('textarea.chat-textarea');
+            textarea.value = ':';
+            // Press tab
+            const tab_event = {
+                'target': textarea,
+                'preventDefault': function preventDefault () {},
+                'stopPropagation': function stopPropagation () {},
+                'keyCode': 9,
+                'key': 'Tab'
+            }
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown(tab_event);
+            await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
+
+            const picker = view.querySelector('converse-emoji-picker');
+            const input = picker.querySelector('.emoji-search');
+            expect(input.value).toBe(':');
+            input.value = ':gri';
+            const event = {
+                'target': input,
+                'preventDefault': function preventDefault () {},
+                'stopPropagation': function stopPropagation () {}
+            };
+            input.dispatchEvent(new KeyboardEvent('keydown', event));
+            await u.waitUntil(() =>  sizzle('.emojis-lists__container--search .insert-emoji', view).length === 3, 1000);
+            let emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop();
+            emoji.click();
+            await u.waitUntil(() => textarea.value === ':grinning: ');
+            textarea.value = ':grinning: :';
+            message_form.onKeyDown(tab_event);
+
+            await u.waitUntil(() => input.value === ':');
+            input.value = ':grimacing';
+            input.dispatchEvent(new KeyboardEvent('keydown', event));
+            await u.waitUntil(() =>  sizzle('.emojis-lists__container--search .insert-emoji', view).length === 1, 1000);
+            emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop();
+            emoji.click();
+            await u.waitUntil(() => textarea.value === ':grinning: :grimacing: ');
+            done();
+        }));
+
+
+        it("properly inserts emojis into the chat textarea",
+                mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.chatboxviews.get(muc_jid);
+            await u.waitUntil(() => view.querySelector('converse-emoji-dropdown'));
+            const textarea = view.querySelector('textarea.chat-textarea');
+            textarea.value = ':gri';
+
+            // Press tab
+            const tab_event = {
+                'target': textarea,
+                'preventDefault': function preventDefault () {},
+                'stopPropagation': function stopPropagation () {},
+                'keyCode': 9,
+                'key': 'Tab'
+            }
+            textarea.value = ':';
+            const message_form = view.querySelector('converse-muc-message-form');
+            message_form.onKeyDown(tab_event);
+            await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
+            const picker = view.querySelector('converse-emoji-picker');
+            const input = picker.querySelector('.emoji-search');
+            input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
+            await u.waitUntil(() => input.value === ':100:');
+            const enter_event = Object.assign({}, tab_event, {'keyCode': 13, 'key': 'Enter', 'target': input});
+            input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
+            expect(textarea.value).toBe(':100: ');
+
+            textarea.value = ':';
+            message_form.onKeyDown(tab_event);
+            await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
+            await u.waitUntil(() => input.value === ':');
+            input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
+            await u.waitUntil(() => input.value === ':100:');
+            await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 1, 1000);
+            const emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop();
+            emoji.click();
+            expect(textarea.value).toBe(':100: ');
+            done();
+        }));
+
+
+        it("allows you to search for particular emojis",
+                mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {
+
+            const muc_jid = 'lounge@montague.lit';
+            await mock.waitForRoster(_converse, 'current', 0);
+            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            const view = _converse.chatboxviews.get(muc_jid);
+            await u.waitUntil(() => view.querySelector('converse-emoji-dropdown'));
+            const toolbar = view.querySelector('converse-chat-toolbar');
+            toolbar.querySelector('.toggle-emojis').click();
+            await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')));
+            await u.waitUntil(() => sizzle('converse-chat-toolbar .insert-emoji:not(.hidden)', view).length === 1589);
+
+            const input = view.querySelector('.emoji-search');
+            input.value = 'smiley';
+            const event = {
+                'target': input,
+                'preventDefault': function preventDefault () {},
+                'stopPropagation': function stopPropagation () {}
+            };
+            input.dispatchEvent(new KeyboardEvent('keydown', event));
+
+            await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 2, 1000);
+            let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view);
+            expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
+            expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':smiley_cat:');
+
+            // Check that pressing enter without an unambiguous match does nothing
+            const enter_event = Object.assign({}, event, {'keyCode': 13});
+            input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
+            expect(input.value).toBe('smiley');
+
+            // Check that search results update when chars are deleted
+            input.value = 'sm';
+            input.dispatchEvent(new KeyboardEvent('keydown', event));
+            await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 25, 1000);
+
+            input.value = 'smiley';
+            input.dispatchEvent(new KeyboardEvent('keydown', event));
+            await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 2, 1000);
+
+            // Test that TAB autocompletes the to first match
+            const tab_event = Object.assign({}, event, {'keyCode': 9, 'key': 'Tab'});
+            input.dispatchEvent(new KeyboardEvent('keydown', tab_event));
+
+            await u.waitUntil(() => input.value === ':smiley:');
+            await u.waitUntil(() => sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", view).length === 1, 1000);
+            visible_emojis = sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", view);
+            expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:');
+
+            // Check that ENTER now inserts the match
+            input.dispatchEvent(new KeyboardEvent('keydown', enter_event));
+            await u.waitUntil(() => input.value === '');
+            expect(view.querySelector('textarea.chat-textarea').value).toBe(':smiley: ');
+            done();
+        }));
+    });
+});

+ 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

+ 8 - 64
src/plugins/muc-views/tests/muc-registration.js

@@ -1,9 +1,6 @@
-/*global mock, converse, _ */
+/*global mock, converse */
 
 
-const $iq = converse.env.$iq,
-      Strophe = converse.env.Strophe,
-      sizzle = converse.env.sizzle,
-      u = converse.env.utils;
+const { $iq, Strophe, sizzle, u } = converse.env;
 
 
 describe("Chatrooms", function () {
 describe("Chatrooms", function () {
 
 
@@ -18,18 +15,17 @@ 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
             });
             });
-            let stanza = await u.waitUntil(() => _.filter(
-                _converse.connection.IQ_stanzas,
+            let stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
                 iq => sizzle(`iq[to="${muc_jid}"][type="get"] query[xmlns="jabber:iq:register"]`, iq).length
                 iq => sizzle(`iq[to="${muc_jid}"][type="get"] query[xmlns="jabber:iq:register"]`, iq).length
             ).pop());
             ).pop());
             expect(Strophe.serialize(stanza))
             expect(Strophe.serialize(stanza))
-                .toBe(`<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" `+
+                .toBe(`<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" `+
                             `type="get" xmlns="jabber:client">`+
                             `type="get" xmlns="jabber:client">`+
                         `<query xmlns="jabber:iq:register"/></iq>`);
                         `<query xmlns="jabber:iq:register"/></iq>`);
             const result = $iq({
             const result = $iq({
@@ -45,64 +41,12 @@ describe("Chatrooms", function () {
                         'var': 'muc#register_roomnick'
                         'var': 'muc#register_roomnick'
                     }).c('required');
                     }).c('required');
             _converse.connection._dataRecv(mock.createRequest(result));
             _converse.connection._dataRecv(mock.createRequest(result));
-            stanza = await u.waitUntil(() => _.filter(
-                _converse.connection.IQ_stanzas,
+            stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter(
                 iq => sizzle(`iq[to="${muc_jid}"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
                 iq => sizzle(`iq[to="${muc_jid}"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
             ).pop());
             ).pop());
 
 
             expect(Strophe.serialize(stanza)).toBe(
             expect(Strophe.serialize(stanza)).toBe(
-                `<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
-                    `<query xmlns="jabber:iq:register">`+
-                        `<x type="submit" xmlns="jabber:x:data">`+
-                            `<field var="FORM_TYPE"><value>http://jabber.org/protocol/muc#register</value></field>`+
-                            `<field var="muc#register_roomnick"><value>romeo</value></field>`+
-                        `</x>`+
-                    `</query>`+
-                `</iq>`);
-            done();
-        }));
-
-    });
-
-    describe("The auto_register_muc_nickname option", function () {
-
-        it("allows you to automatically register your nickname when joining a room",
-                mock.initConverse(['chatBoxesFetched'], {'auto_register_muc_nickname': true},
-                async function (done, _converse) {
-
-            const muc_jid = 'coven@chat.shakespeare.lit';
-            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.chatboxviews.get(muc_jid);
-
-            let stanza = await u.waitUntil(() => _.filter(
-                _converse.connection.IQ_stanzas,
-                iq => sizzle(`iq[to="coven@chat.shakespeare.lit"][type="get"] query[xmlns="jabber:iq:register"]`, iq).length
-            ).pop());
-
-            expect(Strophe.serialize(stanza))
-            .toBe(`<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" `+
-                        `type="get" xmlns="jabber:client">`+
-                    `<query xmlns="jabber:iq:register"/></iq>`);
-            const result = $iq({
-                'from': view.model.get('jid'),
-                'id': stanza.getAttribute('id'),
-                'to': _converse.bare_jid,
-                'type': 'result',
-            }).c('query', {'type': 'jabber:iq:register'})
-                .c('x', {'xmlns': 'jabber:x:data', 'type': 'form'})
-                    .c('field', {
-                        'label': 'Desired Nickname',
-                        'type': 'text-single',
-                        'var': 'muc#register_roomnick'
-                    }).c('required');
-            _converse.connection._dataRecv(mock.createRequest(result));
-            stanza = await u.waitUntil(() => _.filter(
-                _converse.connection.IQ_stanzas,
-                iq => sizzle(`iq[to="coven@chat.shakespeare.lit"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length
-            ).pop());
-
-            expect(Strophe.serialize(stanza)).toBe(
-                `<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
+                `<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+
                     `<query xmlns="jabber:iq:register">`+
                     `<query xmlns="jabber:iq:register">`+
                         `<x type="submit" xmlns="jabber:x:data">`+
                         `<x type="submit" xmlns="jabber:x:data">`+
                             `<field var="FORM_TYPE"><value>http://jabber.org/protocol/muc#register</value></field>`+
                             `<field var="FORM_TYPE"><value>http://jabber.org/protocol/muc#register</value></field>`+

+ 48 - 50
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
@@ -1934,7 +1933,6 @@ describe("Groupchats", function () {
             const message = 'This message is received while the chat area is scrolled up';
             const message = 'This message is received while the chat area is scrolled up';
             await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
             await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
             const view = _converse.chatboxviews.get('lounge@montague.lit');
             const view = _converse.chatboxviews.get('lounge@montague.lit');
-            spyOn(view, 'scrollDown').and.callThrough();
             // Create enough messages so that there's a scrollbar.
             // Create enough messages so that there's a scrollbar.
             const promises = [];
             const promises = [];
             for (let i=0; i<20; i++) {
             for (let i=0; i<20; i++) {
@@ -2754,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');
@@ -2789,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);
@@ -2804,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);
@@ -2819,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);
@@ -2834,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);
@@ -2852,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);
@@ -2911,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
@@ -2924,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
@@ -3031,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
@@ -3042,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
@@ -3056,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
@@ -3069,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
@@ -3086,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
@@ -3124,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
@@ -3143,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");
@@ -3155,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
@@ -3214,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
@@ -3233,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
@@ -3278,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();
@@ -3314,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
@@ -3329,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();
@@ -3416,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
@@ -3433,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();
@@ -3477,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();
@@ -3556,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
@@ -3572,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();
@@ -3613,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();
@@ -3661,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));
 
 
@@ -3712,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));
 
 
@@ -4989,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(`
@@ -5007,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

+ 0 - 0
spec/protocol.js → src/plugins/rosterview/tests/protocol.js


+ 8 - 138
src/shared/chat/baseview.js

@@ -1,31 +1,18 @@
-import debounce from 'lodash-es/debounce';
 import log from '@converse/headless/log';
 import log from '@converse/headless/log';
-import tpl_spinner from 'templates/spinner.js';
 import { ElementView } from '@converse/skeletor/src/element.js';
 import { ElementView } from '@converse/skeletor/src/element.js';
 import { _converse, api, converse } from '@converse/headless/core';
 import { _converse, api, converse } from '@converse/headless/core';
+import { onScrolledDown } from './utils.js';
 
 
 const u = converse.env.utils;
 const u = converse.env.utils;
 
 
 export default class BaseChatView extends ElementView {
 export default class BaseChatView extends ElementView {
 
 
-    initDebounced () {
-        this.markScrolled = debounce(this._markScrolled, 100);
-        this.debouncedScrollDown = debounce(this.scrollDown, 100);
-    }
-
     disconnectedCallback () {
     disconnectedCallback () {
         super.disconnectedCallback();
         super.disconnectedCallback();
         const jid = this.getAttribute('jid');
         const jid = this.getAttribute('jid');
         _converse.chatboxviews.remove(jid, this);
         _converse.chatboxviews.remove(jid, this);
     }
     }
 
 
-    hideNewMessagesIndicator () {
-        const new_msgs_indicator = this.querySelector('.new-msgs-indicator');
-        if (new_msgs_indicator !== null) {
-            new_msgs_indicator.classList.add('hidden');
-        }
-    }
-
     maybeFocus () {
     maybeFocus () {
         api.settings.get('auto_focus') && this.focus();
         api.settings.get('auto_focus') && this.focus();
     }
     }
@@ -50,24 +37,6 @@ export default class BaseChatView extends ElementView {
         this.afterShown();
         this.afterShown();
     }
     }
 
 
-    async close (ev) {
-        ev?.preventDefault?.();
-        if (api.connection.connected()) {
-            // Immediately sending the chat state, because the
-            // model is going to be destroyed afterwards.
-            this.model.setChatState(_converse.INACTIVE);
-            this.model.sendChatState();
-        }
-        await this.model.close(ev);
-        /**
-         * Triggered once a chatbox has been closed.
-         * @event _converse#chatBoxClosed
-         * @type { _converse.ChatBoxView | _converse.ChatRoomView }
-         * @example _converse.api.listen.on('chatBoxClosed', view => { ... });
-         */
-        api.trigger('chatBoxClosed', this);
-    }
-
     emitBlurred (ev) {
     emitBlurred (ev) {
         if (this.contains(document.activeElement) || this.contains(ev.relatedTarget)) {
         if (this.contains(document.activeElement) || this.contains(ev.relatedTarget)) {
             // Something else in this chatbox is still focused
             // Something else in this chatbox is still focused
@@ -96,37 +65,6 @@ export default class BaseChatView extends ElementView {
         api.trigger('chatBoxFocused', this, ev);
         api.trigger('chatBoxFocused', this, ev);
     }
     }
 
 
-    /**
-     * Scroll to the previously saved scrollTop position, or scroll
-     * down if it wasn't set.
-     */
-    maintainScrollTop () {
-        const pos = this.model.get('scrollTop');
-        if (pos) {
-            const msgs_container = this.querySelector('.chat-content__messages');
-            msgs_container.scrollTop = pos;
-        } else {
-            this.scrollDown();
-        }
-    }
-
-    addSpinner (append = false) {
-        const content = this.querySelector('.chat-content');
-        if (this.querySelector('.spinner') === null) {
-            const el = u.getElementFromTemplateResult(tpl_spinner());
-            if (append) {
-                content.insertAdjacentElement('beforeend', el);
-                this.scrollDown();
-            } else {
-                content.insertAdjacentElement('afterbegin', el);
-            }
-        }
-    }
-
-    clearSpinner () {
-        this.querySelectorAll('.chat-content .spinner').forEach(u.removeElement);
-    }
-
     onStatusMessageChanged (item) {
     onStatusMessageChanged (item) {
         this.renderHeading();
         this.renderHeading();
         /**
         /**
@@ -143,24 +81,6 @@ export default class BaseChatView extends ElementView {
         });
         });
     }
     }
 
 
-    showNewMessagesIndicator () {
-        u.showElement(this.querySelector('.new-msgs-indicator'));
-    }
-
-    onMessageAdded (message) {
-        if (u.isNewMessage(message)) {
-            if (message.get('sender') === 'me') {
-                // We remove the "scrolled" flag so that the chat area
-                // gets scrolled down. We always want to scroll down
-                // when the user writes a message as opposed to when a
-                // message is received.
-                this.model.set('scrolled', false);
-            } else if (this.model.get('scrolled', true)) {
-                this.showNewMessagesIndicator();
-            }
-        }
-    }
-
     getBottomPanel () {
     getBottomPanel () {
         if (this.model.get('type') === _converse.CHATROOMS_TYPE) {
         if (this.model.get('type') === _converse.CHATROOMS_TYPE) {
             return this.querySelector('converse-muc-bottom-panel');
             return this.querySelector('converse-muc-bottom-panel');
@@ -169,38 +89,12 @@ export default class BaseChatView extends ElementView {
         }
         }
     }
     }
 
 
-    /**
-     * Called when the chat content is scrolled up or down.
-     * We want to record when the user has scrolled away from
-     * the bottom, so that we don't automatically scroll away
-     * from what the user is reading when new messages are received.
-     *
-     * Don't call this method directly, instead, call `markScrolled`,
-     * which debounces this method by 100ms.
-     * @private
-     */
-    _markScrolled (ev) {
-        let scrolled = true;
-        let scrollTop = null;
-        const msgs_container = this.querySelector('.chat-content__messages');
-        const is_at_bottom =
-            msgs_container.scrollTop + msgs_container.clientHeight >= msgs_container.scrollHeight - 62; // sigh...
-
-        if (is_at_bottom) {
-            scrolled = false;
-            this.onScrolledDown();
-        } else if (msgs_container.scrollTop === 0) {
-            /**
-             * Triggered once the chat's message area has been scrolled to the top
-             * @event _converse#chatBoxScrolledUp
-             * @property { _converse.ChatBoxView | _converse.ChatRoomView } view
-             * @example _converse.api.listen.on('chatBoxScrolledUp', obj => { ... });
-             */
-            api.trigger('chatBoxScrolledUp', this);
+    getMessageForm () {
+        if (this.model.get('type') === _converse.CHATROOMS_TYPE) {
+            return this.querySelector('converse-muc-message-form');
         } else {
         } else {
-            scrollTop = ev.target.scrollTop;
+            return this.querySelector('converse-message-form');
         }
         }
-        u.safeSave(this.model, { scrolled, scrollTop });
     }
     }
 
 
     /**
     /**
@@ -214,38 +108,14 @@ export default class BaseChatView extends ElementView {
         ev?.preventDefault?.();
         ev?.preventDefault?.();
         ev?.stopPropagation?.();
         ev?.stopPropagation?.();
         if (this.model.get('scrolled')) {
         if (this.model.get('scrolled')) {
-            u.safeSave(this.model, {
-                'scrolled': false,
-                'scrollTop': null
-            });
-        }
-        this.querySelector('.chat-content__messages')?.scrollDown();
-        this.onScrolledDown();
-    }
-
-    onScrolledDown () {
-        this.hideNewMessagesIndicator();
-        if (!this.model.isHidden()) {
-            this.model.clearUnreadMsgCounter();
-            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 && this.model.messages.get(hash.slice(1)) && _converse.router.history.navigate();
-            }
+            u.safeSave(this.model, { 'scrolled': false });
         }
         }
-        /**
-         * Triggered once the chat's message area has been scrolled down to the bottom.
-         * @event _converse#chatBoxScrolledDown
-         * @type {object}
-         * @property { _converse.ChatBox | _converse.ChatRoom } chatbox - The chat model
-         * @example _converse.api.listen.on('chatBoxScrolledDown', obj => { ... });
-         */
-        api.trigger('chatBoxScrolledDown', { 'chatbox': this.model }); // TODO: clean up
+        onScrolledDown(this.model);
     }
     }
 
 
     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') {

+ 90 - 7
src/shared/chat/chat-content.js

@@ -1,8 +1,11 @@
-import "./message-history";
-import debounce from 'lodash-es/debounce';
+import './message-history';
+import debounce from 'lodash/debounce';
 import { CustomElement } from 'shared/components/element.js';
 import { CustomElement } from 'shared/components/element.js';
-import { _converse, api } from "@converse/headless/core";
+import { _converse, api } from '@converse/headless/core';
 import { html } from 'lit';
 import { html } from 'lit';
+import { onScrolledDown } from './utils.js';
+import { safeSave } from '@converse/headless/utils/core.js';
+
 
 
 export default class ChatContent extends CustomElement {
 export default class ChatContent extends CustomElement {
 
 
@@ -14,31 +17,41 @@ export default class ChatContent extends CustomElement {
 
 
     connectedCallback () {
     connectedCallback () {
         super.connectedCallback();
         super.connectedCallback();
-        this.debouncedScrolldown = debounce(this.scrollDown, 100);
+        this.debouncedMaintainScroll = debounce(this.maintainScrollPosition, 100);
+        this.markScrolled = debounce(this._markScrolled, 100);
+
         this.model = _converse.chatboxes.get(this.jid);
         this.model = _converse.chatboxes.get(this.jid);
+        this.listenTo(this.model, 'change:hidden_occupants', this.requestUpdate);
+        this.listenTo(this.model, 'change:scrolled', this.requestUpdate);
         this.listenTo(this.model.messages, 'add', this.requestUpdate);
         this.listenTo(this.model.messages, 'add', this.requestUpdate);
         this.listenTo(this.model.messages, 'change', this.requestUpdate);
         this.listenTo(this.model.messages, 'change', this.requestUpdate);
         this.listenTo(this.model.messages, 'remove', this.requestUpdate);
         this.listenTo(this.model.messages, 'remove', this.requestUpdate);
+        this.listenTo(this.model.messages, 'rendered', this.requestUpdate);
         this.listenTo(this.model.messages, 'reset', this.requestUpdate);
         this.listenTo(this.model.messages, 'reset', this.requestUpdate);
         this.listenTo(this.model.notifications, 'change', this.requestUpdate);
         this.listenTo(this.model.notifications, 'change', this.requestUpdate);
+        this.listenTo(this.model.ui, 'change', this.requestUpdate);
+
         if (this.model.occupants) {
         if (this.model.occupants) {
             this.listenTo(this.model.occupants, 'change', this.requestUpdate);
             this.listenTo(this.model.occupants, 'change', this.requestUpdate);
         }
         }
-
         // We jot down whether we were scrolled down before rendering, because when an
         // We jot down whether we were scrolled down before rendering, because when an
         // image loads, it triggers 'scroll' and the chat will be marked as scrolled,
         // image loads, it triggers 'scroll' and the chat will be marked as scrolled,
         // which is technically true, but not what we want because the user
         // which is technically true, but not what we want because the user
         // didn't initiate the scrolling.
         // didn't initiate the scrolling.
         this.was_scrolled_up = this.model.get('scrolled');
         this.was_scrolled_up = this.model.get('scrolled');
         this.addEventListener('imageLoaded', () => {
         this.addEventListener('imageLoaded', () => {
-            !this.was_scrolled_up && this.scrollDown();
+            this.debouncedMaintainScroll(this.was_scrolled_up);
         });
         });
+        this.addEventListener('scroll', () => this.markScrolled());
+        this.initIntersectionObserver();
     }
     }
 
 
     render () {
     render () {
         return html`
         return html`
+            ${ 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}
                 .messages=${[...this.model.messages.models]}>
                 .messages=${[...this.model.messages.models]}>
             </converse-message-history>
             </converse-message-history>
             <div class="chat-content__notifications">${this.model.getNotificationsText()}</div>
             <div class="chat-content__notifications">${this.model.getNotificationsText()}</div>
@@ -46,7 +59,69 @@ export default class ChatContent extends CustomElement {
     }
     }
 
 
     updated () {
     updated () {
-        !this.model.get('scrolled') && this.debouncedScrolldown();
+        this.was_scrolled_up = this.model.get('scrolled');
+        this.debouncedMaintainScroll();
+    }
+
+    initIntersectionObserver () {
+      if (this.observer) {
+          this.observer.disconnect();
+      } else {
+          const options = {
+              root: this,
+              threshold: [0.1]
+          }
+          const handler = ev => this.setAnchoredMessage(ev);
+          this.observer = new IntersectionObserver(handler, options);
+      }
+    }
+
+    /**
+     * Called when the chat content is scrolled up or down.
+     * We want to record when the user has scrolled away from
+     * the bottom, so that we don't automatically scroll away
+     * from what the user is reading when new messages are received.
+     *
+     * Don't call this method directly, instead, call `markScrolled`,
+     * which debounces this method by 100ms.
+     * @private
+     */
+    _markScrolled () {
+        let scrolled = true;
+        const is_at_bottom = this.scrollTop + this.clientHeight >= this.scrollHeight;
+        if (is_at_bottom) {
+            scrolled = false;
+            onScrolledDown(this.model);
+        } else if (this.scrollTop === 0) {
+            /**
+             * Triggered once the chat's message area has been scrolled to the top
+             * @event _converse#chatBoxScrolledUp
+             * @property { _converse.ChatBoxView | _converse.ChatRoomView } view
+             * @example _converse.api.listen.on('chatBoxScrolledUp', obj => { ... });
+             */
+            api.trigger('chatBoxScrolledUp', this);
+        }
+        safeSave(this.model, { scrolled });
+    }
+
+    setAnchoredMessage (entries) {
+        if (!this.model?.ui || this.model.ui.get('chat-content-spinner-top')) {
+            return;
+        }
+        entries = entries.filter(e => e.isIntersecting);
+        const current = entries.reduce((p, c) => c.boundingClientRect.y >= (p?.boundingClientRect.y || 0) ? c : p, null);
+        if (current) {
+            this.anchored_message = current.target;
+        }
+    }
+
+    maintainScrollPosition () {
+        if (this.was_scrolled_up) {
+            console.warn('scrolling into view');
+            this.anchored_message?.scrollIntoView(true);
+        } else {
+            this.scrollDown();
+        }
     }
     }
 
 
     scrollDown () {
     scrollDown () {
@@ -56,6 +131,14 @@ export default class ChatContent extends CustomElement {
         } else {
         } else {
             this.scrollTop = this.scrollHeight;
             this.scrollTop = this.scrollHeight;
         }
         }
+        /**
+         * Triggered once the converse-chat-content element has been scrolled down to the bottom.
+         * @event _converse#chatBoxScrolledDown
+         * @type {object}
+         * @property { _converse.ChatBox | _converse.ChatRoom } chatbox - The chat model
+         * @example _converse.api.listen.on('chatBoxScrolledDown', obj => { ... });
+         */
+        api.trigger('chatBoxScrolledDown', { 'chatbox': this.model });
     }
     }
 }
 }
 
 

+ 3 - 3
src/shared/chat/emoji-picker-content.js

@@ -54,16 +54,16 @@ export default class EmojiPickerContent extends CustomElement {
       sizzle('.emoji-picker', this).forEach(a => this.observer.observe(a));
       sizzle('.emoji-picker', this).forEach(a => this.observer.observe(a));
   }
   }
 
 
-  setCategoryOnVisibilityChange (ev) {
+  setCategoryOnVisibilityChange (entries) {
       const selected = this.parentElement.navigator.selected;
       const selected = this.parentElement.navigator.selected;
-      const intersection_with_selected = ev.filter(i => i.target.contains(selected)).pop();
+      const intersection_with_selected = entries.filter(i => i.target.contains(selected)).pop();
       let current;
       let current;
       // Choose the intersection that contains the currently selected
       // Choose the intersection that contains the currently selected
       // element, or otherwise the one with the largest ratio.
       // element, or otherwise the one with the largest ratio.
       if (intersection_with_selected) {
       if (intersection_with_selected) {
           current = intersection_with_selected;
           current = intersection_with_selected;
       } else {
       } else {
-          current = ev.reduce((p, c) => c.intersectionRatio >= (p?.intersectionRatio || 0) ? c : p, null);
+          current = entries.reduce((p, c) => c.intersectionRatio >= (p?.intersectionRatio || 0) ? c : p, null);
       }
       }
       if (current && current.isIntersecting) {
       if (current && current.isIntersecting) {
           const category = current.target.getAttribute('data-category');
           const category = current.target.getAttribute('data-category');

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

+ 4 - 2
src/shared/chat/message-history.js

@@ -50,8 +50,9 @@ export default class MessageHistory extends CustomElement {
 
 
     static get properties () {
     static get properties () {
         return {
         return {
-            model: { type: Object},
-            messages: { type: Array}
+            model: { type: Object },
+            observer: { type: Object },
+            messages: { type: Array }
         }
         }
     }
     }
 
 
@@ -67,6 +68,7 @@ export default class MessageHistory extends CustomElement {
         const day = getDayIndicator(model);
         const day = getDayIndicator(model);
         const templates = day ? [day] : [];
         const templates = day ? [day] : [];
         const message = html`<converse-chat-message
         const message = html`<converse-chat-message
+            .observer=${this.observer}
             jid="${this.model.get('jid')}"
             jid="${this.model.get('jid')}"
             mid="${model.get('id')}"></converse-chat-message>`
             mid="${model.get('id')}"></converse-chat-message>`
 
 

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

+ 6 - 1
src/shared/chat/message.js

@@ -24,7 +24,8 @@ export default class Message extends CustomElement {
     static get properties () {
     static get properties () {
         return {
         return {
             jid: { type: String },
             jid: { type: String },
-            mid: { type: String }
+            mid: { type: String },
+            observer: { type: Object }
         }
         }
     }
     }
 
 
@@ -59,6 +60,10 @@ export default class Message extends CustomElement {
         }
         }
     }
     }
 
 
+    firstUpdated () {
+        this.observer.observe(this);
+    }
+
     getProps () {
     getProps () {
         return Object.assign(
         return Object.assign(
             this.model.toJSON(),
             this.model.toJSON(),

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

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

@@ -1,5 +1,5 @@
 import { CustomElement } from 'shared/components/element.js';
 import { CustomElement } from 'shared/components/element.js';
-import { _converse, api } from "@converse/headless/core";
+import { api } from "@converse/headless/core";
 import tpl_unfurl from './templates/unfurl.js';
 import tpl_unfurl from './templates/unfurl.js';
 
 
 import './styles/unfurl.scss';
 import './styles/unfurl.scss';
@@ -29,7 +29,7 @@ export default class MessageUnfurl extends CustomElement {
     }
     }
 
 
     onImageLoad () {
     onImageLoad () {
-        _converse.chatboxviews.get(this.getAttribute('jid'))?.scrollDown();
+        this.dispatchEvent(new CustomEvent('imageLoaded', { detail: this, 'bubbles': true }));
     }
     }
 }
 }
 
 

+ 11 - 0
src/shared/chat/utils.js

@@ -0,0 +1,11 @@
+import { _converse, api } from '@converse/headless/core';
+
+export function onScrolledDown (model) {
+    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();
+        }
+    }
+}

+ 1 - 1
src/shared/directives/rich-text.js

@@ -28,7 +28,7 @@ class RichTextRenderer {
 class RichTextDirective extends Directive {
 class RichTextDirective extends Directive {
     render (text, offset, mentions, options, callback) { // eslint-disable-line class-methods-use-this
     render (text, offset, mentions, options, callback) { // eslint-disable-line class-methods-use-this
         const renderer = new RichTextRenderer(text, offset, mentions, options);
         const renderer = new RichTextRenderer(text, offset, mentions, options);
-        const result =renderer.render();
+        const result = renderer.render();
         callback?.();
         callback?.();
         return result;
         return result;
     }
     }

+ 2 - 1
webpack.html

@@ -30,7 +30,7 @@
             modtools_disable_assign: ['owner', 'moderator', 'participant', 'visitor'],
             modtools_disable_assign: ['owner', 'moderator', 'participant', 'visitor'],
             modtools_disable_query: ['moderator', 'participant', 'visitor'],
             modtools_disable_query: ['moderator', 'participant', 'visitor'],
             enable_smacks: true,
             enable_smacks: true,
-            connection_options: { 'worker': '/dist/shared-connection-worker.js' },
+            // connection_options: { 'worker': '/dist/shared-connection-worker.js' },
             persistent_store: 'IndexedDB',
             persistent_store: 'IndexedDB',
             message_archiving: 'always',
             message_archiving: 'always',
             muc_domain: 'conference.chat.example.org',
             muc_domain: 'conference.chat.example.org',
@@ -40,6 +40,7 @@
             // bosh_service_url: 'http://chat.example.org:5280/http-bind',
             // bosh_service_url: 'http://chat.example.org:5280/http-bind',
             muc_show_logs_before_join: true,
             muc_show_logs_before_join: true,
             whitelisted_plugins: ['converse-debug', 'converse-batched-probe'],
             whitelisted_plugins: ['converse-debug', 'converse-batched-probe'],
+            blacklisted_plugins: [],
         });
         });
     });
     });
 </script>
 </script>