import React, {useState, useCallback, useEffect, useRef} from "react"; import PropTypes from "prop-types"; import { ActivityIndicator, Platform, Text, TextInput, View, StyleSheet, TouchableOpacity } from "react-native"; import { typeCheck } from "type-check"; import { isEqual, debounce } from "lodash"; import { useDebounce } from "use-debounce"; const styles = StyleSheet.create({ segments: { flexWrap: "wrap", flexDirection: "row" }, center: { alignItems: "center", justifyContent: "center" }, }); export const PATTERN_MENTION = "(^|\s)@[a-z_\d-]+"; export const PATTERN_HASHTAG = "(^|\s)#[a-z_\d-]+"; const SegmentedTextInput = React.forwardRef( ( { style, textStyle, textInputStyle, invalidTextStyle, segmentContainerStyle, value: [value, segments], onChange, patterns, placeholder, placeholderTextColor, disabled, shouldRenderInvalid, max, onSuggest, suggestionsContainerStyle, minSuggestionLength, debounce: suggestionDebounce, renderSuggestions, multiline, numberOfLines, ...extraProps }, providedRef, ) => { const localRef = useRef(); const ref = providedRef || localRef; const [loadingSuggestions, setLoadingSuggestions] = useState(false); const [suggestions, setSuggestions] = useState([]); const [debouncedLoading] = useDebounce(loadingSuggestions, suggestionDebounce * 0.5); const [debouncedSuggestions, {flush}] = useDebounce(suggestions, suggestionDebounce * 0.5); const [debouncedSuggestion] = useState( () => debounce( async (str) => { try { setLoadingSuggestions(true); setSuggestions(await onSuggest(str)); } catch (e) { console.error(e); } finally { setLoadingSuggestions(false); } }, suggestionDebounce, ), ); const nextSegments = React.useMemo( () => { return ((typeCheck("String", value) && value) || "") .split(/[ ,]+/) .map( str => [ str, Object.keys(patterns) .reduce( (selected, regExp) => (selected || (new RegExp(regExp, "gm").test(str)) && regExp), null, ), ], ); }, [value, patterns], ); // XXX: Latch the final segment (this is text that's in-dev). const [lastSegmentText, isValidLastSegment] = nextSegments[nextSegments.length - 1]; // XXX: Filter all previous segments that didn't match. const existingSegmentText = React.useMemo(() => segments .map(([text]) => text), [segments]); const validSegments = React.useMemo(() => { return [ ...nextSegments .filter(([_, match], i, orig) => (i !== (orig.length - 1) && !!match)) .filter(([text]) => existingSegmentText.indexOf(text) < 0), ]; }, [nextSegments, existingSegmentText]); React.useEffect(() => { lastSegmentText.trim().length === 0 && suggestions.length && setSuggestions([]); }, [lastSegmentText, suggestions, setSuggestions]); // XXX: Prevent duplicates. const onChangeTextCallback = useCallback( (nextValue) => { debouncedSuggestion.cancel(); if (!isEqual(value, nextValue)) { const nextSegments = [...segments, ...validSegments] .filter((e, i, orig) => (orig.indexOf(e) === i)); return onChange([nextValue, nextSegments]); } return undefined; }, [onChange, value, segments, validSegments, debouncedSuggestion], ); const renderLastSegmentAsInvalid = lastSegmentText.length > 0 && (!isValidLastSegment && !!shouldRenderInvalid(lastSegmentText)); const segmentsToRender = React.useMemo(() => { return [...(segments || []), ...validSegments]; }, [segments, validSegments]); const shouldDisable = disabled || (segmentsToRender.length >= max); useEffect( () => { if (shouldDisable) { /* blur if disabled */ debouncedSuggestion.cancel(); setSuggestions([]); ref.current.isFocused() && ref.current.blur(); } return undefined; }, [shouldDisable], ); /* suggestion handling */ useEffect( () => { if (!shouldDisable && !renderLastSegmentAsInvalid && lastSegmentText.length >= minSuggestionLength) { debouncedSuggestion.cancel(); /* request suggestion debounce */ debouncedSuggestion(lastSegmentText); } return undefined; }, [renderLastSegmentAsInvalid, lastSegmentText, minSuggestionLength, debouncedSuggestion], ); useEffect( () => { if (!isEqual(segmentsToRender, segments)) { onChange([lastSegmentText, segmentsToRender]); } }, [segmentsToRender, segments, onChange], ); const renderedSegments = React.useMemo(() => { return segmentsToRender.map(([str, regexp], i) => { const Component = patterns[regexp] || React.Fragment; return ( <Component key={str} style={textStyle} children={str} onRequestDelete={() => { const filteredSegments = segmentsToRender.filter( ([t]) => t !== str ); onChange([lastSegmentText, filteredSegments]); ref.current.focus(); /* refocus the field */ }} /> ); }); }, [patterns, segmentsToRender, lastSegmentText, onChange]); const onKeyPress = React.useCallback((e) => { const { nativeEvent: { key: keyValue }, } = e; /* delete old segments */ if (lastSegmentText.length === 0 && segmentsToRender.length > 0) { if (keyValue === "Backspace") { //debouncedSuggestion.cancel(); onChange([ lastSegmentText, segmentsToRender.filter((_, i, orig) => i < orig.length - 1), ]); } } return undefined; }, [lastSegmentText, segmentsToRender, onChange]); React.useEffect(() => { if ((renderLastSegmentAsInvalid || !value.length) && suggestions.length > 0) { setSuggestions([]); debouncedSuggestion.cancel(); flush(); } }, [renderLastSegmentAsInvalid, debouncedSuggestion, setSuggestions, flush]); const computedTextInputStyle = React.useMemo(() => { return [ textStyle, textInputStyle, !!renderLastSegmentAsInvalid && invalidTextStyle, /* hide text field when disabled */ !!shouldDisable && { height: 0 }, ].filter((e) => !!e); }, [textStyle, textInputStyle, renderLastSegmentAsInvalid, invalidTextStyle, shouldDisable]); const onSubmitEditing = React.useCallback(() => { onChange([`${lastSegmentText} `, segmentsToRender]); }, [lastSegmentText, segmentsToRender]); //const shouldRenderSuggestions = React.useMemo(() => { // return !shouldDisable && suggestions.length > 0; // //return ((!shouldDisable && lastSegmentText.length >= minSuggestionLength && Array.isArray(suggestions) && suggestions.length > 0) || loadingSuggestions); //}, [shouldDisable, lastSegmentText, minSuggestionLength, suggestions, loadingSuggestions]); const shouldPickSuggestion = React.useCallback(([suggestion, regexp]) => { if (!typeCheck("String", suggestion)) { throw new Error( `Expected String suggestion, encountered ${suggestion}.` ); } else if (!typeCheck("String", regexp)) { throw new Error(`Expected String regexp, encountered ${regexp}.`); } debouncedSuggestion.cancel(); setSuggestions([]); onChange(["", [...segmentsToRender, [suggestion, regexp]]]); }, [debouncedSuggestion, setSuggestions, onChange, segmentsToRender]); return ( <> <View {...extraProps} style={[styles.segments, style]}> <View style={[styles.segments, segmentContainerStyle]}> {renderedSegments} </View> <TextInput multiline={multiline} numberOfLines={numberOfLines} pointerEvents={shouldDisable ? "none" : "auto"} onKeyPress={onKeyPress} ref={ref} disabled={shouldDisable} style={computedTextInputStyle} placeholder={shouldDisable ? "" : placeholder} placeholderTextColor={placeholderTextColor} value={lastSegmentText} onChangeText={onChangeTextCallback} onSubmitEditing={onSubmitEditing} /> </View> {/* TODO since the request must conform to a selected regexp, we can be the ones to pick it */} <View style={suggestionsContainerStyle}> {renderSuggestions({ loadingSuggestions: debouncedLoading, suggestions: debouncedSuggestions, pickSuggestion: shouldPickSuggestion, })} </View> </> ); }, ); SegmentedTextInput.propTypes = { value: PropTypes.arrayOf(PropTypes.any), onChange: PropTypes.func, patterns: PropTypes.shape({}), placeholder: PropTypes.string, placeholderTextColor: PropTypes.string, disabled: PropTypes.bool, textStyle: PropTypes.oneOfType([PropTypes.shape({}), PropTypes.number]), textInputStyle: PropTypes.oneOfType([PropTypes.shape({}), PropTypes.number]), invalidTextStyle: PropTypes.oneOfType([ PropTypes.shape({}), PropTypes.number, ]), segmentContainerStyle: PropTypes.oneOfType([PropTypes.shape({}), PropTypes.number]), suggestionsContainerStyle: PropTypes.oneOfType([PropTypes.shape({}), PropTypes.number]), shouldRenderInvalid: PropTypes.func, max: PropTypes.number, onSuggest: PropTypes.func, minSuggestionLength: PropTypes.number, debounce: PropTypes.number, renderSuggestions: PropTypes.func, multiline: PropTypes.bool, numberOfLines: PropTypes.number, }; SegmentedTextInput.defaultProps = { value: ['', []], onChange: Promise.resolve, patterns: { /* a twitter @mention */ [PATTERN_MENTION]: ({style, onRequestDelete, children, ...extraProps}) => ( <TouchableOpacity onPress={onRequestDelete} > <Text {...extraProps} style={[style, { fontWeight: "bold" }]} children={`${children} `} /> </TouchableOpacity> ), }, placeholder: "Add some @mentions...", placeholderTextColor: undefined, disabled: false, textStyle: { fontSize: 28, }, textInputStyle: { minWidth: 100, }, invalidTextStyle: { color: "red", }, segmentContainerStyle: {}, suggestionsContainerStyle: {}, /* don't mark the first character as an invalid animation */ shouldRenderInvalid: str => !str.startsWith("@"), max: 3, onSuggest: text => Promise.resolve([]), minSuggestionLength: 2, debounce: 350, renderSuggestions: ({loadingSuggestions, suggestions, pickSuggestion}) => ( <View pointerEvents={loadingSuggestions ? "none" : "auto"} style={{ flexDirection: "row", alignItems: "center", }} > {!!loadingSuggestions && ( <ActivityIndicator /> )} {suggestions.map( (suggestion, i) => ( <TouchableOpacity key={i} style={{ opacity: loadingSuggestions ? 0.4 : 1.0, }} onPress={() => pickSuggestion([suggestion, PATTERN_MENTION.toString()])} > <Text style={{ fontSize: 14, }} children={`${suggestion} `} /> </TouchableOpacity> ), )} </View> ), multiline: false, numberOfLines: 1, }; export default React.memo(SegmentedTextInput);