diff --git a/karma.conf.js b/karma.conf.js index ef30f9a04..b31b03187 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -25,7 +25,6 @@ module.exports = function(config) { { pattern: "node_modules/sinon/pkg/sinon.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/user-details-modal.js", type: 'module' }, @@ -43,6 +42,7 @@ module.exports = function(config) { { pattern: "src/plugins/bookmark-views/tests/bookmarks.js", type: 'module' }, { pattern: "src/plugins/chatview/tests/chatbox.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/markers.js", type: 'module' }, { pattern: "src/plugins/chatview/tests/me-messages.js", type: 'module' }, diff --git a/spec/emojis.js b/spec/emojis.js index 695d2836e..410890191 100644 --- a/spec/emojis.js +++ b/spec/emojis.js @@ -48,8 +48,8 @@ describe("Emojis", function () { 'keyCode': 9, 'key': 'Tab' } - const bottom_panel = view.querySelector('converse-muc-bottom-panel'); - bottom_panel.onKeyDown(tab_event); + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(tab_event); await u.waitUntil(() => view.querySelector('converse-emoji-picker .emoji-search')?.value === ':gri'); await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji', view).length === 3, 1000); let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', view); @@ -89,7 +89,7 @@ describe("Emojis", function () { _converse.connection._dataRecv(mock.createRequest(presence)); textarea.value = ':use'; - bottom_panel.onKeyDown(tab_event); + message_form.onKeyDown(tab_event); await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists'))); await u.waitUntil(() => input.value === ':use'); visible_emojis = sizzle('.insert-emoji:not(.hidden)', picker); @@ -115,8 +115,8 @@ describe("Emojis", function () { 'keyCode': 9, 'key': 'Tab' } - const bottom_panel = view.querySelector('converse-muc-bottom-panel'); - bottom_panel.onKeyDown(tab_event); + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(tab_event); await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists'))); const picker = view.querySelector('converse-emoji-picker'); @@ -134,7 +134,7 @@ describe("Emojis", function () { emoji.click(); await u.waitUntil(() => textarea.value === ':grinning: '); textarea.value = ':grinning: :'; - bottom_panel.onKeyDown(tab_event); + message_form.onKeyDown(tab_event); await u.waitUntil(() => input.value === ':'); input.value = ':grimacing'; @@ -167,8 +167,8 @@ describe("Emojis", function () { 'key': 'Tab' } textarea.value = ':'; - const bottom_panel = view.querySelector('converse-muc-bottom-panel'); - bottom_panel.onKeyDown(tab_event); + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(tab_event); await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists'))); const picker = view.querySelector('converse-emoji-picker'); const input = picker.querySelector('.emoji-search'); @@ -179,7 +179,7 @@ describe("Emojis", function () { expect(textarea.value).toBe(':100: '); textarea.value = ':'; - bottom_panel.onKeyDown(tab_event); + message_form.onKeyDown(tab_event); await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists'))); await u.waitUntil(() => input.value === ':'); input.dispatchEvent(new KeyboardEvent('keydown', tab_event)); @@ -285,8 +285,8 @@ describe("Emojis", function () { // 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({ + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -296,7 +296,7 @@ describe("Emojis", function () { await u.waitUntil(() => view.querySelector(last_msg_sel).textContent === '๐Ÿ’ฉ ๐Ÿ˜‡'); expect(textarea.value).toBe(''); - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: textarea, keyCode: 38 // Up arrow }); @@ -306,7 +306,7 @@ describe("Emojis", function () { await u.waitUntil(() => u.hasClass('correcting', view.querySelector(sel)), 500); const edited_text = textarea.value += 'This is no longer an emoji-only message'; textarea.value = edited_text; - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -318,7 +318,7 @@ describe("Emojis", function () { expect(u.hasClass('chat-msg__text--larger', message)).toBe(false); textarea.value = ':smile: Hello world!'; - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -326,7 +326,7 @@ describe("Emojis", function () { await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4); textarea.value = ':smile: :smiley: :imp:'; - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -367,8 +367,8 @@ describe("Emojis", function () { const textarea = view.querySelector('textarea.chat-textarea'); textarea.value = ':poop: :innocent:'; - const bottom_panel = view.querySelector('converse-chat-bottom-panel'); - bottom_panel.onKeyDown({ + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -385,7 +385,7 @@ describe("Emojis", function () { 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( @@ -419,8 +419,8 @@ describe("Emojis", function () { 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({ + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter diff --git a/spec/mock.js b/spec/mock.js index 717ab2a99..1ee7995f2 100644 --- a/spec/mock.js +++ b/spec/mock.js @@ -436,8 +436,8 @@ mock.sendMessage = async function (view, message) { const promise = new Promise(resolve => view.model.messages.once('rendered', resolve)); const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); textarea.value = message; - const bottom_panel = view.querySelector('converse-chat-bottom-panel') || view.querySelector('converse-muc-bottom-panel'); - bottom_panel.onKeyDown({ + const message_form = view.querySelector('converse-message-form') || view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ target: view.querySelector('textarea.chat-textarea'), preventDefault: () => {}, keyCode: 13 diff --git a/src/headless/plugins/chat/model.js b/src/headless/plugins/chat/model.js index a44439fdb..d9eb3eb67 100644 --- a/src/headless/plugins/chat/model.js +++ b/src/headless/plugins/chat/model.js @@ -64,6 +64,7 @@ const ChatBox = ModelWithContact.extend({ this.presence.on('change:show', item => this.onPresenceChanged(item)); } this.on('change:chat_state', this.sendChatState, this); + this.on('change:scrolled', () => !this.get('scrolled') && this.clearUnreadMsgCounter()); await this.fetchMessages(); /** @@ -198,7 +199,7 @@ const ChatBox = ModelWithContact.extend({ * Queue an incoming `chat` message stanza for processing. * @async * @private - * @method _converse.ChatRoom#queueMessage + * @method _converse.ChatBox#queueMessage * @param { Promise } attrs - A promise which resolves to the message attributes */ queueMessage (attrs) { @@ -211,7 +212,7 @@ const ChatBox = ModelWithContact.extend({ /** * @async * @private - * @method _converse.ChatRoom#onMessage + * @method _converse.ChatBox#onMessage * @param { MessageAttributes } attrs_promse - A promise which resolves to the message attributes. */ async onMessage (attrs) { @@ -681,7 +682,6 @@ const ChatBox = ModelWithContact.extend({ return _converse.connection.send(msg); }, - /** * Finds the last eligible message and then sends a XEP-0333 chat marker for it. * @param { ('received'|'displayed'|'acknowledged') } [type='displayed'] @@ -866,7 +866,7 @@ const ChatBox = ModelWithContact.extend({ * before the collection has been fetched. * @async * @private - * @method _converse.ChatRoom#queueMessageCreation + * @method _converse.ChatBox#queueMessageCreation * @param { Object } attrs */ async createMessage (attrs, options) { @@ -1029,6 +1029,7 @@ const ChatBox = ModelWithContact.extend({ * Given a newly received {@link _converse.Message} instance, * update the unread counter if necessary. * @private + * @method _converse.ChatBox#handleUnreadMessage * @param {_converse.Message} message */ handleUnreadMessage (message) { @@ -1064,7 +1065,7 @@ const ChatBox = ModelWithContact.extend({ }, isScrolledUp () { - return this.get('scrolled', true); + return this.get('scrolled'); } }); diff --git a/src/headless/plugins/muc/muc.js b/src/headless/plugins/muc/muc.js index 05da4acd3..537d5829b 100644 --- a/src/headless/plugins/muc/muc.js +++ b/src/headless/plugins/muc/muc.js @@ -97,6 +97,7 @@ const ChatRoomMixin = { this.on('change:chat_state', this.sendChatState, this); this.on('change:hidden', this.onHiddenChange, this); + this.on('change:scrolled', () => !this.get('scrolled') && this.clearUnreadMsgCounter()); this.on('destroy', this.removeHandlers, this); await this.restoreSession(); @@ -2562,7 +2563,9 @@ const ChatRoomMixin = { } }, - /* Given a newly received message, update the unread counter if necessary. + /** + * Given a newly received {@link _converse.Message} instance, + * update the unread counter if necessary. * @private * @method _converse.ChatRoom#handleUnreadMessage * @param { XMLElement } - The stanza @@ -2572,7 +2575,13 @@ const ChatRoomMixin = { return; } 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 = { 'num_unread_general': this.get('num_unread_general') + 1 }; diff --git a/src/plugins/chatview/bottom-panel.js b/src/plugins/chatview/bottom-panel.js index 6f0ac8688..fc5659c19 100644 --- a/src/plugins/chatview/bottom-panel.js +++ b/src/plugins/chatview/bottom-panel.js @@ -1,85 +1,42 @@ -import tpl_chatbox_message_form from './templates/chatbox_message_form.js'; -import tpl_toolbar from './templates/toolbar.js'; +import './message-form.js'; +import debounce from 'lodash-es/debounce'; +import tpl_bottom_panel from './templates/bottom-panel.js'; import { ElementView } from '@converse/skeletor/src/element.js'; -import { __ } 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'; -const { u } = converse.env; export default class ChatBottomPanel extends ElementView { events = { - 'click .send-button': 'onFormSubmitted', + 'click .send-button': 'sendButtonClicked', 'click .toggle-clear': 'clearMessages' }; async connectedCallback () { super.connectedCallback(); + this.debouncedRender = debounce(this.render, 100); this.model = _converse.chatboxes.get(this.getAttribute('jid')); - this.listenTo(this.model, 'change', o => this.onModelChanged(o.changed)); 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(); } - onModelChanged (changed) { - if ('composing_spoiler' in changed || 'num_unread' in changed || 'scrolled' in changed) { - this.renderMessageForm(); - } - } - render () { - render(html`
`, this); - this.renderMessageForm(); + render(tpl_bottom_panel({ + 'model': this.model, + 'viewUnreadMessages': ev => this.viewUnreadMessages(ev) + }), this); } - renderToolbar () { - if (!api.settings.get('show_toolbar')) { - return this; - } - const options = Object.assign( - { - 'model': this.model, - 'chatview': _converse.chatboxviews.get(this.getAttribute('jid')) - }, - this.model.toJSON(), - this.getToolbarOptions() - ); - render(tpl_toolbar(options), this.querySelector('.chat-toolbar')); - /** - * Triggered once the _converse.ChatBoxView's toolbar has been rendered - * @event _converse#renderToolbar - * @type { _converse.ChatBoxView } - * @example _converse.api.listen.on('renderToolbar', this => { ... }); - */ - api.trigger('renderToolbar', this); - return this; - } - - renderMessageForm () { - const form_container = this.querySelector('.message-form-container'); - render( - tpl_chatbox_message_form( - Object.assign(this.model.toJSON(), { - 'onDrop': ev => this.onDrop(ev), - 'hint_value': this.querySelector('.spoiler-hint')?.value, - 'inputChanged': ev => this.inputChanged(ev), - 'message_value': this.querySelector('.chat-textarea')?.value, - 'onChange': ev => this.updateCharCounter(ev.target.value), - 'onKeyDown': ev => this.onKeyDown(ev), - 'onKeyUp': ev => this.onKeyUp(ev), - 'onPaste': ev => this.onPaste(ev), - 'viewUnreadMessages': ev => this.viewUnreadMessages(ev) - }) - ), - form_container - ); - this.addEventListener('focusin', ev => this.emitFocused(ev)); - this.addEventListener('focusout', ev => this.emitBlurred(ev)); - this.renderToolbar(); + sendButtonClicked (ev) { + this.querySelector('converse-message-form')?.onFormSubmitted(ev); } viewUnreadMessages (ev) { @@ -87,19 +44,6 @@ export default class ChatBottomPanel extends ElementView { this.model.save({ 'scrolled': false }); } - onMessageCorrecting (message) { - if (message.get('correcting')) { - this.insertIntoTextArea(u.prefixMentions(message), true, true); - } else { - const currently_correcting = this.model.messages.findWhere('correcting'); - if (currently_correcting && currently_correcting !== message) { - this.insertIntoTextArea(u.prefixMentions(message), true, true); - } else { - this.insertIntoTextArea('', true, false); - } - } - } - emitFocused (ev) { _converse.chatboxviews.get(this.getAttribute('jid'))?.emitFocused(ev); } @@ -112,18 +56,6 @@ export default class ChatBottomPanel extends ElementView { 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) { if (evt.dataTransfer.files.length == 0) { // There are no files to be dropped, so this isnโ€™t a file @@ -143,211 +75,19 @@ export default class ChatBottomPanel extends ElementView { clearMessages(this.model); } - parseMessageForCommands (text) { - return parseMessageForCommands(this.model, text); - } - - async onFormSubmitted (ev) { - ev?.preventDefault?.(); - - const textarea = this.querySelector('.chat-textarea'); - const message_text = textarea.value.trim(); - if ( - (api.settings.get('message_limit') && message_text.length > api.settings.get('message_limit')) || - !message_text.replace(/\s/g, '').length - ) { - return; - } - if (!_converse.connection.authenticated) { - const err_msg = __('Sorry, the connection has been lost, and your message could not be sent'); - api.alert('error', __('Error'), err_msg); - api.connection.reconnect(); - return; - } - let spoiler_hint, - hint_el = {}; - if (this.model.get('composing_spoiler')) { - hint_el = this.querySelector('form.sendXMPPMessage input.spoiler-hint'); - spoiler_hint = hint_el.value; - } - u.addClass('disabled', textarea); - textarea.setAttribute('disabled', 'disabled'); - this.querySelector('converse-emoji-dropdown')?.hideMenu(); - - const is_command = this.parseMessageForCommands(message_text); - const message = is_command ? null : await this.model.sendMessage(message_text, spoiler_hint); - if (is_command || message) { - hint_el.value = ''; - textarea.value = ''; - u.removeClass('correcting', textarea); - textarea.style.height = 'auto'; - this.updateCharCounter(textarea.value); - } - if (api.settings.get('view_mode') === 'overlayed') { - // XXX: Chrome flexbug workaround. The .chat-content area - // doesn't resize when the textarea is resized to its original size. - const chatview = _converse.chatboxviews.get(this.getAttribute('jid')); - const msgs_container = chatview.querySelector('.chat-content__messages'); - msgs_container.parentElement.style.display = 'none'; - } - textarea.removeAttribute('disabled'); - u.removeClass('disabled', textarea); - - if (api.settings.get('view_mode') === 'overlayed') { - // XXX: Chrome flexbug workaround. - const chatview = _converse.chatboxviews.get(this.getAttribute('jid')); - const msgs_container = chatview.querySelector('.chat-content__messages'); - msgs_container.parentElement.style.display = ''; - } - // Suppress events, otherwise superfluous CSN gets set - // immediately after the message, causing rate-limiting issues. - this.model.setChatState(_converse.ACTIVE, { 'silent': true }); - textarea.focus(); - } - - /** - * Insert a particular string value into the textarea of this chat box. - * @param {string} value - The value to be inserted. - * @param {(boolean|string)} [replace] - Whether an existing value - * should be replaced. If set to `true`, the entire textarea will - * be replaced with the new value. If set to a string, then only - * that string will be replaced *if* a position is also specified. - * @param {integer} [position] - The end index of the string to be - * replaced with the new value. - */ - insertIntoTextArea (value, replace = false, correcting = false, position) { - const textarea = this.querySelector('.chat-textarea'); - if (correcting) { - u.addClass('correcting', textarea); - } else { - u.removeClass('correcting', textarea); - } - if (replace) { - if (position && typeof replace == 'string') { - textarea.value = textarea.value.replace(new RegExp(replace, 'g'), (match, offset) => - offset == position - replace.length ? value + ' ' : match - ); - } else { - textarea.value = value; - } - } else { - let existing = textarea.value; - if (existing && existing[existing.length - 1] !== ' ') { - existing = existing + ' '; - } - textarea.value = existing + value + ' '; - } - const ev = document.createEvent('HTMLEvents'); - ev.initEvent('change', false, true); - textarea.dispatchEvent(ev); - u.placeCaretAtEnd(textarea); - } - - onEscapePressed (ev) { - ev.preventDefault(); - const idx = this.model.messages.findLastIndex('correcting'); - const message = idx >= 0 ? this.model.messages.at(idx) : null; - if (message) { - message.save('correcting', false); - } - this.insertIntoTextArea('', true, false); - } - async autocompleteInPicker (input, value) { await api.emojis.initialize(); - const emoji_dropdown = this.querySelector('converse-emoji-dropdown'); const emoji_picker = this.querySelector('converse-emoji-picker'); - if (emoji_picker && emoji_dropdown) { + if (emoji_picker) { emoji_picker.model.set({ 'ac_position': input.selectionStart, 'autocompleting': value, 'query': value }); - emoji_dropdown.showMenu(); - return true; + const emoji_dropdown = this.querySelector('converse-emoji-dropdown'); + emoji_dropdown?.showMenu(); } } - - 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; - } - this.updateCharCounter(ev.clipboardData.getData('text/plain')); - } } api.elements.define('converse-chat-bottom-panel', ChatBottomPanel); diff --git a/src/plugins/chatview/message-form.js b/src/plugins/chatview/message-form.js new file mode 100644 index 000000000..4ed47c376 --- /dev/null +++ b/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); diff --git a/src/plugins/chatview/templates/bottom-panel.js b/src/plugins/chatview/templates/bottom-panel.js index 61ed00905..a2e885295 100644 --- a/src/plugins/chatview/templates/bottom-panel.js +++ b/src/plugins/chatview/templates/bottom-panel.js @@ -1,4 +1,30 @@ +import { __ } from 'i18n'; +import { api } from '@converse/headless/core'; +import { html } from 'lit'; -
-
-
+ +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`
o.viewUnreadMessages(ev)}>โ–ผ ${ unread_msgs } โ–ผ
` : '' } + ${api.settings.get('show_toolbar') ? html` + ` : '' } + + `; +} diff --git a/src/plugins/chatview/templates/message-form.js b/src/plugins/chatview/templates/message-form.js new file mode 100644 index 000000000..e372ebd93 --- /dev/null +++ b/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` +
+ + +
`; +} diff --git a/src/plugins/chatview/templates/toolbar.js b/src/plugins/chatview/templates/toolbar.js deleted file mode 100644 index b3c3c262b..000000000 --- a/src/plugins/chatview/templates/toolbar.js +++ /dev/null @@ -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` - - `; -} diff --git a/src/plugins/chatview/tests/chatbox.js b/src/plugins/chatview/tests/chatbox.js index a1f3bbc84..dc69f8770 100644 --- a/src/plugins/chatview/tests/chatbox.js +++ b/src/plugins/chatview/tests/chatbox.js @@ -59,8 +59,8 @@ describe("Chatboxes", function () { const textarea = view.querySelector('textarea.chat-textarea'); 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, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -264,14 +264,14 @@ describe("Chatboxes", function () { const toolbar = view.querySelector('.chat-toolbar'); const counter = toolbar.querySelector('.message-limit'); 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(); const picker = await u.waitUntil(() => view.querySelector('.emoji-picker__lists')); const item = await u.waitUntil(() => picker.querySelector('.emoji-picker li.insert-emoji a')); item.click() - expect(counter.textContent).toBe('179'); + await u.waitUntil(() => counter.textContent === '179'); const textarea = view.querySelector('.chat-textarea'); const ev = { @@ -279,15 +279,15 @@ describe("Chatboxes", function () { preventDefault: function preventDefault () {}, 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); - bottom_panel.onKeyUp(ev); + message_form.onKeyUp(ev); expect(counter.textContent).toBe('200'); textarea.value = 'hello world'; - bottom_panel.onKeyUp(ev); - expect(counter.textContent).toBe('189'); + message_form.onKeyUp(ev); + await u.waitUntil(() => counter.textContent === '189'); done(); })); @@ -430,8 +430,8 @@ describe("Chatboxes", function () { spyOn(_converse.connection, 'send'); 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'), keyCode: 1 }); @@ -446,7 +446,7 @@ describe("Chatboxes", function () { expect(stanza.childNodes[2].tagName).toBe('no-permanent-store'); // The notification is not sent again - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: view.querySelector('textarea.chat-textarea'), keyCode: 1 }); @@ -470,8 +470,8 @@ describe("Chatboxes", function () { expect(view.model.get('chat_state')).toBe('active'); spyOn(_converse.connection, 'send'); 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'), keyCode: 1 }); @@ -579,8 +579,8 @@ describe("Chatboxes", function () { const view = _converse.chatboxviews.get(contact_jid); spyOn(view.model, 'setChatState').and.callThrough(); 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'), keyCode: 1 }); @@ -612,14 +612,14 @@ describe("Chatboxes", function () { // Test #359. A paused notification should not be sent // out if the user simply types longer than the // timeout. - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: view.querySelector('textarea.chat-textarea'), keyCode: 1 }); expect(view.model.setChatState).toHaveBeenCalled(); expect(view.model.get('chat_state')).toBe('composing'); - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: view.querySelector('textarea.chat-textarea'), keyCode: 1 }); @@ -718,8 +718,8 @@ describe("Chatboxes", function () { ``); - 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'), keyCode: 1 }); @@ -937,10 +937,10 @@ describe("Chatboxes", function () { await u.waitUntil(() => view.querySelector('.chat-msg')); 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); view.querySelector('.chat-textarea').value = message; - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: view.querySelector('textarea.chat-textarea'), preventDefault: function preventDefault () {}, keyCode: 13 @@ -1191,7 +1191,7 @@ describe("Chatboxes", function () { const view = _converse.chatboxviews.get(sender_jid); await u.waitUntil(() => view.model.messages.length); 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(); await u.waitUntil(() => select_msgs_indicator() === undefined); done(); diff --git a/src/plugins/chatview/tests/corrections.js b/src/plugins/chatview/tests/corrections.js index be69c44a1..416bd1773 100644 --- a/src/plugins/chatview/tests/corrections.js +++ b/src/plugins/chatview/tests/corrections.js @@ -14,15 +14,15 @@ describe("A Chat Message", function () { const view = _converse.api.chatviews.get(contact_jid); const textarea = view.querySelector('textarea.chat-textarea'); expect(textarea.value).toBe(''); - 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, keyCode: 38 // Up arrow }); expect(textarea.value).toBe(''); textarea.value = 'But soft, what light through yonder airlock breaks?'; - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, 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?'}); expect(textarea.value).toBe(''); - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: textarea, keyCode: 38 // Up arrow }); @@ -46,7 +46,7 @@ describe("A Chat Message", function () { spyOn(_converse.connection, 'send'); let new_text = 'But soft, what light through yonder window breaks?'; textarea.value = new_text; - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -80,7 +80,7 @@ describe("A Chat Message", function () { // Test that pressing the down arrow cancels message correction await u.waitUntil(() => textarea.value === '') - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: textarea, keyCode: 38 // Up arrow }); @@ -89,7 +89,7 @@ describe("A Chat Message", function () { expect(view.querySelectorAll('.chat-msg').length).toBe(1); await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500); expect(textarea.value).toBe('But soft, what light through yonder window breaks?'); - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: textarea, keyCode: 40 // Down arrow }); @@ -100,7 +100,7 @@ describe("A Chat Message", function () { new_text = 'It is the east, and Juliet is the one.'; textarea.value = new_text; - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -110,14 +110,14 @@ describe("A Chat Message", function () { expect(view.querySelectorAll('.chat-msg').length).toBe(2); textarea.value = 'Arise, fair sun, and kill the envious moon'; - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter }); await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3); - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: textarea, keyCode: 38 // Up arrow }); @@ -129,7 +129,7 @@ describe("A Chat Message", function () { textarea.selectionEnd = 0; // Happens by pressing up, // but for some reason not in tests, so we set it manually. - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: textarea, 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); textarea.value = 'It is the east, and Juliet is the sun.'; - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -176,8 +176,8 @@ describe("A Chat Message", function () { const textarea = view.querySelector('textarea.chat-textarea'); 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, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -204,7 +204,7 @@ describe("A Chat Message", function () { spyOn(_converse.connection, 'send'); textarea.value = 'But soft, what light through yonder window breaks?'; - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter diff --git a/src/plugins/chatview/tests/markers.js b/src/plugins/chatview/tests/markers.js index 39d15ce11..4acf4499b 100644 --- a/src/plugins/chatview/tests/markers.js +++ b/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 textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); 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, preventDefault: function preventDefault () {}, keyCode: 13 // Enter diff --git a/src/plugins/chatview/tests/messages.js b/src/plugins/chatview/tests/messages.js index cc585056c..84a8103ce 100644 --- a/src/plugins/chatview/tests/messages.js +++ b/src/plugins/chatview/tests/messages.js @@ -1213,14 +1213,13 @@ describe("A Chat Message", function () { } 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.querySelector('.chat-content').scrollTop).toBe(0); 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(); })); diff --git a/src/plugins/chatview/tests/receipts.js b/src/plugins/chatview/tests/receipts.js index a4b5a20ba..e07b1814f 100644 --- a/src/plugins/chatview/tests/receipts.js +++ b/src/plugins/chatview/tests/receipts.js @@ -110,8 +110,8 @@ describe("A delivery receipt", function () { const view = _converse.chatboxviews.get(contact_jid); const textarea = view.querySelector('textarea.chat-textarea'); textarea.value = 'But soft, what light through yonder airlock breaks?'; - 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, preventDefault: function preventDefault () {}, keyCode: 13 // Enter @@ -132,7 +132,7 @@ describe("A delivery receipt", function () { // Also handle receipts with type 'chat'. See #1353 spyOn(_converse, 'handleMessageStanza').and.callThrough(); textarea.value = 'Another message'; - bottom_panel.onKeyDown({ + message_form.onKeyDown({ target: textarea, preventDefault: function preventDefault () {}, keyCode: 13 // Enter diff --git a/src/plugins/chatview/tests/spoilers.js b/src/plugins/chatview/tests/spoilers.js index 54f837a99..10b2a9c03 100644 --- a/src/plugins/chatview/tests/spoilers.js +++ b/src/plugins/chatview/tests/spoilers.js @@ -112,8 +112,8 @@ describe("A spoiler message", function () { const textarea = view.querySelector('.chat-textarea'); 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, preventDefault: function preventDefault () {}, keyCode: 13 @@ -193,8 +193,8 @@ describe("A spoiler message", function () { const hint_input = view.querySelector('.spoiler-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, preventDefault: function preventDefault () {}, keyCode: 13 diff --git a/src/plugins/chatview/utils.js b/src/plugins/chatview/utils.js index e0ecd902e..ff8424f75 100644 --- a/src/plugins/chatview/utils.js +++ b/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 = ''; + } +} diff --git a/src/plugins/chatview/view.js b/src/plugins/chatview/view.js index 26ba886fc..462eda68d 100644 --- a/src/plugins/chatview/view.js +++ b/src/plugins/chatview/view.js @@ -141,7 +141,6 @@ export default class ChatView extends BaseChatView { } afterShown () { - this.model.clearUnreadMsgCounter(); this.model.setChatState(_converse.ACTIVE); this.scrollDown(); this.maybeFocus(); diff --git a/src/plugins/headlines-view/templates/chat-head.js b/src/plugins/headlines-view/templates/chat-head.js index 1a3c6efd9..52d0025e4 100644 --- a/src/plugins/headlines-view/templates/chat-head.js +++ b/src/plugins/headlines-view/templates/chat-head.js @@ -8,7 +8,7 @@ export default (o) => { return html`
- ${ (!_converse.api.settings.get("singleton")) ? html`
` : '' } + ${ (!_converse.api.settings.get("singleton")) ? html`` : '' }
${ o.display_name }
diff --git a/src/plugins/muc-views/bottom-panel.js b/src/plugins/muc-views/bottom-panel.js index 7dc5385d6..7557b5e88 100644 --- a/src/plugins/muc-views/bottom-panel.js +++ b/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 { __ } from 'i18n'; import { _converse, api, converse } from "@converse/headless/core"; -import { getAutoCompleteListItem, parseMessageForMUCCommands } from './utils.js'; import { render } from 'lit'; import './styles/muc-bottom-panel.scss'; @@ -14,15 +13,15 @@ export default class MUCBottomPanel extends BottomPanel { events = { 'click .hide-occupants': 'hideOccupants', - 'click .send-button': 'onFormSubmitted', + 'click .send-button': 'sendButtonClicked', } async connectedCallback () { // this.model gets set in the super method and we also wait there for this.model.initialized await super.connectedCallback(); 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:num_unread_general', this.debouncedRender) this.listenTo(this.model.features, 'change:moderated', this.debouncedRender); this.listenTo(this.model.occupants, 'add', this.renderIfOwnOccupant) this.listenTo(this.model.occupants, 'change:role', this.renderIfOwnOccupant); @@ -33,17 +32,21 @@ export default class MUCBottomPanel extends BottomPanel { render () { const entered = this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED; const can_edit = entered && !(this.model.features.get('moderated') && this.model.getOwnRole() === 'visitor'); - render(tpl_muc_bottom_panel({ can_edit, entered, 'model': this.model }), this); - if (entered && can_edit) { - this.renderMessageForm(); - this.initMentionAutoComplete(); - } + render(tpl_muc_bottom_panel({ + can_edit, entered, + 'model': this.model, + 'viewUnreadMessages': ev => this.viewUnreadMessages(ev) + }), this); } renderIfOwnOccupant (o) { (o.get('jid') === _converse.bare_jid) && this.debouncedRender(); } + sendButtonClicked (ev) { + this.querySelector('converse-message-form')?.onFormSubmitted(ev); + } + getToolbarOptions () { return Object.assign(super.getToolbarOptions(), { 'is_groupchat': true, @@ -52,49 +55,11 @@ export default class MUCBottomPanel extends BottomPanel { }); } - getAutoCompleteList () { - return this.model.getAllKnownNicknames().map(nick => ({ 'label': nick, 'value': `@${nick}` })); - } - - initMentionAutoComplete () { - this.mention_auto_complete = new _converse.AutoComplete(this, { - 'auto_first': true, - 'auto_evaluate': false, - 'min_chars': api.settings.get('muc_mention_autocomplete_min_chars'), - 'match_current_word': true, - 'list': () => this.getAutoCompleteList(), - 'filter': - api.settings.get('muc_mention_autocomplete_filter') == 'contains' - ? _converse.FILTER_CONTAINS - : _converse.FILTER_STARTSWITH, - 'ac_triggers': ['Tab', '@'], - 'include_triggers': [], - 'item': getAutoCompleteListItem - }); - this.mention_auto_complete.on('suggestion-box-selectcomplete', () => (this.auto_completing = false)); - } - hideOccupants (ev) { ev?.preventDefault?.(); ev?.stopPropagation?.(); this.model.save({ 'hidden_occupants': true }); } - - onKeyDown (ev) { - if (this.mention_auto_complete.onKeyDown(ev)) { - return; - } - super.onKeyDown(ev); - } - - onKeyUp (ev) { - this.mention_auto_complete.evaluate(ev); - super.onKeyUp(ev); - } - - parseMessageForCommands (text) { - return parseMessageForMUCCommands(this.model, text); - } } api.elements.define('converse-muc-bottom-panel', MUCBottomPanel); diff --git a/src/plugins/muc-views/message-form.js b/src/plugins/muc-views/message-form.js new file mode 100644 index 000000000..0e0aa3784 --- /dev/null +++ b/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); diff --git a/src/plugins/muc-views/muc.js b/src/plugins/muc-views/muc.js index c9f95c021..5cc8c8276 100644 --- a/src/plugins/muc-views/muc.js +++ b/src/plugins/muc-views/muc.js @@ -53,7 +53,6 @@ export default class MUCView extends BaseChatView { */ afterShown () { if (!this.model.get('hidden') && !this.model.get('minimized')) { - this.model.clearUnreadMsgCounter(); this.scrollDown(); } } diff --git a/src/plugins/muc-views/styles/index.scss b/src/plugins/muc-views/styles/index.scss index 4a4bf73f4..1d1d10015 100644 --- a/src/plugins/muc-views/styles/index.scss +++ b/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); - } - } - } - } - } - } - } - } -} diff --git a/src/plugins/chatview/templates/chatbox_message_form.js b/src/plugins/muc-views/templates/message-form.js similarity index 94% rename from src/plugins/chatview/templates/chatbox_message_form.js rename to src/plugins/muc-views/templates/message-form.js index 9a9e0fd88..3de032f64 100644 --- a/src/plugins/chatview/templates/chatbox_message_form.js +++ b/src/plugins/muc-views/templates/message-form.js @@ -1,6 +1,7 @@ import { __ } from 'i18n'; import { api } from "@converse/headless/core"; import { html } from "lit"; +import { resetElementHeight } from 'plugins/chatview/utils.js'; export default (o) => { @@ -15,16 +16,14 @@ export default (o) => {
- -