UNPKG

stream-chat-react

Version:

React components to create chat conversations or livestream style chat

594 lines (593 loc) 27.5 kB
import React from 'react'; import PropTypes from 'prop-types'; import Textarea from 'react-textarea-autosize'; import getCaretCoordinates from 'textarea-caret'; import clsx from 'clsx'; import { List as DefaultSuggestionList } from './List'; import { DEFAULT_CARET_POSITION, defaultScrollToItem, errorMessage, triggerPropsCheck, } from './utils'; import { CommandItem } from '../CommandItem'; import { UserItem } from '../UserItem'; import { isSafari } from '../../utils/browsers'; export class ReactTextareaAutocomplete extends React.Component { constructor(props) { super(props); // FIXME: unused method this.getSelectionPosition = () => { if (!this.textareaRef) return null; return { selectionEnd: this.textareaRef.selectionEnd, selectionStart: this.textareaRef.selectionStart, }; }; // FIXME: unused method this.getSelectedText = () => { if (!this.textareaRef) return null; const { selectionEnd, selectionStart } = this.textareaRef; if (selectionStart === selectionEnd) return null; return this.state.value.substr(selectionStart, selectionEnd - selectionStart); }; this.setCaretPosition = (position = 0) => { if (!this.textareaRef) return; this.textareaRef.focus(); this.textareaRef.setSelectionRange(position, position); }; this.getCaretPosition = () => { if (!this.textareaRef) return 0; return this.textareaRef.selectionEnd; }; /** * isComposing prevents double submissions in Korean and other languages. * starting point for a read: * https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/isComposing * In the long term, the fix should happen by handling keypress, but changing this has unknown implications. * @param event React.KeyboardEvent */ this._defaultShouldSubmit = (event) => event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing; this._handleKeyDown = (event) => { const { shouldSubmit = this._defaultShouldSubmit } = this.props; // prevent default behaviour when the selection list is rendered if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && this.dropdownRef) event.preventDefault(); if (shouldSubmit?.(event)) return this._onEnter(event); if (event.key === ' ') return this._onSpace(event); if (event.key === 'Escape') return this._closeAutocomplete(); }; this._onEnter = async (event) => { if (!this.textareaRef) return; const trigger = this.state.currentTrigger; if (!trigger || !this.state.data) { // https://legacy.reactjs.org/docs/legacy-event-pooling.html event.persist(); // trigger a submit await this._replaceWord(); if (this.textareaRef) { this.textareaRef.selectionEnd = 0; } this.props.handleSubmit(event); this._closeAutocomplete(); } }; this._onSpace = () => { if (!this.props.replaceWord || !this.textareaRef) return; // don't change characters if the element doesn't have focus const hasFocus = this.textareaRef.matches(':focus'); if (!hasFocus) return; this._replaceWord(); }; this._replaceWord = async () => { const { value } = this.state; const lastWordRegex = /([^\s]+)(\s*)$/; const match = lastWordRegex.exec(value.slice(0, this.getCaretPosition())); const lastWord = match && match[1]; if (!lastWord) return; const spaces = match[2]; const newWord = await this.props.replaceWord(lastWord); if (newWord == null) return; const textBeforeWord = value.slice(0, this.getCaretPosition() - match[0].length); const textAfterCaret = value.slice(this.getCaretPosition(), -1); const newText = textBeforeWord + newWord + spaces + textAfterCaret; this.setState({ value: newText, }, () => { // fire onChange event after successful selection const e = new CustomEvent('change', { bubbles: true }); this.textareaRef.dispatchEvent(e); if (this.props.onChange) this.props.onChange(e); }); }; this._onSelect = (newToken) => { const { closeCommandsList, closeMentionsList, onChange, showCommandsList, showMentionsList, } = this.props; const { currentTrigger: stateTrigger, selectionEnd, value: textareaValue, } = this.state; const currentTrigger = showCommandsList ? '/' : showMentionsList ? '@' : stateTrigger; if (!currentTrigger) return; const computeCaretPosition = (position, token, startToken) => { switch (position) { case 'start': return startToken; case 'next': case 'end': return startToken + token.length; default: if (!Number.isInteger(position)) { throw new Error('RTA: caretPosition should be "start", "next", "end" or number.'); } return position; } }; const textToModify = showCommandsList ? '/' : showMentionsList ? '@' : textareaValue.slice(0, selectionEnd); const startOfTokenPosition = textToModify.lastIndexOf(currentTrigger); // we add space after emoji is selected if a caret position is next const newTokenString = newToken.caretPosition === 'next' ? `${newToken.text} ` : newToken.text; const newCaretPosition = computeCaretPosition(newToken.caretPosition, newTokenString, startOfTokenPosition); const modifiedText = textToModify.substring(0, startOfTokenPosition) + newTokenString; const valueToReplace = textareaValue.replace(textToModify, modifiedText); // set the new textarea value and after that set the caret back to its position this.setState({ dataLoading: false, value: valueToReplace, }, () => { // fire onChange event after successful selection const e = new CustomEvent('change', { bubbles: true }); this.textareaRef.dispatchEvent(e); if (onChange) onChange(e); this.setCaretPosition(newCaretPosition); }); this._closeAutocomplete(); if (showCommandsList) closeCommandsList(); if (showMentionsList) closeMentionsList(); }; this._getItemOnSelect = (paramTrigger) => { const { currentTrigger: stateTrigger } = this.state; const triggerSettings = this._getCurrentTriggerSettings(paramTrigger); const currentTrigger = paramTrigger || stateTrigger; if (!currentTrigger || !triggerSettings) return null; const { callback } = triggerSettings; if (!callback) return null; return (item) => { if (typeof callback !== 'function') { throw new Error('Output functor is not defined! You have to define "output" function. https://github.com/webscopeio/react-textarea-autocomplete#trigger-type'); } if (callback) { return callback(item, currentTrigger); } return null; }; }; this._getTextToReplace = (paramTrigger) => { const { actualToken, currentTrigger: stateTrigger } = this.state; const triggerSettings = this._getCurrentTriggerSettings(paramTrigger); const currentTrigger = paramTrigger || stateTrigger; if (!currentTrigger || !triggerSettings) return null; const { output } = triggerSettings; return (item) => { if (typeof item === 'object' && (!output || typeof output !== 'function')) { throw new Error('Output functor is not defined! If you are using items as object you have to define "output" function. https://github.com/webscopeio/react-textarea-autocomplete#trigger-type'); } if (output) { const textToReplace = output(item, currentTrigger); if (!textToReplace || typeof textToReplace === 'number') { throw new Error(`Output functor should return string or object in shape {text: string, caretPosition: string | number}.\nGot "${String(textToReplace)}". Check the implementation for trigger "${currentTrigger}" and its token "${actualToken}"\n\nSee https://github.com/webscopeio/react-textarea-autocomplete#trigger-type for more informations.\n`); } if (typeof textToReplace === 'string') { return { caretPosition: DEFAULT_CARET_POSITION, text: textToReplace, }; } if (!textToReplace.text && currentTrigger !== ':') { throw new Error(`Output "text" is not defined! Object should has shape {text: string, caretPosition: string | number}. Check the implementation for trigger "${currentTrigger}" and its token "${actualToken}"\n`); } if (!textToReplace.caretPosition) { throw new Error(`Output "caretPosition" is not defined! Object should has shape {text: string, caretPosition: string | number}. Check the implementation for trigger "${currentTrigger}" and its token "${actualToken}"\n`); } return textToReplace; } if (typeof item !== 'string') { throw new Error('Output item should be string\n'); } return { caretPosition: DEFAULT_CARET_POSITION, text: `${currentTrigger}${item}${currentTrigger}`, }; }; }; this._getCurrentTriggerSettings = (paramTrigger) => { const { currentTrigger: stateTrigger } = this.state; const currentTrigger = paramTrigger || stateTrigger; if (!currentTrigger) return null; return this.props.trigger[currentTrigger]; }; this._getValuesFromProvider = () => { const { actualToken, currentTrigger } = this.state; const triggerSettings = this._getCurrentTriggerSettings(); if (!currentTrigger || !triggerSettings) return; const { component, dataProvider } = triggerSettings; if (typeof dataProvider !== 'function') { throw new Error('Trigger provider has to be a function!'); } this.setState({ dataLoading: true }); // Modified: send the full text to support / style commands dataProvider(actualToken, this.state.value, (data, token) => { // Make sure that the result is still relevant for current query if (token !== this.state.actualToken) return; if (!Array.isArray(data)) { throw new Error('Trigger provider has to provide an array!'); } // throw away if we resolved old trigger if (currentTrigger !== this.state.currentTrigger) return; // if we haven't resolved any data let's close the autocomplete if (!data.length) { this._closeAutocomplete(); return; } this.setState({ component, data, dataLoading: false, }); }); }; this._getSuggestions = (paramTrigger) => { const { currentTrigger: stateTrigger, data } = this.state; const currentTrigger = paramTrigger || stateTrigger; if (!currentTrigger || !data || (data && !data.length)) return null; return data; }; /** * Close autocomplete, also clean up trigger (to avoid slow promises) */ this._closeAutocomplete = () => { this.setState({ currentTrigger: null, data: null, dataLoading: false, left: null, top: null, }); }; this._cleanUpProps = () => { const props = { ...this.props }; const notSafe = [ 'additionalTextareaProps', 'className', 'closeCommandsList', 'closeMentionsList', 'closeOnClickOutside', 'containerClassName', 'containerStyle', 'disableMentions', 'dropdownClassName', 'dropdownStyle', 'grow', 'handleSubmit', 'innerRef', 'itemClassName', 'itemStyle', 'listClassName', 'listStyle', 'loaderClassName', 'loaderStyle', 'loadingComponent', 'minChar', 'movePopupAsYouType', 'onCaretPositionChange', 'onChange', 'ref', 'replaceWord', 'scrollToItem', 'shouldSubmit', 'showCommandsList', 'showMentionsList', 'SuggestionItem', 'SuggestionList', 'trigger', 'value', ]; for (const prop in props) { if (notSafe.includes(prop)) delete props[prop]; } return props; }; this._isCommand = (text) => { if (text[0] !== '/') return false; const tokens = text.split(' '); return tokens.length <= 1; }; this._changeHandler = (e) => { const { minChar, movePopupAsYouType, onCaretPositionChange, onChange, trigger } = this.props; const { left, top } = this.state; const textarea = e.target; const { selectionEnd, selectionStart, value } = textarea; if (onChange) { e.persist(); onChange(e); } if (onCaretPositionChange) onCaretPositionChange(this.getCaretPosition()); this.setState({ value }); let currentTrigger; let lastToken; if (this._isCommand(value)) { currentTrigger = '/'; lastToken = value; } else { const triggerTokens = Object.keys(trigger).join().replace('/', ''); const triggerNorWhitespace = `[^\\s${triggerTokens}]*`; const regex = new RegExp(`(?!^|\\W)?[${triggerTokens}]${triggerNorWhitespace}\\s?${triggerNorWhitespace}$`, 'g'); const tokenMatch = value.slice(0, selectionEnd).match(regex); lastToken = tokenMatch && tokenMatch[tokenMatch.length - 1].trim(); currentTrigger = (lastToken && Object.keys(trigger).find((a) => a === lastToken[0])) || null; } /* if we lost the trigger token or there is no following character we want to close the autocomplete */ if (!lastToken || lastToken.length <= minChar) { this._closeAutocomplete(); return; } const actualToken = lastToken.slice(1); // if trigger is not configured step out from the function, otherwise proceed if (!currentTrigger) return; if (movePopupAsYouType || (top === null && left === null) || // if we have single char - trigger it means we want to re-position the autocomplete lastToken.length === 1) { const { left: newLeft, top: newTop } = getCaretCoordinates(textarea, selectionEnd); this.setState({ // make position relative to textarea left: newLeft, top: newTop - this.textareaRef.scrollTop || 0, }); } this.setState({ actualToken, currentTrigger, selectionEnd, selectionStart, }, () => { try { this._getValuesFromProvider(); } catch (err) { errorMessage(err.message); } }); }; this._selectHandler = (e) => { const { onCaretPositionChange, onSelect } = this.props; if (onCaretPositionChange) onCaretPositionChange(this.getCaretPosition()); if (onSelect) { e.persist(); onSelect(e); } }; // The textarea itself is outside the auto-select dropdown. this._onClickAndBlurHandler = (e) => { const { closeOnClickOutside, onBlur } = this.props; // If this is a click: e.target is the textarea, and e.relatedTarget is the thing // that was actually clicked. If we clicked inside the auto-select dropdown, then // that's not a blur, from the auto-select point of view, so then do nothing. const el = e.relatedTarget; // If this is a blur event in Safari, then relatedTarget is never a dropdown item, but a common parent // of textarea and dropdown container. That means that dropdownRef will not contain its parent and the // autocomplete will be closed before onclick handler can be invoked selecting an item. // It seems that Safari has different implementation determining the relatedTarget node than Chrome and Firefox. // Therefore, if focused away in Safari, the dropdown will be kept rendered until pressing Esc or selecting and item from it. const focusedAwayInSafari = isSafari() && e.type === 'blur'; if ((this.dropdownRef && el instanceof Node && this.dropdownRef.contains(el)) || focusedAwayInSafari) { return; } if (closeOnClickOutside) this._closeAutocomplete(); if (onBlur) { e.persist(); onBlur(e); } }; this._onScrollHandler = () => this._closeAutocomplete(); this._dropdownScroll = (item) => { const { scrollToItem } = this.props; if (!scrollToItem) return; if (scrollToItem === true) { defaultScrollToItem(this.dropdownRef, item); return; } if (typeof scrollToItem !== 'function' || scrollToItem.length !== 2) { throw new Error('`scrollToItem` has to be boolean (true for default implementation) or function with two parameters: container, item.'); } scrollToItem(this.dropdownRef, item); }; this.getTriggerProps = () => { const { showCommandsList, showMentionsList, trigger } = this.props; const { component, currentTrigger, selectionEnd, value } = this.state; const selectedItem = this._getItemOnSelect(); const suggestionData = this._getSuggestions(); const textToReplace = this._getTextToReplace(); const triggerProps = { component, currentTrigger, getSelectedItem: selectedItem, getTextToReplace: textToReplace, selectionEnd, value, values: suggestionData, }; if ((showCommandsList && trigger['/']) || (showMentionsList && trigger['@'])) { let currentCommands; const getCommands = trigger[showCommandsList ? '/' : '@'].dataProvider; getCommands?.('', showCommandsList ? '/' : '@', (data) => { currentCommands = data; }); triggerProps.component = showCommandsList ? CommandItem : UserItem; triggerProps.currentTrigger = showCommandsList ? '/' : '@'; triggerProps.getTextToReplace = this._getTextToReplace(showCommandsList ? '/' : '@'); triggerProps.getSelectedItem = this._getItemOnSelect(showCommandsList ? '/' : '@'); triggerProps.selectionEnd = 1; triggerProps.value = showCommandsList ? '/' : '@'; triggerProps.values = currentCommands; } return triggerProps; }; this.setDropdownRef = (element) => { this.dropdownRef = element; }; const { loadingComponent, trigger, value } = this.props; // TODO: it would be better to have the parent control state... // if (value) this.state.value = value; if (!loadingComponent) { throw new Error('RTA: loadingComponent is not defined'); } if (!trigger) { throw new Error('RTA: trigger is not defined'); } this.state = { actualToken: '', component: null, currentTrigger: null, data: null, dataLoading: false, isComposing: false, left: null, selectionEnd: 0, selectionStart: 0, top: null, value: value || '', }; } /** * setup to emulate the UNSAFE_componentWillReceiveProps */ static getDerivedStateFromProps(props, state) { if (props.value !== state.propsValue || !state.value) { return { propsValue: props.value, value: props.value }; } else { return null; } } renderSuggestionListContainer() { const { disableMentions, dropdownClassName, dropdownStyle, itemClassName, itemStyle, listClassName, SuggestionItem, SuggestionList = DefaultSuggestionList, } = this.props; const { isComposing } = this.state; const triggerProps = this.getTriggerProps(); if (isComposing || !triggerProps.values || !triggerProps.currentTrigger || (disableMentions && triggerProps.currentTrigger === '@')) return null; return (React.createElement("div", { className: clsx('str-chat__suggestion-list-container', dropdownClassName), ref: this.setDropdownRef, style: dropdownStyle }, React.createElement(SuggestionList, { className: listClassName, dropdownScroll: this._dropdownScroll, itemClassName: clsx('str-chat__suggestion-list-item', itemClassName), itemStyle: itemStyle, onSelect: this._onSelect, SuggestionItem: SuggestionItem, ...triggerProps }))); } render() { const { className, containerClassName, containerStyle, style } = this.props; const { onBlur, onChange, onClick, onFocus, onKeyDown, onScroll, onSelect, ...restAdditionalTextareaProps } = this.props.additionalTextareaProps || {}; let { maxRows } = this.props; const { dataLoading, value } = this.state; if (!this.props.grow) maxRows = 1; // By setting defaultValue to undefined, avoid error: // ForwardRef(TextareaAutosize) contains a textarea with both value and defaultValue props. // Textarea elements must be either controlled or uncontrolled return (React.createElement("div", { className: clsx('rta', containerClassName, { ['rta--loading']: dataLoading, }), style: containerStyle }, this.renderSuggestionListContainer(), React.createElement(Textarea, { "data-testid": 'message-input', ...this._cleanUpProps(), className: clsx('rta__textarea', className), maxRows: maxRows, onBlur: (e) => { this._onClickAndBlurHandler(e); onBlur?.(e); }, onChange: (e) => { this._changeHandler(e); onChange?.(e); }, onClick: (e) => { this._onClickAndBlurHandler(e); onClick?.(e); }, onCompositionEnd: () => this.setState((pv) => ({ ...pv, isComposing: false })), onCompositionStart: () => this.setState((pv) => ({ ...pv, isComposing: true })), onFocus: (e) => { this.props.onFocus?.(e); onFocus?.(e); }, onKeyDown: (e) => { this._handleKeyDown(e); onKeyDown?.(e); }, onScroll: (e) => { this._onScrollHandler(e); onScroll?.(e); }, onSelect: (e) => { this._selectHandler(e); onSelect?.(e); }, ref: (ref) => { this.props?.innerRef(ref); this.textareaRef = ref; }, style: style, value: value, ...restAdditionalTextareaProps, defaultValue: undefined }))); } } ReactTextareaAutocomplete.defaultProps = { closeOnClickOutside: true, maxRows: 10, minChar: 1, movePopupAsYouType: false, scrollToItem: true, value: '', }; ReactTextareaAutocomplete.propTypes = { className: PropTypes.string, closeOnClickOutside: PropTypes.bool, containerClassName: PropTypes.string, containerStyle: PropTypes.object, disableMentions: PropTypes.bool, dropdownClassName: PropTypes.string, dropdownStyle: PropTypes.object, itemClassName: PropTypes.string, itemStyle: PropTypes.object, listClassName: PropTypes.string, listStyle: PropTypes.object, loaderClassName: PropTypes.string, loaderStyle: PropTypes.object, loadingComponent: PropTypes.elementType, minChar: PropTypes.number, onBlur: PropTypes.func, onCaretPositionChange: PropTypes.func, onChange: PropTypes.func, onSelect: PropTypes.func, shouldSubmit: PropTypes.func, style: PropTypes.object, SuggestionList: PropTypes.elementType, trigger: triggerPropsCheck, value: PropTypes.string, };