diff --git a/native/account/registration/registration-text-input.react.js b/native/account/registration/registration-text-input.react.js index ba4373afd..a92d911d6 100644 --- a/native/account/registration/registration-text-input.react.js +++ b/native/account/registration/registration-text-input.react.js @@ -1,95 +1,100 @@ // @flow import * as React from 'react'; import { TextInput } from 'react-native'; +import type { ReactRefSetter } from 'lib/types/react-types.js'; + import { useStyles, useColors, useKeyboardAppearance, } from '../../themes/colors.js'; type Props = React.ElementConfig; -function ForwardedRegistrationTextInput(props: Props, ref): React.Node { +function ForwardedRegistrationTextInput( + props: Props, + ref: ReactRefSetter>, +): React.Node { const { onFocus, onBlur, style, placeholderTextColor, keyboardAppearance, ...rest } = props; const [focused, setFocused] = React.useState(false); const ourOnFocus = React.useCallback( event => { setFocused(true); onFocus?.(event); }, [onFocus], ); const ourOnBlur = React.useCallback( event => { setFocused(false); onBlur?.(event); }, [onBlur], ); const styles = useStyles(unboundStyles); const ourStyle = React.useMemo( () => focused ? [styles.textInput, styles.focusedTextInput, style] : [styles.textInput, style], [focused, styles.textInput, styles.focusedTextInput, style], ); const colors = useColors(); const ourPlaceholderTextColor = placeholderTextColor ?? colors.panelSecondaryForegroundBorder; const themeKeyboardAppearance = useKeyboardAppearance(); const ourKeyboardAppearance = keyboardAppearance ?? themeKeyboardAppearance; return ( ); } const unboundStyles = { textInput: { color: 'panelForegroundLabel', borderColor: 'panelSecondaryForegroundBorder', borderWidth: 1, borderRadius: 4, padding: 12, }, focusedTextInput: { borderColor: 'panelForegroundLabel', }, }; const RegistrationTextInput: React.AbstractComponent< Props, React.ElementRef, > = React.forwardRef>( ForwardedRegistrationTextInput, ); RegistrationTextInput.displayName = 'RegistrationTextInput'; const MemoizedRegistrationTextInput: typeof RegistrationTextInput = React.memo< Props, React.ElementRef, >(RegistrationTextInput); export default MemoizedRegistrationTextInput; diff --git a/native/bottom-sheet/bottom-sheet.react.js b/native/bottom-sheet/bottom-sheet.react.js index 0b7b3cebe..de8c79bb4 100644 --- a/native/bottom-sheet/bottom-sheet.react.js +++ b/native/bottom-sheet/bottom-sheet.react.js @@ -1,75 +1,77 @@ // @flow import GorhomBottomSheet from '@gorhom/bottom-sheet'; import invariant from 'invariant'; import * as React from 'react'; +import type { ReactRefSetter } from 'lib/types/react-types.js'; + import BottomSheetBackdrop from './bottom-sheet-backdrop.react.js'; import BottomSheetHandle from './bottom-sheet-handle.react.js'; import { BottomSheetContext } from './bottom-sheet-provider.react.js'; import { useStyles } from '../themes/colors.js'; type Props = { +children: React.Node, +onClosed: () => mixed, }; function ForwardedBottomSheet( props: Props, - ref: React.Ref, + ref: ReactRefSetter, ): React.Node { const { children, onClosed } = props; const styles = useStyles(unboundStyles); const bottomSheetContext = React.useContext(BottomSheetContext); invariant(bottomSheetContext, 'bottomSheetContext should be set'); const { contentHeight } = bottomSheetContext; const snapPoints = React.useMemo(() => [contentHeight], [contentHeight]); const onChange = React.useCallback( (index: number) => { if (index === -1) { onClosed(); } }, [onClosed], ); return ( {children} ); } const unboundStyles = { background: { backgroundColor: 'modalForeground', }, }; const BottomSheet: React.AbstractComponent< Props, React.ElementRef, > = React.forwardRef>( ForwardedBottomSheet, ); BottomSheet.displayName = 'BottomSheet'; const MemoizedBottomSheet: typeof BottomSheet = React.memo< Props, React.ElementRef, >(BottomSheet); export default MemoizedBottomSheet; diff --git a/native/chat/chat-thread-list-search.react.js b/native/chat/chat-thread-list-search.react.js index 938c3da43..7fc92bcd6 100644 --- a/native/chat/chat-thread-list-search.react.js +++ b/native/chat/chat-thread-list-search.react.js @@ -1,174 +1,179 @@ // @flow import * as React from 'react'; import { TextInput as BaseTextInput } from 'react-native'; import Animated from 'react-native-reanimated'; +import type { ReactRefSetter } from 'lib/types/react-types.js'; + import type { SearchStatus } from './chat-thread-list.react.js'; import Button from '../components/button.react.js'; import Search from '../components/search.react.js'; import { useStyles } from '../themes/colors.js'; import { AnimatedView, type AnimatedStyleObj } from '../types/styles.js'; import { animateTowards } from '../utils/animation-utils.js'; /* eslint-disable import/no-named-as-default-member */ const { Node, Value, interpolateNode, useValue } = Animated; /* eslint-enable import/no-named-as-default-member */ type Props = { +searchText: string, +onChangeText: (updatedSearchText: string) => mixed, +onBlur: () => mixed, +additionalProps?: $Shape>, +onSearchCancel: () => mixed, +searchStatus: SearchStatus, +innerSearchAutoFocus?: boolean, +innerSearchActive?: boolean, }; -function ForwardedChatThreadListSearch(props: Props, ref): React.Node { +function ForwardedChatThreadListSearch( + props: Props, + ref: ReactRefSetter>, +): React.Node { const { searchText, onChangeText, onBlur, onSearchCancel, searchStatus, innerSearchActive, innerSearchAutoFocus, } = props; const styles = useStyles(unboundStyles); const searchCancelButtonOpen: Value = useValue(0); const searchCancelButtonProgress: Node = React.useMemo( () => animateTowards(searchCancelButtonOpen, 100), [searchCancelButtonOpen], ); const searchCancelButtonOffset: Node = React.useMemo( () => interpolateNode(searchCancelButtonProgress, { inputRange: [0, 1], outputRange: [0, 56], }), [searchCancelButtonProgress], ); const isActiveOrActivating = searchStatus === 'active' || searchStatus === 'activating'; React.useEffect(() => { if (isActiveOrActivating) { searchCancelButtonOpen.setValue(1); } else { searchCancelButtonOpen.setValue(0); } }, [isActiveOrActivating, searchCancelButtonOpen]); const animatedSearchBoxStyle: AnimatedStyleObj = React.useMemo( () => ({ marginRight: searchCancelButtonOffset, }), [searchCancelButtonOffset], ); const searchBoxStyle = React.useMemo( () => [styles.searchBox, animatedSearchBoxStyle], [animatedSearchBoxStyle, styles.searchBox], ); const buttonStyle = React.useMemo( () => [ styles.cancelSearchButtonText, { opacity: searchCancelButtonProgress }, ], [searchCancelButtonProgress, styles.cancelSearchButtonText], ); const innerSearchNode = React.useMemo( () => ( ), [ innerSearchActive, innerSearchAutoFocus, onBlur, onChangeText, ref, searchText, styles.search, ], ); const searchContainer = React.useMemo( () => {innerSearchNode}, [innerSearchNode, searchBoxStyle], ); const cancelButton = React.useMemo( () => ( ), [buttonStyle, onSearchCancel, searchStatus, styles.cancelSearchButton], ); const chatThreadListSearch = React.useMemo( () => ( <> {cancelButton} {searchContainer} ), [cancelButton, searchContainer], ); return chatThreadListSearch; } const unboundStyles = { searchBox: { flex: 1, }, search: { marginBottom: 8, marginHorizontal: 18, marginTop: 16, }, cancelSearchButton: { position: 'absolute', right: 0, top: 0, bottom: 0, display: 'flex', justifyContent: 'center', }, cancelSearchButtonText: { color: 'link', fontSize: 16, paddingHorizontal: 16, paddingTop: 8, }, }; const ChatThreadListSearch: React.AbstractComponent< Props, React.ElementRef, > = React.forwardRef>( ForwardedChatThreadListSearch, ); ChatThreadListSearch.displayName = 'ChatThreadListSearch'; export default ChatThreadListSearch; diff --git a/native/components/gesture-touchable-opacity.react.js b/native/components/gesture-touchable-opacity.react.js index 6149efa50..a1113efa7 100644 --- a/native/components/gesture-touchable-opacity.react.js +++ b/native/components/gesture-touchable-opacity.react.js @@ -1,250 +1,252 @@ // @flow import * as React from 'react'; import { StyleSheet } from 'react-native'; import { LongPressGestureHandler, TapGestureHandler, State as GestureState, } from 'react-native-gesture-handler'; import Animated, { EasingNode } from 'react-native-reanimated'; +import type { ReactRefSetter } from 'lib/types/react-types.js'; + import type { AnimatedViewStyle, ViewStyle } from '../types/styles.js'; import { runTiming, useReanimatedValueForBoolean, } from '../utils/animation-utils.js'; /* eslint-disable import/no-named-as-default-member */ const { Clock, block, event, set, call, cond, not, and, or, eq, stopClock, clockRunning, useValue, } = Animated; /* eslint-enable import/no-named-as-default-member */ const pressAnimationSpec = { duration: 150, easing: EasingNode.inOut(EasingNode.quad), }; const resetAnimationSpec = { duration: 250, easing: EasingNode.inOut(EasingNode.quad), }; type Props = { +activeOpacity?: number, +onPress?: () => mixed, +onLongPress?: () => mixed, +children?: React.Node, +style?: ViewStyle, +animatedStyle?: AnimatedViewStyle, // If stickyActive is a boolean, we assume that we should stay active after a // successful onPress or onLongPress. We will wait for stickyActive to flip // from true to false before animating back to our deactivated mode. +stickyActive?: boolean, +overlay?: React.Node, +disabled?: boolean, }; function ForwardedGestureTouchableOpacity( props: Props, - ref: React.Ref, + ref: ReactRefSetter, ) { const { onPress: innerOnPress, onLongPress: innerOnLongPress } = props; const onPress = React.useCallback(() => { innerOnPress && innerOnPress(); }, [innerOnPress]); const onLongPress = React.useCallback(() => { innerOnLongPress && innerOnLongPress(); }, [innerOnLongPress]); const activeOpacity = props.activeOpacity ?? 0.2; const { stickyActive, disabled } = props; const activeValue = useReanimatedValueForBoolean(!!stickyActive); const disabledValue = useReanimatedValueForBoolean(!!disabled); const stickyActiveEnabled = stickyActive !== null && stickyActive !== undefined; const longPressState = useValue(-1); const tapState = useValue(-1); const longPressEvent = React.useMemo( () => event([ { nativeEvent: { state: longPressState, }, }, ]), [longPressState], ); const tapEvent = React.useMemo( () => event([ { nativeEvent: { state: tapState, }, }, ]), [tapState], ); const gestureActive = React.useMemo( () => or( eq(longPressState, GestureState.ACTIVE), eq(tapState, GestureState.BEGAN), eq(tapState, GestureState.ACTIVE), activeValue, ), [longPressState, tapState, activeValue], ); const curOpacity = useValue(1); const pressClockRef = React.useRef(); if (!pressClockRef.current) { pressClockRef.current = new Clock(); } const pressClock = pressClockRef.current; const resetClockRef = React.useRef(); if (!resetClockRef.current) { resetClockRef.current = new Clock(); } const resetClock = resetClockRef.current; const animationCode = React.useMemo( () => [ cond(or(gestureActive, clockRunning(pressClock)), [ set( curOpacity, runTiming( pressClock, curOpacity, activeOpacity, true, pressAnimationSpec, ), ), stopClock(resetClock), ]), // We have to do two separate conds here even though the condition is the // same because if runTiming stops the pressClock, we need to immediately // start the resetClock or Reanimated won't keep running the code because // it will think there is nothing left to do cond( not(or(gestureActive, clockRunning(pressClock))), set( curOpacity, runTiming(resetClock, curOpacity, 1, true, resetAnimationSpec), ), ), ], [gestureActive, curOpacity, pressClock, resetClock, activeOpacity], ); const prevTapSuccess = useValue(0); const prevLongPressSuccess = useValue(0); const transformStyle = React.useMemo(() => { const tapSuccess = eq(tapState, GestureState.END); const longPressSuccess = eq(longPressState, GestureState.ACTIVE); const opacity = block([ ...animationCode, [ cond(and(tapSuccess, not(prevTapSuccess), not(disabledValue)), [ stickyActiveEnabled ? set(activeValue, 1) : undefined, call([], onPress), ]), set(prevTapSuccess, tapSuccess), ], [ cond( and(longPressSuccess, not(prevLongPressSuccess), not(disabledValue)), [ stickyActiveEnabled ? set(activeValue, 1) : undefined, call([], onLongPress), ], ), set(prevLongPressSuccess, longPressSuccess), ], curOpacity, ]); return { opacity }; }, [ animationCode, tapState, longPressState, prevTapSuccess, prevLongPressSuccess, curOpacity, onPress, onLongPress, activeValue, disabledValue, stickyActiveEnabled, ]); const fillStyle = React.useMemo(() => { const result = StyleSheet.flatten(props.style); if (!result) { return undefined; } const { flex } = result; if (flex === null || flex === undefined) { return undefined; } return { flex }; }, [props.style]); const tapHandler = ( {props.children} {props.overlay} ); if (!innerOnLongPress) { return tapHandler; } return ( {tapHandler} ); } const GestureTouchableOpacity: React.AbstractComponent< Props, TapGestureHandler, > = React.forwardRef( ForwardedGestureTouchableOpacity, ); GestureTouchableOpacity.displayName = 'GestureTouchableOpacity'; export default GestureTouchableOpacity; diff --git a/native/components/search.react.js b/native/components/search.react.js index 83d7b5547..b987d812c 100644 --- a/native/components/search.react.js +++ b/native/components/search.react.js @@ -1,145 +1,149 @@ // @flow import * as React from 'react'; import { View, TouchableOpacity, TextInput as BaseTextInput, Text, Platform, } from 'react-native'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; +import type { ReactRefSetter } from 'lib/types/react-types.js'; import SWMansionIcon from './swmansion-icon.react.js'; import TextInput from './text-input.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles, useColors } from '../themes/colors.js'; import type { ViewStyle } from '../types/styles.js'; type Props = { ...React.ElementConfig, +searchText: string, +onChangeText: (searchText: string) => mixed, +containerStyle?: ViewStyle, +active?: boolean, }; -function ForwardedSearch(props: Props, ref) { +function ForwardedSearch( + props: Props, + ref: ReactRefSetter>, +) { const { onChangeText, searchText, containerStyle, active, ...rest } = props; const clearSearch = React.useCallback(() => { onChangeText(''); }, [onChangeText]); const loggedIn = useSelector(isLoggedIn); const styles = useStyles(unboundStyles); const colors = useColors(); const prevLoggedInRef = React.useRef(); React.useEffect(() => { const prevLoggedIn = prevLoggedInRef.current; prevLoggedInRef.current = loggedIn; if (!loggedIn && prevLoggedIn) { clearSearch(); } }, [loggedIn, clearSearch]); const { listSearchIcon: iconColor } = colors; let clearSearchInputIcon = null; if (searchText) { clearSearchInputIcon = ( ); } const inactive = active === false; const usingPlaceholder = !searchText && rest.placeholder; const inactiveTextStyle = React.useMemo( () => inactive && usingPlaceholder ? [styles.searchText, styles.inactiveSearchText, { color: iconColor }] : [styles.searchText, styles.inactiveSearchText], [ inactive, usingPlaceholder, styles.searchText, styles.inactiveSearchText, iconColor, ], ); let textNode; if (!inactive) { const textInputProps: React.ElementProps = { style: styles.searchText, value: searchText, onChangeText: onChangeText, placeholderTextColor: iconColor, returnKeyType: 'go', }; textNode = ; } else { const text = usingPlaceholder ? rest.placeholder : searchText; textNode = {text}; } return ( {textNode} {clearSearchInputIcon} ); } const Search: React.AbstractComponent< Props, React.ElementRef, > = React.forwardRef>( ForwardedSearch, ); Search.displayName = 'Search'; const unboundStyles = { search: { alignItems: 'center', backgroundColor: 'listSearchBackground', borderRadius: 8, flexDirection: 'row', paddingLeft: 14, paddingRight: 7, }, inactiveSearchText: { transform: Platform.select({ ios: [{ translateY: 1 / 3 }], default: undefined, }), }, searchText: { color: 'listForegroundLabel', flex: 1, fontSize: 16, marginLeft: 8, marginVertical: 6, padding: 0, borderBottomColor: 'transparent', }, clearSearchButton: { paddingVertical: 5, paddingLeft: 5, }, }; const MemoizedSearch: typeof Search = React.memo< Props, React.ElementRef, >(Search); export default MemoizedSearch; diff --git a/native/components/selectable-text-input.react.ios.js b/native/components/selectable-text-input.react.ios.js index d11bb7010..c1f2113d8 100644 --- a/native/components/selectable-text-input.react.ios.js +++ b/native/components/selectable-text-input.react.ios.js @@ -1,141 +1,142 @@ // @flow import _debounce from 'lodash/debounce.js'; import * as React from 'react'; import type { Selection } from 'lib/shared/mention-utils.js'; +import type { ReactRefSetter } from 'lib/types/react-types.js'; // eslint-disable-next-line import/extensions import ClearableTextInput from './clearable-text-input.react'; import type { SelectableTextInputProps, SelectableTextInputRef, } from './selectable-text-input.js'; import type { SelectionChangeEvent } from '../types/react-native.js'; const SelectableTextInput = React.forwardRef(function BaseSelectableTextInput( props, - ref, + ref: ReactRefSetter, ): React.Node { const { clearableTextInputRef, onChangeText, onSelectionChange, onUpdateSyncedSelectionData, ...rest } = props; // React Native doesn't handle controlled selection well, so we only set the // selection prop when we need to mutate the selection // https://github.com/facebook/react-native/issues/29063 const [controlSelection, setControlSelection] = React.useState(false); const clearableTextInputRefCallback = React.useCallback( (clearableTextInput: ?React.ElementRef) => { clearableTextInputRef(clearableTextInput); }, [clearableTextInputRef], ); // - It's important for us to keep text and selection state in sync, since // upstream code in ChatInputBar processes this data during render to // generate a list of @-mention suggestions // - On iOS, selection events precede text change events, and each leads to a // separate React render cycle // - To prevent render cycles where the data isn't in sync, we defer selection // events until the corresponding text change event comes in // - Since selection events can happen without text changes (user moving the // cursor) we also set a debounced timeout after each selection event that // will activate if no corresponding text change event comes in within 50ms const pendingSelectionEventRef = React.useRef(); const sendPendingSelectionEvent = React.useCallback( (text: string) => { const pendingSelectionEvent = pendingSelectionEventRef.current; if (!pendingSelectionEvent) { return; } pendingSelectionEventRef.current = undefined; onUpdateSyncedSelectionData({ text, selection: pendingSelectionEvent }); }, [onUpdateSyncedSelectionData], ); const onChangeTextOverride = React.useCallback( (text: string) => { onChangeText(text); sendPendingSelectionEvent(text); }, [onChangeText, sendPendingSelectionEvent], ); // When a user selects a @-mention in the middle of some text, React Native on // iOS has a strange bug where it emits two selection events in a row: // - The first selection event resets the cursor to the very end of the text // - The second selection event puts the cursor back where it should go, which // is the middle of the text where it started, but after the new text that // just got inserted // In contrast, if an @-mention is entered at the end, only the first event // occurs. We actually want to ignore both, because we manually reset the // selection state ourselves and these events don't reflect our updates. const numNextSelectionEventsToIgnoreRef = React.useRef(0); const prepareForSelectionMutation = React.useCallback( (text: string, selection: Selection) => { setControlSelection(true); numNextSelectionEventsToIgnoreRef.current = selection.start === text.length ? 1 : 2; }, [], ); const ourRef = React.useMemo( () => ({ prepareForSelectionMutation, }), [prepareForSelectionMutation], ); React.useImperativeHandle(ref, () => ourRef, [ourRef]); const debouncedSendPendingSelectionEvent = React.useMemo( () => _debounce(sendPendingSelectionEvent, 50), [sendPendingSelectionEvent], ); const onSelectionChangeOverride = React.useCallback( (event: SelectionChangeEvent) => { if (numNextSelectionEventsToIgnoreRef.current <= 1) { // If after this tick we will start allowing selection events through, // then we will drop control of selection setControlSelection(false); } if (numNextSelectionEventsToIgnoreRef.current > 0) { numNextSelectionEventsToIgnoreRef.current--; return; } pendingSelectionEventRef.current = event.nativeEvent.selection; debouncedSendPendingSelectionEvent(props.value); if (onSelectionChange) { onSelectionChange(event); } }, [debouncedSendPendingSelectionEvent, props.value, onSelectionChange], ); return ( ); }); const MemoizedSelectableTextInput: React.AbstractComponent< SelectableTextInputProps, SelectableTextInputRef, > = React.memo( SelectableTextInput, ); export default MemoizedSelectableTextInput; diff --git a/native/components/selectable-text-input.react.js b/native/components/selectable-text-input.react.js index 2dff3a9d6..0d9fec5ba 100644 --- a/native/components/selectable-text-input.react.js +++ b/native/components/selectable-text-input.react.js @@ -1,86 +1,88 @@ // @flow import * as React from 'react'; +import type { ReactRefSetter } from 'lib/types/react-types.js'; + // eslint-disable-next-line import/extensions import ClearableTextInput from './clearable-text-input.react'; import type { SelectableTextInputProps, SelectableTextInputRef, } from './selectable-text-input.js'; import type { SelectionChangeEvent } from '../types/react-native.js'; const SelectableTextInput = React.forwardRef(function BaseSelectableTextInput( props, - ref, + ref: ReactRefSetter, ): React.Node { const { clearableTextInputRef, onUpdateSyncedSelectionData, onSelectionChange, selection, ...rest } = props; // React Native doesn't handle controlled selection well, so we only set the // selection prop when we need to mutate the selection // https://github.com/facebook/react-native/issues/29063 const [controlSelection, setControlSelection] = React.useState(false); const clearableTextInputRefCallback = React.useCallback( (clearableTextInput: ?React.ElementRef) => { clearableTextInputRef(clearableTextInput); }, [clearableTextInputRef], ); const prepareForSelectionMutation = React.useCallback( () => setControlSelection(true), [], ); const ourRef = React.useMemo( () => ({ prepareForSelectionMutation, }), [prepareForSelectionMutation], ); React.useImperativeHandle(ref, () => ourRef, [ourRef]); // - It's important for us to keep text and selection state in sync, since // upstream code in ChatInputBar processes this data during render to // generate a list of @-mention suggestions // - On Android, text change events precede selection events, and each leads // to a separate React render cycle // - To prevent render cycles where the data isn't in sync, we defer text // change events until the corresponding selection event comes in const onSelectionChangeOverride = React.useCallback( (event: SelectionChangeEvent) => { setControlSelection(false); onSelectionChange?.(event); onUpdateSyncedSelectionData({ text: props.value, selection: event.nativeEvent.selection, }); }, [onUpdateSyncedSelectionData, props.value, onSelectionChange], ); return ( ); }); const MemoizedSelectableTextInput: React.AbstractComponent< SelectableTextInputProps, SelectableTextInputRef, > = React.memo( SelectableTextInput, ); export default MemoizedSelectableTextInput; diff --git a/native/components/tag-input.react.js b/native/components/tag-input.react.js index dd147a067..4e7189ab1 100644 --- a/native/components/tag-input.react.js +++ b/native/components/tag-input.react.js @@ -1,482 +1,483 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text, TextInput as BaseTextInput, StyleSheet, TouchableOpacity, TouchableWithoutFeedback, ScrollView, Platform, } from 'react-native'; import type { Shape } from 'lib/types/core.js'; +import type { ReactRefSetter } from 'lib/types/react-types.js'; import TextInput from './text-input.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useColors, type Colors } from '../themes/colors.js'; import type { LayoutEvent, KeyPressEvent, BlurEvent, } from '../types/react-native.js'; import type { ViewStyle, TextStyle } from '../types/styles.js'; type DefaultProps = { /** * Min height of the tag input on screen */ +minHeight: number, /** * Max height of the tag input on screen (will scroll if max height reached) */ +maxHeight: number, /** * inputWidth if text === "". we want this number explicitly because if we're * forced to measure the component, there can be a short jump between the old * value and the new value, which looks sketchy. */ +defaultInputWidth: number, }; type TagInputProps = { ...DefaultProps, /** * An array of tags, which can be any type, as long as labelExtractor below * can extract a string from it. */ +value: $ReadOnlyArray, /** * A handler to be called when array of tags change. */ +onChange: (items: $ReadOnlyArray) => void, /** * Function to extract string value for label from item */ +labelExtractor: (tagData: T) => string, /** * The text currently being displayed in the TextInput following the list of * tags. */ +text: string, /** * This callback gets called when the user in the TextInput. The caller should * update the text prop when this is called if they want to access input. */ +onChangeText: (text: string) => mixed, /** * If `true`, text and tags are not editable. The default value is `false`. */ +disabled?: boolean, /** * Background color of tags */ +tagColor?: string, /** * Text color of tags */ +tagTextColor?: string, /** * Styling override for container surrounding tag text */ +tagContainerStyle?: ViewStyle, /** * Styling override for tag's text component */ +tagTextStyle?: TextStyle, /** * Color of text input */ +inputColor?: string, /** * Any misc. TextInput props (autoFocus, placeholder, returnKeyType, etc.) */ +inputProps?: React.ElementConfig, /** * Callback that gets passed the new component height when it changes */ +onHeightChange?: (height: number) => void, }; type BaseTagInputProps = { ...TagInputProps, +windowWidth: number, +colors: Colors, }; type State = { +wrapperHeight: number, +contentHeight: number, +wrapperWidth: number, +spaceLeft: number, }; class BaseTagInput extends React.PureComponent, State> { // scroll to bottom scrollViewHeight: number = 0; scrollToBottomAfterNextScrollViewLayout: boolean = false; // refs tagInput: ?React.ElementRef = null; scrollView: ?React.ElementRef = null; lastChange: ?{ time: number, prevText: string }; static defaultProps: DefaultProps = { minHeight: 30, maxHeight: 75, defaultInputWidth: 90, }; constructor(props: BaseTagInputProps) { super(props); this.state = { wrapperHeight: 30, // was wrapperHeight: 36, contentHeight: 0, wrapperWidth: props.windowWidth, spaceLeft: 0, }; } static getDerivedStateFromProps( props: BaseTagInputProps, state: State, ): Shape { const wrapperHeight = Math.max( Math.min(props.maxHeight, state.contentHeight), props.minHeight, ); return { wrapperHeight }; } componentDidUpdate(prevProps: BaseTagInputProps, prevState: State) { if ( this.props.onHeightChange && this.state.wrapperHeight !== prevState.wrapperHeight ) { this.props.onHeightChange(this.state.wrapperHeight); } } measureWrapper: (event: LayoutEvent) => void = event => { const wrapperWidth = event.nativeEvent.layout.width; if (wrapperWidth !== this.state.wrapperWidth) { this.setState({ wrapperWidth }); } }; onChangeText: (text: string) => void = text => { this.lastChange = { time: Date.now(), prevText: this.props.text }; this.props.onChangeText(text); }; onBlur: (event: BlurEvent) => void = event => { invariant(Platform.OS === 'ios', 'only iOS gets text on TextInput.onBlur'); const nativeEvent: $ReadOnly<{ target: number, text: string, }> = (event.nativeEvent: any); this.onChangeText(nativeEvent.text); }; onKeyPress: (event: KeyPressEvent) => void = event => { const { lastChange } = this; let { text } = this.props; if ( Platform.OS === 'android' && lastChange !== null && lastChange !== undefined && Date.now() - lastChange.time < 150 ) { text = lastChange.prevText; } if (text !== '' || event.nativeEvent.key !== 'Backspace') { return; } const tags = [...this.props.value]; tags.pop(); this.props.onChange(tags); this.focus(); }; focus: () => void = () => { invariant(this.tagInput, 'should be set'); this.tagInput.focus(); }; removeIndex: (index: number) => void = index => { const tags = [...this.props.value]; tags.splice(index, 1); this.props.onChange(tags); }; scrollToBottom: () => void = () => { const scrollView = this.scrollView; invariant( scrollView, 'this.scrollView ref should exist before scrollToBottom called', ); scrollView.scrollToEnd(); }; render(): React.Node { const tagColor = this.props.tagColor || this.props.colors.modalSubtext; const tagTextColor = this.props.tagTextColor || this.props.colors.modalForegroundLabel; const inputColor = this.props.inputColor || this.props.colors.modalForegroundLabel; const placeholderColor = this.props.colors.modalForegroundTertiaryLabel; const tags = this.props.value.map((tag, index) => ( )); let inputWidth; if (this.props.text === '') { inputWidth = this.props.defaultInputWidth; } else if (this.state.spaceLeft >= 100) { inputWidth = this.state.spaceLeft - 10; } else { inputWidth = this.state.wrapperWidth; } const defaultTextInputProps: React.ElementConfig = { blurOnSubmit: false, style: [ styles.textInput, { width: inputWidth, color: inputColor, }, ], autoCapitalize: 'none', autoCorrect: false, placeholder: 'Start typing', placeholderTextColor: placeholderColor, returnKeyType: 'done', keyboardType: 'default', }; const textInputProps: React.ElementConfig = { ...defaultTextInputProps, ...this.props.inputProps, // should not be overridden onKeyPress: this.onKeyPress, value: this.props.text, onBlur: Platform.OS === 'ios' ? this.onBlur : undefined, onChangeText: this.onChangeText, editable: !this.props.disabled, }; return ( {tags} ); } tagInputRef: (tagInput: ?React.ElementRef) => void = tagInput => { this.tagInput = tagInput; }; scrollViewRef: (scrollView: ?React.ElementRef) => void = scrollView => { this.scrollView = scrollView; }; onScrollViewContentSizeChange: (w: number, h: number) => void = (w, h) => { const oldContentHeight = this.state.contentHeight; if (h === oldContentHeight) { return; } let callback; if (h > oldContentHeight) { callback = () => { if (this.scrollViewHeight === this.props.maxHeight) { this.scrollToBottom(); } else { this.scrollToBottomAfterNextScrollViewLayout = true; } }; } this.setState({ contentHeight: h }, callback); }; onScrollViewLayout: (event: LayoutEvent) => void = event => { this.scrollViewHeight = event.nativeEvent.layout.height; if (this.scrollToBottomAfterNextScrollViewLayout) { this.scrollToBottom(); this.scrollToBottomAfterNextScrollViewLayout = false; } }; onLayoutLastTag: (endPosOfTag: number) => void = endPosOfTag => { const margin = 3; const spaceLeft = this.state.wrapperWidth - endPosOfTag - margin - 10; if (spaceLeft !== this.state.spaceLeft) { this.setState({ spaceLeft }); } }; } type TagProps = { +index: number, +label: string, +isLastTag: boolean, +onLayoutLastTag: (endPosOfTag: number) => void, +removeIndex: (index: number) => void, +tagColor: string, +tagTextColor: string, +tagContainerStyle?: ViewStyle, +tagTextStyle?: TextStyle, +disabled?: boolean, }; class Tag extends React.PureComponent { curPos: ?number = null; componentDidUpdate(prevProps: TagProps) { if ( !prevProps.isLastTag && this.props.isLastTag && this.curPos !== null && this.curPos !== undefined ) { this.props.onLayoutLastTag(this.curPos); } } render() { return ( {this.props.label}  × ); } onPress = () => { this.props.removeIndex(this.props.index); }; onLayoutLastTag = (event: LayoutEvent) => { const layout = event.nativeEvent.layout; this.curPos = layout.width + layout.x; if (this.props.isLastTag) { this.props.onLayoutLastTag(this.curPos); } }; } const styles = StyleSheet.create({ tag: { borderRadius: 2, justifyContent: 'center', marginBottom: 3, marginRight: 3, paddingHorizontal: 6, paddingVertical: 2, }, tagInputContainer: { flex: 1, flexDirection: 'row', flexWrap: 'wrap', }, tagInputContainerScroll: { flex: 1, }, tagText: { fontSize: 16, margin: 0, padding: 0, }, textInput: { borderBottomColor: 'transparent', flex: 0.6, fontSize: 16, height: 24, marginBottom: 3, marginHorizontal: 0, marginTop: 3, padding: 0, }, textInputContainer: {}, wrapper: {}, }); type BaseConfig = React.Config< TagInputProps, typeof BaseTagInput.defaultProps, >; function createTagInput(): React.AbstractComponent< BaseConfig, BaseTagInput, > { return React.forwardRef, BaseTagInput>( function ForwardedTagInput( props: BaseConfig, - ref: React.Ref, + ref: ReactRefSetter>, ) { const windowWidth = useSelector(state => state.dimensions.width); const colors = useColors(); return ( ); }, ); } export { createTagInput, BaseTagInput }; diff --git a/native/components/text-input.react.js b/native/components/text-input.react.js index 3c94dbec5..86010a22b 100644 --- a/native/components/text-input.react.js +++ b/native/components/text-input.react.js @@ -1,24 +1,29 @@ // @flow import * as React from 'react'; import { TextInput } from 'react-native'; +import type { ReactRefSetter } from 'lib/types/react-types.js'; + import { useKeyboardAppearance } from '../themes/colors.js'; type Props = React.ElementConfig; -function ForwardedTextInput(props: Props, ref): React.Node { +function ForwardedTextInput( + props: Props, + ref: ReactRefSetter>, +): React.Node { const keyboardAppearance = useKeyboardAppearance(); return ( ); } const WrappedTextInput: React.AbstractComponent< Props, React.ElementRef, > = React.forwardRef>( ForwardedTextInput, ); WrappedTextInput.displayName = 'CommTextInput'; export default WrappedTextInput;