diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -2,6 +2,7 @@ import Icon from '@expo/vector-icons/Ionicons'; import invariant from 'invariant'; +import _omit from 'lodash/fp/omit'; import _throttle from 'lodash/throttle'; import * as React from 'react'; import { @@ -16,7 +17,9 @@ } from 'react-native'; import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input'; import Animated, { EasingNode } from 'react-native-reanimated'; +import type { SelectionChangeEvent } from 'react-native/Libraries/Components/TextInput/TextInput'; import { useDispatch } from 'react-redux'; +import shallowequal from 'shallowequal'; import { moveDraftActionType, @@ -144,12 +147,20 @@ +userSearchIndex: SearchIndex, +threadMembers: $ReadOnlyArray, }; + +export type Selection = { + +start: number, + +end: number, +}; + type State = { +text: string, +textEdited: boolean, +buttonsExpanded: boolean, + +selection: Selection, + +controlSelection: boolean, }; -class ChatInputBar extends React.PureComponent { +class ChatInputBar extends React.Component { textInput: ?React.ElementRef; clearableTextInput: ?ClearableTextInput; @@ -164,16 +175,49 @@ targetSendButtonContainerOpen: Value; sendButtonContainerStyle: AnimatedViewStyle; + // Refs are needed for hacks used to display typeahead properly + // There was a problem with the typeahead flickering. + // There are two events coming from TextInput: text change + // and selection change. Those two events triggered two rerenders + // which caused flickering of typeahead as regex wasn't matched + // after just one of those. They also come in different order + // based on platform. SelectionChange happens first on iOS + // but TextChange happens first on Android. That is the reason + // we need separate workarounds for two platforms + // iOS hack: + // Introduce iosKeyWasPressed ref and set it to true on onKeyPress event. + // It happens before the other two events + // (on iOS, it happens in the end on Android) + // Then we only rerender the ChatInputBar on selection change + // if iosKeyWasPressed is set to false, e.g. when user moves cursor. + // If it's set to true, we skip rerender if other props/state haven't + // changed + iosKeyWasPressed: boolean; + // Android hack: + // Because an order of events is different, we can't use the previous + // method. Setting flag on text change and then setting state + // on selection change caused extra events being emitted from native + // side. It was probably a reaction to setting text state and trying to + // control component. I used a different approach. We perform two rerenders + // but use androidPreviousText when creating typeahead. + // This way, already updated text is not processed along with old selection + // We set it to null, when new selection is set. + androidPreviousText: ?string; + constructor(props: Props) { super(props); this.state = { text: props.draft, textEdited: false, buttonsExpanded: true, + selection: { start: 0, end: 0 }, + controlSelection: false, }; this.setUpActionIconAnimations(); this.setUpSendIconAnimations(); + this.iosKeyWasPressed = false; + this.androidPreviousText = null; } setUpActionIconAnimations() { @@ -315,6 +359,28 @@ } } + shouldComponentUpdate(nextProps: Props, nextState: State): boolean { + if (Platform.OS !== 'ios') { + return ( + !shallowequal(nextState, this.state) || + !shallowequal(nextProps, this.props) + ); + } + + // we want to rerender only when selection changed, but key was not pressed + const selectionChangedWithoutKeyPress = + !this.iosKeyWasPressed && nextState.selection !== this.state.selection; + + return ( + !shallowequal( + _omit(['selection'])(nextState), + _omit(['selection'])(this.state), + ) || + !shallowequal(nextProps, this.props) || + selectionChangedWithoutKeyPress + ); + } + componentDidUpdate(prevProps: Props, prevState: State) { if ( this.state.textEdited && @@ -568,6 +634,11 @@ allowImagePasteForThreadID={this.props.threadInfo.id} value={this.state.text} onChangeText={this.updateText} + selection={ + this.state.controlSelection ? this.state.selection : undefined + } + onSelectionChange={this.updateSelection} + onKeyPress={this.onKeyPress} placeholder="Send a message..." placeholderTextColor={this.props.colors.listInputButton} multiline={true} @@ -604,11 +675,43 @@ this.clearableTextInput = clearableTextInput; }; + onKeyPress = () => { + if (Platform.OS === 'ios') { + this.iosKeyWasPressed = true; + } + }; + updateText = (text: string) => { - this.setState({ text, textEdited: true }); + if (Platform.OS === 'ios') { + this.iosKeyWasPressed = false; + } + if (Platform.OS === 'android') { + this.androidPreviousText = this.state.text; + } + + this.setState({ text, textEdited: true, controlSelection: false }); this.saveDraft(text); }; + updateSelection: (event: SelectionChangeEvent) => void = event => { + // we introduced controlSelection state to avoid flickering of selection + // it is workaround that allow as only control selection in concrete + // situations, like clicking into typeahead button + // in other situations it is handled by native side, and we don't control it + + if (Platform.OS === 'android') { + this.androidPreviousText = null; + } + + this.setState({ + selection: { + start: event.nativeEvent.selection.start, + end: event.nativeEvent.selection.end, + }, + controlSelection: false, + }); + }; + saveDraft = _throttle(text => { this.props.dispatch({ type: updateDraftActionType, diff --git a/native/components/clearable-text-input.js b/native/components/clearable-text-input.js --- a/native/components/clearable-text-input.js +++ b/native/components/clearable-text-input.js @@ -9,4 +9,6 @@ textInputRef: (textInput: ?React.ElementRef) => mixed, onChangeText: $NonMaybeType<$PropertyType>, value: $NonMaybeType<$PropertyType>, + onSelectionChange: $PropertyType, + selection?: $NonMaybeType<$PropertyType>, }; diff --git a/native/components/clearable-text-input.react.ios.js b/native/components/clearable-text-input.react.ios.js --- a/native/components/clearable-text-input.react.ios.js +++ b/native/components/clearable-text-input.react.ios.js @@ -154,6 +154,7 @@ {...props} style={[props.style, styles.invisibleTextInput]} onChangeText={this.onOldInputChangeText} + onSelectionChange={this.props.onSelectionChange} onKeyPress={this.onOldInputKeyPress} onBlur={this.onOldInputBlur} onFocus={this.onOldInputFocus} @@ -167,6 +168,7 @@ onFocus={this.onFocus} onBlur={this.onBlur} onChangeText={this.props.onChangeText} + onSelectionChange={this.props.onSelectionChange} key={this.state.textInputKey} ref={this.textInputRef} />, diff --git a/native/components/clearable-text-input.react.js b/native/components/clearable-text-input.react.js --- a/native/components/clearable-text-input.react.js +++ b/native/components/clearable-text-input.react.js @@ -60,6 +60,7 @@