import { h, Component, createRef } from '/js/web_modules/preact.js'; import htm from '/js/web_modules/htm.js'; const html = htm.bind(h); import { EmojiButton } from '/js/web_modules/@joeattardi/emoji-button.js'; import ContentEditable, { replaceCaret } from './content-editable.js'; import { generatePlaceholderText, getCaretPosition, convertToText, convertOnPaste, createEmojiMarkup, trimNbsp, emojify, } from '../../utils/chat.js'; import { getLocalStorage, setLocalStorage, classNames, } from '../../utils/helpers.js'; import { URL_CUSTOM_EMOJIS, KEY_CHAT_FIRST_MESSAGE_SENT, CHAT_CHAR_COUNT_BUFFER, CHAT_OK_KEYCODES, CHAT_KEY_MODIFIERS, } from '../../utils/constants.js'; export default class ChatInput extends Component { constructor(props, context) { super(props, context); this.formMessageInput = createRef(); this.emojiPickerButton = createRef(); this.messageCharCount = 0; this.prepNewLine = false; this.modifierKeyPressed = false; // control/meta/shift/alt this.state = { inputHTML: '', inputCharsLeft: props.inputMaxBytes, hasSentFirstChatMessage: getLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT), emojiPicker: null, emojiList: null, }; this.handleEmojiButtonClick = this.handleEmojiButtonClick.bind(this); this.handleEmojiSelected = this.handleEmojiSelected.bind(this); this.getCustomEmojis = this.getCustomEmojis.bind(this); this.handleMessageInputKeydown = this.handleMessageInputKeydown.bind(this); this.handleMessageInputKeyup = this.handleMessageInputKeyup.bind(this); this.handleMessageInputBlur = this.handleMessageInputBlur.bind(this); this.handleSubmitChatButton = this.handleSubmitChatButton.bind(this); this.handlePaste = this.handlePaste.bind(this); this.handleContentEditableChange = this.handleContentEditableChange.bind(this); } componentDidMount() { this.getCustomEmojis(); } getCustomEmojis() { fetch(URL_CUSTOM_EMOJIS) .then((response) => { if (!response.ok) { throw new Error(`Network response was not ok ${response.ok}`); } return response.json(); }) .then((json) => { const emojiList = json; const emojiPicker = new EmojiButton({ zIndex: 100, theme: 'owncast', // see chat.css custom: json, initialCategory: 'custom', showPreview: false, autoHide: false, autoFocusSearch: false, showAnimation: false, emojiSize: '24px', position: 'right-start', strategy: 'absolute', }); emojiPicker.on('emoji', (emoji) => { this.handleEmojiSelected(emoji); }); emojiPicker.on('hidden', () => { this.formMessageInput.current.focus(); replaceCaret(this.formMessageInput.current); }); this.setState({ emojiList, emojiPicker }); }) .catch((error) => { // this.handleNetworkingError(`Emoji Fetch: ${error}`); }); } handleEmojiButtonClick() { const { emojiPicker } = this.state; if (emojiPicker) { emojiPicker.togglePicker(this.emojiPickerButton.current); } } handleEmojiSelected(emoji) { const { inputHTML, inputCharsLeft } = this.state; // if we're already at char limit, don't do anything if (inputCharsLeft < 0) { return; } let content = ''; if (emoji.url) { content = createEmojiMarkup(emoji, false); } else { content = emoji.emoji; } const newHTML = inputHTML + content; const charsLeft = this.calculateCurrentBytesLeft(newHTML); this.setState({ inputHTML: inputHTML + content, inputCharsLeft: charsLeft, }); // a hacky way add focus back into input field setTimeout(() => { const input = this.formMessageInput.current; input.focus(); replaceCaret(input); }, 100); } // autocomplete user names autoCompleteNames() { const { chatUserNames } = this.props; const { inputHTML } = this.state; const position = getCaretPosition(this.formMessageInput.current); const at = inputHTML.lastIndexOf('@', position - 1); if (at === -1) { return false; } let partial = inputHTML.substring(at + 1, position).trim(); if (partial === this.suggestion) { partial = this.partial; } else { this.partial = partial; } const possibilities = chatUserNames.filter(function (username) { return username.toLowerCase().startsWith(partial.toLowerCase()); }); if ( this.completionIndex === undefined || ++this.completionIndex >= possibilities.length ) { this.completionIndex = 0; } if (possibilities.length > 0) { this.suggestion = possibilities[this.completionIndex]; const newHTML = inputHTML.substring(0, at + 1) + this.suggestion + ' ' + inputHTML.substring(position); this.setState({ inputHTML: newHTML, inputCharsLeft: this.calculateCurrentBytesLeft(newHTML), }); } return true; } // replace :emoji: with the emoji injectEmoji() { const { inputHTML, emojiList } = this.state; const textValue = convertToText(inputHTML); const processedHTML = emojify(inputHTML, emojiList); if (textValue != convertToText(processedHTML)) { this.setState({ inputHTML: processedHTML, }); return true; } return false; } handleMessageInputKeydown(event) { const key = event && event.key; if (key === 'Enter') { if (!this.prepNewLine) { this.sendMessage(); event.preventDefault(); this.prepNewLine = false; return; } } // allow key presses such as command/shift/meta, etc even when message length is full later. if (CHAT_KEY_MODIFIERS.includes(key)) { this.modifierKeyPressed = true; } if (key === 'Control' || key === 'Shift') { this.prepNewLine = true; } if (key === 'Tab') { if (this.autoCompleteNames()) { event.preventDefault(); } } // if new input pushes the potential chars over, don't do anything const formField = this.formMessageInput.current; const tempCharsLeft = this.calculateCurrentBytesLeft(formField.innerHTML); if (tempCharsLeft <= 0 && !CHAT_OK_KEYCODES.includes(key)) { if (!this.modifierKeyPressed) { event.preventDefault(); // prevent typing more } return; } } handleMessageInputKeyup(event) { const { key } = event; if (key === 'Control' || key === 'Shift') { this.prepNewLine = false; } if (CHAT_KEY_MODIFIERS.includes(key)) { this.modifierKeyPressed = false; } if (key === ':' || key === ';') { this.injectEmoji(); } this.setState({ inputCharsLeft: CHAT_MAX_MESSAGE_LENGTH - textValue.length, }); } handleMessageInputBlur() { this.prepNewLine = false; this.modifierKeyPressed = false; } handlePaste(event) { // don't allow paste if too much text already if (this.state.inputCharsLeft < 0) { event.preventDefault(); return; } convertOnPaste(event, this.state.emojiList); this.handleMessageInputKeydown(event); } handleSubmitChatButton(event) { event.preventDefault(); this.sendMessage(); } sendMessage() { const { handleSendMessage, inputMaxBytes } = this.props; const { hasSentFirstChatMessage, inputHTML, inputCharsLeft } = this.state; if (inputCharsLeft < 0) { return; } const message = convertToText(inputHTML); const newStates = { inputHTML: '', inputCharsLeft: inputMaxBytes, }; handleSendMessage(message); if (!hasSentFirstChatMessage) { newStates.hasSentFirstChatMessage = true; setLocalStorage(KEY_CHAT_FIRST_MESSAGE_SENT, true); } // clear things out. this.setState(newStates); } handleContentEditableChange(event) { const value = event.target.value; this.setState({ inputHTML: value, inputCharsLeft: this.calculateCurrentBytesLeft(value), }); } calculateCurrentBytesLeft(inputContent) { const { inputMaxBytes } = this.props; const curBytes = new Blob([trimNbsp(inputContent)]).size; return inputMaxBytes - curBytes; } render(props, state) { const { hasSentFirstChatMessage, inputCharsLeft, inputHTML, emojiPicker } = state; const { inputEnabled, inputMaxBytes } = props; const emojiButtonStyle = { display: emojiPicker && inputCharsLeft > 0 ? 'block' : 'none', }; const extraClasses = classNames({ 'display-count': inputCharsLeft <= CHAT_CHAR_COUNT_BUFFER, }); const placeholderText = generatePlaceholderText( inputEnabled, hasSentFirstChatMessage ); return html`
<${ContentEditable} id="message-input" class="appearance-none block w-full bg-transparent text-sm text-gray-700 h-full focus:outline-none" placeholderText=${placeholderText} innerRef=${this.formMessageInput} html=${inputHTML} disabled=${!inputEnabled} onChange=${this.handleContentEditableChange} onKeyDown=${this.handleMessageInputKeydown} onKeyUp=${this.handleMessageInputKeyup} onBlur=${this.handleMessageInputBlur} onPaste=${this.handlePaste} />
${inputCharsLeft} bytes
`; } }