message-form.js 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. import tplMessageForm from './templates/message-form.js';
  2. import { ElementView } from '@converse/skeletor/src/element.js';
  3. import { __ } from 'i18n';
  4. import { _converse, api, converse } from "@converse/headless/core.js";
  5. import { parseMessageForCommands } from './utils.js';
  6. import { prefixMentions } from '@converse/headless/utils/core.js';
  7. const { u } = converse.env;
  8. export default class MessageForm extends ElementView {
  9. async connectedCallback () {
  10. super.connectedCallback();
  11. this.model = _converse.chatboxes.get(this.getAttribute('jid'));
  12. await this.model.initialized;
  13. this.listenTo(this.model.messages, 'change:correcting', this.onMessageCorrecting);
  14. this.listenTo(this.model, 'change:composing_spoiler', () => this.render());
  15. this.handleEmojiSelection = ({ detail }) => {
  16. if (this.model.get('jid') === detail.jid) {
  17. this.insertIntoTextArea(detail.value, detail.autocompleting, false, detail.ac_position);
  18. }
  19. }
  20. document.addEventListener("emojiSelected", this.handleEmojiSelection);
  21. this.render();
  22. }
  23. disconnectedCallback () {
  24. super.disconnectedCallback();
  25. document.removeEventListener("emojiSelected", this.handleEmojiSelection);
  26. }
  27. toHTML () {
  28. return tplMessageForm(
  29. Object.assign(this.model.toJSON(), {
  30. 'onDrop': ev => this.onDrop(ev),
  31. 'hint_value': this.querySelector('.spoiler-hint')?.value,
  32. 'message_value': this.querySelector('.chat-textarea')?.value,
  33. 'onChange': ev => this.model.set({'draft': ev.target.value}),
  34. 'onKeyDown': ev => this.onKeyDown(ev),
  35. 'onKeyUp': ev => this.onKeyUp(ev),
  36. 'onPaste': ev => this.onPaste(ev),
  37. 'viewUnreadMessages': ev => this.viewUnreadMessages(ev)
  38. })
  39. );
  40. }
  41. /**
  42. * Insert a particular string value into the textarea of this chat box.
  43. * @param { string } value - The value to be inserted.
  44. * @param {(boolean|string)} [replace] - Whether an existing value
  45. * should be replaced. If set to `true`, the entire textarea will
  46. * be replaced with the new value. If set to a string, then only
  47. * that string will be replaced *if* a position is also specified.
  48. * @param { number } [position] - The end index of the string to be
  49. * replaced with the new value.
  50. */
  51. insertIntoTextArea (value, replace = false, correcting = false, position) {
  52. const textarea = this.querySelector('.chat-textarea');
  53. if (correcting) {
  54. u.addClass('correcting', textarea);
  55. } else {
  56. u.removeClass('correcting', textarea);
  57. }
  58. if (replace) {
  59. if (position && typeof replace == 'string') {
  60. textarea.value = textarea.value.replace(new RegExp(replace, 'g'), (match, offset) =>
  61. offset == position - replace.length ? value + ' ' : match
  62. );
  63. } else {
  64. textarea.value = value;
  65. }
  66. } else {
  67. let existing = textarea.value;
  68. if (existing && existing[existing.length - 1] !== ' ') {
  69. existing = existing + ' ';
  70. }
  71. textarea.value = existing + value + ' ';
  72. }
  73. const ev = document.createEvent('HTMLEvents');
  74. ev.initEvent('change', false, true);
  75. textarea.dispatchEvent(ev);
  76. u.placeCaretAtEnd(textarea);
  77. }
  78. onMessageCorrecting (message) {
  79. if (message.get('correcting')) {
  80. this.insertIntoTextArea(prefixMentions(message), true, true);
  81. } else {
  82. const currently_correcting = this.model.messages.findWhere('correcting');
  83. if (currently_correcting && currently_correcting !== message) {
  84. this.insertIntoTextArea(prefixMentions(message), true, true);
  85. } else {
  86. this.insertIntoTextArea('', true, false);
  87. }
  88. }
  89. }
  90. onEscapePressed (ev) {
  91. const idx = this.model.messages.findLastIndex('correcting');
  92. const message = idx >= 0 ? this.model.messages.at(idx) : null;
  93. if (message) {
  94. ev.preventDefault();
  95. message.save('correcting', false);
  96. this.insertIntoTextArea('', true, false);
  97. }
  98. }
  99. onPaste (ev) {
  100. ev.stopPropagation();
  101. if (ev.clipboardData.files.length !== 0) {
  102. ev.preventDefault();
  103. // Workaround for quirk in at least Firefox 60.7 ESR:
  104. // It seems that pasted files disappear from the event payload after
  105. // the event has finished, which apparently happens during async
  106. // processing in sendFiles(). So we copy the array here.
  107. this.model.sendFiles(Array.from(ev.clipboardData.files));
  108. return;
  109. }
  110. this.model.set({'draft': ev.clipboardData.getData('text/plain')});
  111. }
  112. onKeyUp (ev) {
  113. this.model.set({'draft': ev.target.value});
  114. }
  115. onKeyDown (ev) {
  116. if (ev.ctrlKey) {
  117. // When ctrl is pressed, no chars are entered into the textarea.
  118. return;
  119. }
  120. if (!ev.shiftKey && !ev.altKey && !ev.metaKey) {
  121. if (ev.keyCode === converse.keycodes.TAB) {
  122. const value = u.getCurrentWord(ev.target, null, /(:.*?:)/g);
  123. if (value.startsWith(':')) {
  124. ev.preventDefault();
  125. ev.stopPropagation();
  126. this.model.trigger('emoji-picker-autocomplete', ev.target, value);
  127. }
  128. } else if (ev.keyCode === converse.keycodes.FORWARD_SLASH) {
  129. // Forward slash is used to run commands. Nothing to do here.
  130. return;
  131. } else if (ev.keyCode === converse.keycodes.ESCAPE) {
  132. return this.onEscapePressed(ev, this);
  133. } else if (ev.keyCode === converse.keycodes.ENTER) {
  134. return this.onFormSubmitted(ev);
  135. } else if (ev.keyCode === converse.keycodes.UP_ARROW && !ev.target.selectionEnd) {
  136. const textarea = this.querySelector('.chat-textarea');
  137. if (!textarea.value || u.hasClass('correcting', textarea)) {
  138. return this.model.editEarlierMessage();
  139. }
  140. } else if (
  141. ev.keyCode === converse.keycodes.DOWN_ARROW &&
  142. ev.target.selectionEnd === ev.target.value.length &&
  143. u.hasClass('correcting', this.querySelector('.chat-textarea'))
  144. ) {
  145. return this.model.editLaterMessage();
  146. }
  147. }
  148. if (
  149. [
  150. converse.keycodes.SHIFT,
  151. converse.keycodes.META,
  152. converse.keycodes.META_RIGHT,
  153. converse.keycodes.ESCAPE,
  154. converse.keycodes.ALT
  155. ].includes(ev.keyCode)
  156. ) {
  157. return;
  158. }
  159. if (this.model.get('chat_state') !== _converse.COMPOSING) {
  160. // Set chat state to composing if keyCode is not a forward-slash
  161. // (which would imply an internal command and not a message).
  162. this.model.setChatState(_converse.COMPOSING);
  163. }
  164. }
  165. async onFormSubmitted (ev) {
  166. ev?.preventDefault?.();
  167. const textarea = this.querySelector('.chat-textarea');
  168. const message_text = textarea.value.trim();
  169. if (
  170. (api.settings.get('message_limit') && message_text.length > api.settings.get('message_limit')) ||
  171. !message_text.replace(/\s/g, '').length
  172. ) {
  173. return;
  174. }
  175. if (!_converse.connection.authenticated) {
  176. const err_msg = __('Sorry, the connection has been lost, and your message could not be sent');
  177. api.alert('error', __('Error'), err_msg);
  178. api.connection.reconnect();
  179. return;
  180. }
  181. let spoiler_hint,
  182. hint_el = {};
  183. if (this.model.get('composing_spoiler')) {
  184. hint_el = this.querySelector('form.sendXMPPMessage input.spoiler-hint');
  185. spoiler_hint = hint_el.value;
  186. }
  187. u.addClass('disabled', textarea);
  188. textarea.setAttribute('disabled', 'disabled');
  189. this.querySelector('converse-emoji-dropdown')?.hideMenu();
  190. const is_command = await parseMessageForCommands(this.model, message_text);
  191. const message = is_command ? null : await this.model.sendMessage({'body': message_text, spoiler_hint});
  192. if (is_command || message) {
  193. hint_el.value = '';
  194. textarea.value = '';
  195. u.removeClass('correcting', textarea);
  196. textarea.style.height = 'auto';
  197. this.model.set({'draft': ''});
  198. }
  199. if (api.settings.get('view_mode') === 'overlayed') {
  200. // XXX: Chrome flexbug workaround. The .chat-content area
  201. // doesn't resize when the textarea is resized to its original size.
  202. const chatview = _converse.chatboxviews.get(this.getAttribute('jid'));
  203. const msgs_container = chatview.querySelector('.chat-content__messages');
  204. msgs_container.parentElement.style.display = 'none';
  205. }
  206. textarea.removeAttribute('disabled');
  207. u.removeClass('disabled', textarea);
  208. if (api.settings.get('view_mode') === 'overlayed') {
  209. // XXX: Chrome flexbug workaround.
  210. const chatview = _converse.chatboxviews.get(this.getAttribute('jid'));
  211. const msgs_container = chatview.querySelector('.chat-content__messages');
  212. msgs_container.parentElement.style.display = '';
  213. }
  214. // Suppress events, otherwise superfluous CSN gets set
  215. // immediately after the message, causing rate-limiting issues.
  216. this.model.setChatState(_converse.ACTIVE, { 'silent': true });
  217. textarea.focus();
  218. }
  219. }
  220. api.elements.define('converse-message-form', MessageForm);