UNPKG

stream-chat-react

Version:

React components to create chat conversations or livestream style chat

188 lines (187 loc) 9.2 kB
import clsx from 'clsx'; import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import Textarea from 'react-textarea-autosize'; import { useMessageComposer } from '../MessageInput'; import { useComponentContext, useMessageInputContext, useTranslationContext, } from '../../context'; import { useStateStore } from '../../store'; import { SuggestionList as DefaultSuggestionList } from './SuggestionList'; const textComposerStateSelector = (state) => ({ selection: state.selection, suggestions: state.suggestions, text: state.text, }); const searchSourceStateSelector = (state) => ({ isLoadingItems: state.isLoading, items: state.items, }); const configStateSelector = (state) => ({ enabled: state.text.enabled, }); const messageComposerStateSelector = (state) => ({ quotedMessage: state.quotedMessage, }); const attachmentManagerStateSelector = (state) => ({ attachments: state.attachments, }); /** * 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. */ const defaultShouldSubmit = (event) => event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing; export const TextareaComposer = ({ className, closeSuggestionsOnClickOutside, containerClassName, listClassName, maxRows: maxRowsProp, minRows: minRowsProp, onBlur, onChange, onKeyDown, onScroll, onSelect, placeholder: placeholderProp, shouldSubmit: shouldSubmitProp, ...restTextareaProps }) => { const { t } = useTranslationContext(); const { AutocompleteSuggestionList = DefaultSuggestionList } = useComponentContext(); const { additionalTextareaProps, cooldownRemaining, focus, handleSubmit, maxRows: maxRowsContext, minRows: minRowsContext, onPaste, shouldSubmit: shouldSubmitContext, textareaRef, } = useMessageInputContext(); const maxRows = maxRowsProp ?? maxRowsContext ?? 1; const minRows = minRowsProp ?? minRowsContext; const placeholder = placeholderProp ?? additionalTextareaProps?.placeholder; const shouldSubmit = shouldSubmitProp ?? shouldSubmitContext ?? defaultShouldSubmit; const messageComposer = useMessageComposer(); const { textComposer } = messageComposer; const { selection, suggestions, text } = useStateStore(textComposer.state, textComposerStateSelector); const { enabled } = useStateStore(messageComposer.configState, configStateSelector); const { quotedMessage } = useStateStore(messageComposer.state, messageComposerStateSelector); const { attachments } = useStateStore(messageComposer.attachmentManager.state, attachmentManagerStateSelector); const { isLoadingItems } = useStateStore(suggestions?.searchSource.state, searchSourceStateSelector) ?? {}; const containerRef = useRef(null); const [focusedItemIndex, setFocusedItemIndex] = useState(0); const [isComposing, setIsComposing] = useState(false); const changeHandler = useCallback((e) => { if (onChange) { onChange(e); return; } if (!textareaRef.current) return; textComposer.handleChange({ selection: { end: textareaRef.current.selectionEnd, start: textareaRef.current.selectionStart, }, text: e.target.value, }); }, [onChange, textComposer, textareaRef]); const onCompositionEnd = useCallback(() => { setIsComposing(false); }, []); const onCompositionStart = useCallback(() => { setIsComposing(true); }, []); const keyDownHandler = useCallback((event) => { if (onKeyDown) { onKeyDown(event); return; } if (textComposer.suggestions && textComposer.suggestions.searchSource.items?.length) { if (event.key === 'Escape') return textComposer.closeSuggestions(); const loadedItems = textComposer.suggestions.searchSource.items; if (event.key === 'Enter') { event.preventDefault(); textComposer.handleSelect(loadedItems[focusedItemIndex]); } if (event.key === 'ArrowUp') { event.preventDefault(); setFocusedItemIndex((prev) => { let nextIndex = prev - 1; if (suggestions?.searchSource.hasNext) { nextIndex = prev; } else if (nextIndex < 0) { nextIndex = loadedItems.length - 1; } return nextIndex; }); } if (event.key === 'ArrowDown') { event.preventDefault(); setFocusedItemIndex((prev) => { let nextIndex = prev + 1; if (suggestions?.searchSource.hasNext) { nextIndex = prev; } else if (nextIndex >= loadedItems.length) { nextIndex = 0; } return nextIndex; }); } } else if (shouldSubmit(event) && textareaRef.current) { if (event.key === 'Enter') { // prevent adding newline when submitting a message with event.preventDefault(); } handleSubmit(); } }, [ focusedItemIndex, handleSubmit, onKeyDown, shouldSubmit, suggestions, textComposer, textareaRef, ]); const scrollHandler = useCallback((event) => { if (onScroll) { onScroll(event); } else { textComposer.closeSuggestions(); } }, [onScroll, textComposer]); const setSelection = useCallback((e) => { onSelect?.(e); textComposer.setSelection({ end: e.target.selectionEnd, start: e.target.selectionStart, }); }, [onSelect, textComposer]); useEffect(() => { if (textComposer.suggestions) { setFocusedItemIndex(0); } }, [textComposer.suggestions]); useEffect(() => { const textareaIsFocused = textareaRef.current?.matches(':focus'); if (!textareaRef.current || textareaIsFocused || !focus) return; textareaRef.current.focus(); }, [attachments, focus, quotedMessage, textareaRef]); useLayoutEffect(() => { /** * It is important to perform set text and after that the range * to prevent cursor reset to the end of the textarea if doing it in separate effects. */ const textarea = textareaRef.current; if (!textarea || isComposing) return; /** * The textarea value has to be overridden outside the render cycle so that the events like compositionend can be triggered. * If we have overridden the value during the component rendering, the compositionend event would not be triggered, and * it would not be possible to type composed characters (ô). * On the other hand, just removing the value override via prop (value={text}) would not allow us to change the text based on * middleware results (e.g. replace characters with emojis) */ if (textarea.value !== text) { textarea.value = text; } const length = textarea.value.length; const start = Math.max(0, Math.min(selection.start, length)); const end = Math.max(start, Math.min(selection.end, length)); if (textarea.selectionStart === start && textarea.selectionEnd === end) return; textarea.setSelectionRange(start, end, 'forward'); }, [text, selection.start, selection.end, isComposing, textareaRef]); return (React.createElement("div", { className: clsx('rta', 'str-chat__textarea str-chat__message-textarea-react-host', containerClassName, { ['rta--loading']: isLoadingItems, }), ref: containerRef }, React.createElement(Textarea, { ...additionalTextareaProps, ...restTextareaProps, "aria-label": cooldownRemaining ? t('Slow Mode ON') : placeholder, className: clsx('rta__textarea', 'str-chat__textarea__textarea str-chat__message-textarea', className), "data-testid": 'message-input', disabled: !enabled || !!cooldownRemaining, maxRows: maxRows, minRows: minRows, onBlur: onBlur, onChange: changeHandler, onCompositionEnd: onCompositionEnd, onCompositionStart: onCompositionStart, onKeyDown: keyDownHandler, onPaste: onPaste, onScroll: scrollHandler, onSelect: setSelection, placeholder: placeholder || t('Type your message'), ref: (ref) => { textareaRef.current = ref; } }), !isComposing && (React.createElement(AutocompleteSuggestionList, { className: listClassName, closeOnClickOutside: closeSuggestionsOnClickOutside, focusedItemIndex: focusedItemIndex, setFocusedItemIndex: setFocusedItemIndex })))); };