import React, { ReactElement, ComponentType, useRef, useState, useEffect } from 'react'; import { StyleSheet, Text, View, StyleProp, TextStyle, TextInputSelectionChangeEventData, NativeSyntheticEvent, TextInputKeyPressEventData, } from 'react-native'; import { Mention, Mentions, Message, Selection } from './types'; import { deserialize, serialize, getAffectedMentionIndexes, stringReplace, arrayInsertAt } from './utils'; import { useTheme } from '../ThemeContext'; import TextInput from '../TextInput'; const isEmptyString = (str: string): boolean => str.length === 0; const TRIGGER = '@'; type Props = { readonly testID?: string; readonly placeholder?: string; readonly value: Message; readonly onChange: (message: Message) => void; readonly onFocus?: () => void; readonly onBlur?: () => void; readonly renderSuggestionList: ( searchValue: string, handleSuggestionPress: (id: string, name: string) => void, ) => ReactElement; readonly rightIcon?: string; readonly disabled?: boolean; readonly error?: string; readonly autoFocus?: boolean; readonly highlightTextStyle?: StyleProp; }; const MentionTextInput: ComponentType = ({ testID, placeholder, value, onChange, onFocus, onBlur, renderSuggestionList, rightIcon, disabled, error = '', autoFocus, highlightTextStyle = {}, }) => { const theme = useTheme(); const valueTextRef = useRef(''); const mentionsRef = useRef([]); const searchStartPositionRef = useRef(0); const previousSelectionRef = useRef([0, 0]); const [showSuggestions, setShowSugesstions] = useState(false); const [searchValue, setSearchValue] = useState(''); let eventKey: string | undefined = undefined; let eventText: string | undefined = undefined; let eventSelection: Selection | undefined = undefined; useEffect(() => { const [valueText, mentions] = deserialize(value); valueTextRef.current = valueText; mentionsRef.current = mentions; }, [value]); const handleChange = () => { if (eventKey === undefined && eventText === undefined && eventSelection !== undefined) { setShowSugesstions(false); previousSelectionRef.current = eventSelection; return; } /* * if user presses Backspace when the value is empty already, handleChange * will be called with the Backspace key, lead to wrong calculation. * Detect this case and reset eventKey to None */ if (eventKey === 'Backspace' && eventText === undefined && eventSelection === undefined) { if (isEmptyString(valueTextRef.current)) { eventKey = undefined; } return; } if (eventKey !== undefined && eventText !== undefined && eventSelection !== undefined) { const valueText = valueTextRef.current; const mentions = mentionsRef.current; const previousSelections = previousSelectionRef.current; const [currentPosition] = eventSelection; const textDiff = eventText.length - valueText.length; const [selectedIndexes, unselectedIndexes] = getAffectedMentionIndexes(previousSelections, mentions); unselectedIndexes.forEach(index => { const unselectedMention = mentions[index]; const [startOffset, endOffset] = unselectedMention.offset; unselectedMention.offset = [startOffset + textDiff, endOffset + textDiff]; }); selectedIndexes.reverse().forEach(index => { mentions.splice(index, 1); }); if (!showSuggestions) { if (eventKey === TRIGGER) { setSearchValue(''); setShowSugesstions(true); searchStartPositionRef.current = currentPosition; } } if (showSuggestions) { const searchStartPosition = searchStartPositionRef.current; const searchValue = eventText.substring(searchStartPosition, currentPosition); if (eventKey === ' ' || eventKey === 'Enter' || searchValue === '') { setShowSugesstions(false); } else { setSearchValue(searchValue); } } /* * due to async setState, shifting offsets may be incorrect if user types super fast. * Setting refs to keep them up to date to prevent this bug */ valueTextRef.current = eventText; mentionsRef.current = mentions; onChange(serialize(eventText, mentions)); previousSelectionRef.current = eventSelection; return; } }; const handleSuggestionPress = (id: string, name: string) => { const valueText = valueTextRef.current; const mentions = mentionsRef.current; const searchStartPosition = searchStartPositionRef.current; const previousSelection = previousSelectionRef.current; const insertText = name + ' '; const textDiff = insertText.length; const [currentPosition] = previousSelection; const [, unselectedIndexes] = getAffectedMentionIndexes(previousSelection, mentions); const insertPos = unselectedIndexes[0] || mentions.length; const newMention: Mention = { id, name: TRIGGER + name, offset: [searchStartPosition - 1, searchStartPosition + textDiff], }; unselectedIndexes.forEach(index => { const unselectdMention = mentions[index]; const [startOffset, endOffset] = unselectdMention.offset; unselectdMention.offset = [startOffset + textDiff, endOffset + textDiff]; }); const newValueText = stringReplace(searchStartPosition, currentPosition, insertText, valueText); const newMentions = arrayInsertAt(insertPos, newMention, mentions); onChange(serialize(newValueText, newMentions)); /* * in Samsung, selectionChange is triggered AFTER the next render, which * causes eventSelection is set before eventKey and eventSelection. This * leads to the bug of suggestion list is hidden immediately after showing. * Call setShowSuggestions reactively will prevent this bug. */ /* setShowSuggestions(false); */ }; const handleSelectionChange = (event: NativeSyntheticEvent) => { const selection = event.nativeEvent.selection; eventSelection = [selection.start, selection.end]; handleChange(); }; const handleKeyPress = (event: NativeSyntheticEvent) => { const key = event.nativeEvent.key; eventKey = key; handleChange(); }; const handleChangeText = text => { eventText = text; handleChange(); }; return ( {value.map((textBlock, index) => { if (textBlock.ref === undefined) { return {textBlock.text}; } return ( {textBlock.text} ); })} {showSuggestions ? ( {renderSuggestionList(searchValue, handleSuggestionPress)} ) : null} ); }; export default MentionTextInput;