UNPKG

converse.js

Version:
269 lines (244 loc) 10.8 kB
/** * @typedef {import('shared/chat/emoji-dropdown.js').default} EmojiDropdown */ import { _converse, api, converse, constants, u } from '@converse/headless'; import log from '@converse/log'; import { __ } from 'i18n'; import { CustomElement } from 'shared/components/element.js'; import tplMessageForm from './templates/message-form.js'; import { parseMessageForCommands } from './utils.js'; const { ACTIVE, COMPOSING } = constants; export default class MessageForm extends CustomElement { static get properties() { return { model: { type: Object }, }; } constructor() { super(); this.model = null; } async initialize() { await this.model.initialized; this.listenTo(this.model, 'change:composing_spoiler', () => this.requestUpdate()); this.listenTo(this.model, 'change:draft', () => this.requestUpdate()); this.listenTo(this.model, 'change:hidden', () => { if (this.model.get('hidden')) { const draft_hint = /** @type {HTMLInputElement} */ (this.querySelector('.spoiler-hint'))?.value; const draft_message = /** @type {HTMLTextAreaElement} */ (this.querySelector('.chat-textarea'))?.value; u.safeSave(this.model, { draft: draft_message, draft_hint }); } }); this.handleEmojiSelection = (/** @type { CustomEvent } */ { detail }) => { if (this.model.get('jid') === detail.jid) { this.insertIntoTextArea(detail.value, detail.autocompleting, detail.ac_position); } }; document.addEventListener('emojiSelected', this.handleEmojiSelection); this.requestUpdate(); } disconnectedCallback() { super.disconnectedCallback(); document.removeEventListener('emojiSelected', this.handleEmojiSelection); } render() { return tplMessageForm(this); } /** * 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 {number} [position] - The end index of the string to be * replaced with the new value. */ insertIntoTextArea(value, replace = false, position, separator = ' ') { const textarea = /** @type {HTMLTextAreaElement} */ (this.querySelector('.chat-textarea')); if (replace) { if (position && typeof replace == 'string') { textarea.value = textarea.value.replace(new RegExp(replace, 'g'), (match, offset) => offset == position - replace.length ? value + separator : match ); } else { textarea.value = value; } } else { let existing = textarea.value; if (existing && existing[existing.length - 1] !== separator) { existing = existing + separator; } textarea.value = existing + value + separator; } const ev = new Event('change', { bubbles: false, cancelable: true }); textarea.dispatchEvent(ev); u.placeCaretAtEnd(textarea); } /** * Handles the escape key press event to stop correcting a message. * @param {KeyboardEvent} ev */ onEscapePressed(ev) { const idx = this.model.messages.findLastIndex('correcting'); const message = idx >= 0 ? this.model.messages.at(idx) : null; if (message) { ev.preventDefault(); message.save('correcting', false); } } /** * Handles the paste event to insert text or files into the chat. * @param {ClipboardEvent} ev */ onPaste(ev) { if (ev.clipboardData.files.length !== 0) { ev.stopPropagation(); ev.preventDefault(); this.model.sendFiles(Array.from(ev.clipboardData.files)); return; } const textarea = /** @type {HTMLTextAreaElement} */ (this.querySelector('.chat-textarea')); if (!textarea) { log.error('onPaste: could not find textarea to paste in to!'); return; } ev.preventDefault(); ev.stopPropagation(); const draft = textarea.value ?? ''; const pasted_text = ev.clipboardData.getData('text/plain'); const cursor_pos = textarea.selectionStart; // Insert text at cursor position const before = draft.substring(0, cursor_pos); const after = draft.substring(textarea.selectionEnd); const separator = before.endsWith(' ') || before.length === 0 ? '' : ' '; const end_separator = after.startsWith(' ') || after.length === 0 ? '' : ' '; this.model.save({ draft: `${before}${separator}${pasted_text}${end_separator}${after}` }); // Set cursor position after the pasted text const new_pos = before.length + separator.length + pasted_text.length + end_separator.length; setTimeout(() => textarea.setSelectionRange(new_pos, new_pos), 0); } /** * Handles the drop event to send files dragged-and-dropped into the chat. * @param {DragEvent} ev */ onDrop(ev) { if (ev.dataTransfer.files.length == 0) { return; } ev.preventDefault(); this.model.sendFiles(ev.dataTransfer.files); } /** * @param {KeyboardEvent} ev */ onKeyUp(ev) { // Trigger an event, for `<converse-message-limit-indicator/>` this.model.trigger('event:keyup', { ev }); } /** * @param {KeyboardEvent} [ev] */ onKeyDown(ev) { const { keycodes } = converse; if ( ev.ctrlKey || (ev.shiftKey && ev.key === keycodes.ENTER) || [keycodes.SHIFT, keycodes.META, keycodes.ESCAPE, keycodes.ALT].includes(ev.key) ) { return; } if (!ev.shiftKey && !ev.altKey && !ev.metaKey) { const target = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (ev.target); if (ev.key === converse.keycodes.TAB) { const value = u.getCurrentWord(target, null, /(:.*?:)/g); if (value.startsWith(':')) { ev.preventDefault(); ev.stopPropagation(); this.model.trigger('emoji-picker-autocomplete', { target, value }); } } else if (ev.key === converse.keycodes.FORWARD_SLASH) { // Forward slash is used to run commands. Nothing to do here. return; } else if (ev.key === converse.keycodes.ESCAPE) { return this.onEscapePressed(ev); } else if (ev.key === converse.keycodes.ENTER) { return this.onFormSubmitted(ev); } else if (ev.key === converse.keycodes.UP_ARROW && !target.selectionEnd) { const textarea = /** @type {HTMLTextAreaElement} */ (this.querySelector('.chat-textarea')); if (!textarea.value || this.model.get('correcting')) { return this.model.editEarlierMessage(); } } else if ( ev.key === converse.keycodes.DOWN_ARROW && target.selectionEnd === target.value.length && this.model.get('correcting') ) { return this.model.editLaterMessage(); } } if (this.model.get('chat_state') !== COMPOSING) { // Set chat state to composing if key is not a forward-slash // (which would imply an internal command and not a message). this.model.setChatState(COMPOSING); } } /** * @param {SubmitEvent|KeyboardEvent} ev */ async onFormSubmitted(ev) { ev?.preventDefault?.(); const { chatboxviews } = _converse.state; const textarea = /** @type {HTMLTextAreaElement} */ (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 (!api.connection.get().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 = /** @type {HTMLInputElement} */ (this.querySelector('form.chat-message-form input.spoiler-hint')); spoiler_hint = hint_el.value; } u.addClass('disabled', textarea); textarea.setAttribute('disabled', 'disabled'); /** @type {EmojiDropdown} */ (this.querySelector('converse-emoji-dropdown'))?.dropdown.hide(); const is_command = await parseMessageForCommands(this.model, message_text); const message = is_command ? null : await this.model.sendMessage({ 'body': message_text, spoiler_hint }); if (is_command || message) { hint_el.value = ''; textarea.value = ''; textarea.style.height = 'auto'; this.model.save({ '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 = chatboxviews.get(this.model.get('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 = chatboxviews.get(this.model.get('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(ACTIVE, { 'silent': true }); textarea.focus(); } } api.elements.define('converse-message-form', MessageForm);