diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index f8b24dd09..1e197556c 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,1092 +1,1094 @@ // @flow import Icon from '@expo/vector-icons/Ionicons.js'; import invariant from 'invariant'; import _throttle from 'lodash/throttle.js'; import * as React from 'react'; import { View, TextInput, TouchableOpacity, Platform, Text, ActivityIndicator, TouchableWithoutFeedback, NativeAppEventEmitter, } from 'react-native'; import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input'; import Animated, { EasingNode } from 'react-native-reanimated'; import { useDispatch } from 'react-redux'; import { moveDraftActionType, updateDraftActionType, } from 'lib/actions/draft-actions.js'; import { joinThreadActionTypes, joinThread, newThreadActionTypes, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { relativeMemberInfoSelectorForMembersOfThread, userStoreSearchIndex, } from 'lib/selectors/user-selectors.js'; import { localIDPrefix, trimMessage } from 'lib/shared/message-utils.js'; import SearchIndex from 'lib/shared/search-index.js'; import { threadHasPermission, viewerIsMember, threadFrozenDueToViewerBlock, threadActualMembers, checkIfDefaultMembersAreVoiced, draftKeyFromThreadID, colorIsDark, } from 'lib/shared/thread-utils.js'; import { getTypeaheadUserSuggestions, getTypeaheadRegexMatches, type Selection, } from 'lib/shared/typeahead-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { PhotoPaste } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { type ThreadInfo, threadPermissions, type ClientThreadJoinRequest, type ThreadJoinPayload, type RelativeMemberInfo, } from 'lib/types/thread-types.js'; import { type UserInfos } from 'lib/types/user-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import { ChatContext } from './chat-context.js'; import type { ChatNavigationProp } from './chat.react.js'; import TypeaheadTooltip from './typeahead-tooltip.react.js'; import Button from '../components/button.react.js'; -import ClearableTextInput from '../components/clearable-text-input.react.js'; +// eslint-disable-next-line import/extensions +import ClearableTextInput from '../components/clearable-text-input.react'; import type { SyncedSelectionData } from '../components/selectable-text-input.js'; -import SelectableTextInput from '../components/selectable-text-input.react.js'; +// eslint-disable-next-line import/extensions +import SelectableTextInput from '../components/selectable-text-input.react'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import KeyboardInputHost from '../keyboard/keyboard-input-host.react.js'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state.js'; import { getKeyboardHeight } from '../keyboard/keyboard.js'; import { nonThreadCalendarQuery, activeThreadSelector, } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import { type NavigationRoute, CameraModalRouteName, ImagePasteModalRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useStyles, useColors } from '../themes/colors.js'; import type { LayoutEvent } from '../types/react-native.js'; import { type AnimatedViewStyle, AnimatedView } from '../types/styles.js'; import { runTiming } from '../utils/animation-utils.js'; import { nativeTypeaheadRegex } from '../utils/typeahead-utils.js'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, cond, neq, sub, interpolateNode, stopClock, } = Animated; /* eslint-enable import/no-named-as-default-member */ const expandoButtonsAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; type BaseProps = { +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, // Redux state +viewerID: ?string, +draft: string, +joinThreadLoadingStatus: LoadingStatus, +threadCreationInProgress: boolean, +calendarQuery: () => CalendarQuery, +nextLocalID: number, +userInfos: UserInfos, +colors: Colors, +styles: typeof unboundStyles, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, // connectNav +isActive: boolean, // withKeyboardState +keyboardState: ?KeyboardState, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +joinThread: (request: ClientThreadJoinRequest) => Promise, // withInputState +inputState: ?InputState, +userSearchIndex: SearchIndex, +threadMembers: $ReadOnlyArray, }; type State = { +text: string, +textEdited: boolean, +buttonsExpanded: boolean, +selectionState: SyncedSelectionData, }; class ChatInputBar extends React.PureComponent { textInput: ?React.ElementRef; clearableTextInput: ?ClearableTextInput; selectableTextInput: ?React.ElementRef; expandoButtonsOpen: Value; targetExpandoButtonsOpen: Value; expandoButtonsStyle: AnimatedViewStyle; cameraRollIconStyle: AnimatedViewStyle; cameraIconStyle: AnimatedViewStyle; expandIconStyle: AnimatedViewStyle; sendButtonContainerOpen: Value; targetSendButtonContainerOpen: Value; sendButtonContainerStyle: AnimatedViewStyle; constructor(props: Props) { super(props); this.state = { text: props.draft, textEdited: false, buttonsExpanded: true, selectionState: { text: props.draft, selection: { start: 0, end: 0 } }, }; this.setUpActionIconAnimations(); this.setUpSendIconAnimations(); } setUpActionIconAnimations() { this.expandoButtonsOpen = new Value(1); this.targetExpandoButtonsOpen = new Value(1); const prevTargetExpandoButtonsOpen = new Value(1); const expandoButtonClock = new Clock(); const expandoButtonsOpen = block([ cond(neq(this.targetExpandoButtonsOpen, prevTargetExpandoButtonsOpen), [ stopClock(expandoButtonClock), set(prevTargetExpandoButtonsOpen, this.targetExpandoButtonsOpen), ]), cond( neq(this.expandoButtonsOpen, this.targetExpandoButtonsOpen), set( this.expandoButtonsOpen, runTiming( expandoButtonClock, this.expandoButtonsOpen, this.targetExpandoButtonsOpen, true, expandoButtonsAnimationConfig, ), ), ), this.expandoButtonsOpen, ]); this.cameraRollIconStyle = { ...unboundStyles.cameraRollIcon, opacity: expandoButtonsOpen, }; this.cameraIconStyle = { ...unboundStyles.cameraIcon, opacity: expandoButtonsOpen, }; const expandoButtonsWidth = interpolateNode(expandoButtonsOpen, { inputRange: [0, 1], outputRange: [26, 66], }); this.expandoButtonsStyle = { ...unboundStyles.expandoButtons, width: expandoButtonsWidth, }; const expandOpacity = sub(1, expandoButtonsOpen); this.expandIconStyle = { ...unboundStyles.expandIcon, opacity: expandOpacity, }; } setUpSendIconAnimations() { const initialSendButtonContainerOpen = trimMessage(this.props.draft) ? 1 : 0; this.sendButtonContainerOpen = new Value(initialSendButtonContainerOpen); this.targetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const prevTargetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const sendButtonClock = new Clock(); const sendButtonContainerOpen = block([ cond( neq( this.targetSendButtonContainerOpen, prevTargetSendButtonContainerOpen, ), [ stopClock(sendButtonClock), set( prevTargetSendButtonContainerOpen, this.targetSendButtonContainerOpen, ), ], ), cond( neq(this.sendButtonContainerOpen, this.targetSendButtonContainerOpen), set( this.sendButtonContainerOpen, runTiming( sendButtonClock, this.sendButtonContainerOpen, this.targetSendButtonContainerOpen, true, sendButtonAnimationConfig, ), ), ), this.sendButtonContainerOpen, ]); const sendButtonContainerWidth = interpolateNode(sendButtonContainerOpen, { inputRange: [0, 1], outputRange: [4, 38], }); this.sendButtonContainerStyle = { width: sendButtonContainerWidth }; } static mediaGalleryOpen(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } static systemKeyboardShowing(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.systemKeyboardShowing); } get systemKeyboardShowing() { return ChatInputBar.systemKeyboardShowing(this.props); } immediatelyShowSendButton() { this.sendButtonContainerOpen.setValue(1); this.targetSendButtonContainerOpen.setValue(1); } updateSendButton(currentText: string) { if (this.shouldShowTextInput) { this.targetSendButtonContainerOpen.setValue(currentText === '' ? 0 : 1); } else { this.setUpSendIconAnimations(); } } componentDidMount() { if (this.props.isActive) { this.addReplyListener(); } } componentWillUnmount() { if (this.props.isActive) { this.removeReplyListener(); } } componentDidUpdate(prevProps: Props, prevState: State) { if ( this.state.textEdited && this.state.text && this.props.threadInfo.id !== prevProps.threadInfo.id ) { this.props.dispatch({ type: moveDraftActionType, payload: { oldKey: draftKeyFromThreadID(prevProps.threadInfo.id), newKey: draftKeyFromThreadID(this.props.threadInfo.id), }, }); } else if (!this.state.textEdited && this.props.draft !== prevProps.draft) { this.setState({ text: this.props.draft }); } if (this.props.isActive && !prevProps.isActive) { this.addReplyListener(); } else if (!this.props.isActive && prevProps.isActive) { this.removeReplyListener(); } const currentText = trimMessage(this.state.text); const prevText = trimMessage(prevState.text); if ( (currentText === '' && prevText !== '') || (currentText !== '' && prevText === '') ) { this.updateSendButton(currentText); } const systemKeyboardIsShowing = ChatInputBar.systemKeyboardShowing( this.props, ); const systemKeyboardWasShowing = ChatInputBar.systemKeyboardShowing( prevProps, ); if (systemKeyboardIsShowing && !systemKeyboardWasShowing) { this.hideButtons(); } else if (!systemKeyboardIsShowing && systemKeyboardWasShowing) { this.expandButtons(); } const imageGalleryIsOpen = ChatInputBar.mediaGalleryOpen(this.props); const imageGalleryWasOpen = ChatInputBar.mediaGalleryOpen(prevProps); if (!imageGalleryIsOpen && imageGalleryWasOpen) { this.hideButtons(); } else if (imageGalleryIsOpen && !imageGalleryWasOpen) { this.expandButtons(); this.setIOSKeyboardHeight(); } } addReplyListener() { invariant( this.props.inputState, 'inputState should be set in addReplyListener', ); this.props.inputState.addReplyListener(this.focusAndUpdateText); } removeReplyListener() { invariant( this.props.inputState, 'inputState should be set in removeReplyListener', ); this.props.inputState.removeReplyListener(this.focusAndUpdateText); } setIOSKeyboardHeight() { if (Platform.OS !== 'ios') { return; } const { textInput } = this; if (!textInput) { return; } const keyboardHeight = getKeyboardHeight(); if (keyboardHeight === null || keyboardHeight === undefined) { return; } TextInputKeyboardMangerIOS.setKeyboardHeight(textInput, keyboardHeight); } get shouldShowTextInput(): boolean { if (threadHasPermission(this.props.threadInfo, threadPermissions.VOICED)) { return true; } // If the thread is created by somebody else while the viewer is attempting // to create it, the threadInfo might be modified in-place // and won't list the viewer as a member, // which will end up hiding the input. // In this case, we will assume that our creation action // will get translated into a join, and as long // as members are voiced, we can show the input. if (!this.props.threadCreationInProgress) { return false; } return checkIfDefaultMembersAreVoiced(this.props.threadInfo); } render() { const isMember = viewerIsMember(this.props.threadInfo); const canJoin = threadHasPermission( this.props.threadInfo, threadPermissions.JOIN_THREAD, ); let joinButton = null; if (!isMember && canJoin && !this.props.threadCreationInProgress) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( ); } else { const textStyle = colorIsDark(this.props.threadInfo.color) ? this.props.styles.joinButtonTextLight : this.props.styles.joinButtonTextDark; buttonContent = ( Join Chat ); } joinButton = ( ); } const typeaheadRegexMatches = getTypeaheadRegexMatches( this.state.selectionState.text, this.state.selectionState.selection, nativeTypeaheadRegex, ); let typeaheadTooltip = null; if (typeaheadRegexMatches) { const typeaheadMatchedStrings = { textBeforeAtSymbol: typeaheadRegexMatches[1] ?? '', usernamePrefix: typeaheadRegexMatches[4] ?? '', }; const suggestedUsers = getTypeaheadUserSuggestions( this.props.userSearchIndex, this.props.threadMembers, this.props.viewerID, typeaheadMatchedStrings.usernamePrefix, ); if (suggestedUsers.length > 0) { typeaheadTooltip = ( ); } } let content; const defaultMembersAreVoiced = checkIfDefaultMembersAreVoiced( this.props.threadInfo, ); if (this.shouldShowTextInput) { content = this.renderInput(); } else if ( threadFrozenDueToViewerBlock( this.props.threadInfo, this.props.viewerID, this.props.userInfos, ) && threadActualMembers(this.props.threadInfo.members).length === 2 ) { content = ( You can't send messages to a user that you've blocked. ); } else if (isMember) { content = ( You don't have permission to send messages. ); } else if (defaultMembersAreVoiced && canJoin) { content = null; } else { content = ( You don't have permission to send messages. ); } const keyboardInputHost = Platform.OS === 'android' ? null : ( ); return ( {typeaheadTooltip} {joinButton} {content} {keyboardInputHost} ); } renderInput() { const expandoButton = ( ); const threadColor = `#${this.props.threadInfo.color}`; return ( {this.state.buttonsExpanded ? expandoButton : null} {this.state.buttonsExpanded ? null : expandoButton} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; clearableTextInputRef = (clearableTextInput: ?ClearableTextInput) => { this.clearableTextInput = clearableTextInput; }; selectableTextInputRef = ( selectableTextInput: ?React.ElementRef, ) => { this.selectableTextInput = selectableTextInput; }; updateText = (text: string) => { this.setState({ text, textEdited: true }); this.saveDraft(text); }; updateSelectionState: (data: SyncedSelectionData) => void = data => { this.setState({ selectionState: data }); }; saveDraft = _throttle(text => { this.props.dispatch({ type: updateDraftActionType, payload: { key: draftKeyFromThreadID(this.props.threadInfo.id), text, }, }); }, 400); focusAndUpdateTextAndSelection = (text: string, selection: Selection) => { this.selectableTextInput?.prepareForSelectionMutation(text, selection); this.setState({ text, textEdited: true, selectionState: { text, selection }, }); this.saveDraft(text); this.focusAndUpdateButtonsVisibility(); }; focusAndUpdateText = (text: string) => { const currentText = this.state.text; if (!currentText.startsWith(text)) { const prependedText = text.concat(currentText); this.updateText(prependedText); } this.focusAndUpdateButtonsVisibility(); }; focusAndUpdateButtonsVisibility = () => { const { textInput } = this; if (!textInput) { return; } this.immediatelyShowSendButton(); this.immediatelyHideButtons(); textInput.focus(); }; onSend = async () => { if (!trimMessage(this.state.text)) { return; } this.updateSendButton(''); const { clearableTextInput } = this; invariant( clearableTextInput, 'clearableTextInput should be sent in onSend', ); let text = await clearableTextInput.getValueAndReset(); text = trimMessage(text); if (!text) { return; } const localID = `${localIDPrefix}${this.props.nextLocalID}`; const creatorID = this.props.viewerID; invariant(creatorID, 'should have viewer ID in order to send a message'); invariant( this.props.inputState, 'inputState should be set in ChatInputBar.onSend', ); this.props.inputState.sendTextMessage( { type: messageTypes.TEXT, localID, threadID: this.props.threadInfo.id, text, creatorID, time: Date.now(), }, this.props.threadInfo, ); }; onPressJoin = () => { this.props.dispatchActionPromise(joinThreadActionTypes, this.joinAction()); }; async joinAction() { const query = this.props.calendarQuery(); return await this.props.joinThread({ threadID: this.props.threadInfo.id, calendarQuery: { startDate: query.startDate, endDate: query.endDate, filters: [ ...query.filters, { type: 'threads', threadIDs: [this.props.threadInfo.id] }, ], }, }); } expandButtons = () => { if (this.state.buttonsExpanded) { return; } this.targetExpandoButtonsOpen.setValue(1); this.setState({ buttonsExpanded: true }); }; hideButtons() { if ( ChatInputBar.mediaGalleryOpen(this.props) || !this.systemKeyboardShowing || !this.state.buttonsExpanded ) { return; } this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } immediatelyHideButtons() { this.expandoButtonsOpen.setValue(0); this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } showMediaGallery = () => { const { keyboardState } = this.props; invariant(keyboardState, 'keyboardState should be initialized'); keyboardState.showMediaGallery(this.props.threadInfo); }; dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const unboundStyles = { cameraIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 8, paddingRight: 5, }, cameraRollIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 8, paddingRight: 5, }, container: { backgroundColor: 'listBackground', paddingLeft: Platform.OS === 'android' ? 10 : 5, }, expandButton: { bottom: 0, position: 'absolute', right: 0, }, expandIcon: { paddingBottom: Platform.OS === 'android' ? 13 : 11, paddingRight: 2, }, expandoButtons: { alignSelf: 'flex-end', }, explanation: { color: 'listBackgroundSecondaryLabel', paddingBottom: 4, paddingTop: 1, textAlign: 'center', }, innerExpandoButtons: { alignItems: 'flex-end', alignSelf: 'flex-end', flexDirection: 'row', }, inputContainer: { flexDirection: 'row', }, joinButton: { borderRadius: 8, flex: 1, justifyContent: 'center', marginHorizontal: 12, marginVertical: 3, }, joinButtonContainer: { flexDirection: 'row', height: 48, marginBottom: 8, }, joinButtonContent: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', }, joinButtonTextLight: { color: 'white', fontSize: 20, marginHorizontal: 4, }, joinButtonTextDark: { color: 'black', fontSize: 20, marginHorizontal: 4, }, joinThreadLoadingIndicator: { paddingVertical: 2, }, sendButton: { position: 'absolute', bottom: 4, left: 0, }, sendIcon: { paddingLeft: 9, paddingRight: 8, paddingVertical: 6, }, textInput: { backgroundColor: 'listInputBackground', borderRadius: 12, color: 'listForegroundLabel', fontSize: 16, marginLeft: 4, marginRight: 4, marginTop: 6, marginBottom: 8, maxHeight: 110, paddingHorizontal: 10, paddingVertical: 5, }, }; const joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); const createThreadLoadingStatusSelector = createLoadingStatusSelector( newThreadActionTypes, ); type ConnectedChatInputBarBaseProps = { ...BaseProps, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, }; function ConnectedChatInputBarBase(props: ConnectedChatInputBarBaseProps) { const navContext = React.useContext(NavContext); const keyboardState = React.useContext(KeyboardContext); const inputState = React.useContext(InputStateContext); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const draft = useSelector( state => state.draftStore.drafts[draftKeyFromThreadID(props.threadInfo.id)] ?? '', ); const joinThreadLoadingStatus = useSelector(joinThreadLoadingStatusSelector); const createThreadLoadingStatus = useSelector( createThreadLoadingStatusSelector, ); const threadCreationInProgress = createThreadLoadingStatus === 'loading'; const calendarQuery = useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); const nextLocalID = useSelector(state => state.nextLocalID); const userInfos = useSelector(state => state.userStore.userInfos); const styles = useStyles(unboundStyles); const colors = useColors(); const isActive = React.useMemo( () => props.threadInfo.id === activeThreadSelector(navContext), [props.threadInfo.id, navContext], ); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callJoinThread = useServerCall(joinThread); const userSearchIndex = useSelector(userStoreSearchIndex); const threadMembers = useSelector( relativeMemberInfoSelectorForMembersOfThread(props.threadInfo.id), ); return ( ); } type DummyChatInputBarProps = { ...BaseProps, +onHeightMeasured: (height: number) => mixed, }; const noop = () => {}; function DummyChatInputBar(props: DummyChatInputBarProps): React.Node { const { onHeightMeasured, ...restProps } = props; const onInputBarLayout = React.useCallback( (event: LayoutEvent) => { const { height } = event.nativeEvent.layout; onHeightMeasured(height); }, [onHeightMeasured], ); return ( ); } type ChatInputBarProps = { ...BaseProps, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; const ConnectedChatInputBar: React.ComponentType = React.memo( function ConnectedChatInputBar(props: ChatInputBarProps) { const { navigation, route, ...restProps } = props; const keyboardState = React.useContext(KeyboardContext); const { threadInfo } = props; const imagePastedCallback = React.useCallback( imagePastedEvent => { if (threadInfo.id !== imagePastedEvent.threadID) { return; } const pastedImage: PhotoPaste = { step: 'photo_paste', dimensions: { height: imagePastedEvent.height, width: imagePastedEvent.width, }, filename: imagePastedEvent.fileName, uri: 'file://' + imagePastedEvent.filePath, selectTime: 0, sendTime: 0, retries: 0, }; navigation.navigate<'ImagePasteModal'>({ name: ImagePasteModalRouteName, params: { imagePasteStagingInfo: pastedImage, thread: threadInfo, }, }); }, [navigation, threadInfo], ); React.useEffect(() => { const imagePasteListener = NativeAppEventEmitter.addListener( 'imagePasted', imagePastedCallback, ); return () => imagePasteListener.remove(); }, [imagePastedCallback]); const chatContext = React.useContext(ChatContext); invariant(chatContext, 'should be set'); const { setChatInputBarHeight, deleteChatInputBarHeight } = chatContext; const onInputBarLayout = React.useCallback( (event: LayoutEvent) => { const { height } = event.nativeEvent.layout; setChatInputBarHeight(threadInfo.id, height); }, [threadInfo.id, setChatInputBarHeight], ); React.useEffect(() => { return () => { deleteChatInputBarHeight(threadInfo.id); }; }, [deleteChatInputBarHeight, threadInfo.id]); const openCamera = React.useCallback(() => { keyboardState?.dismissKeyboard(); navigation.navigate<'CameraModal'>({ name: CameraModalRouteName, params: { presentedFrom: route.key, thread: threadInfo, }, }); }, [keyboardState, navigation, route.key, threadInfo]); return ( ); }, ); export { ConnectedChatInputBar as ChatInputBar, DummyChatInputBar }; diff --git a/native/components/selectable-text-input.js b/native/components/selectable-text-input.js index 50fd2c9ee..b8bb3cd6a 100644 --- a/native/components/selectable-text-input.js +++ b/native/components/selectable-text-input.js @@ -1,25 +1,26 @@ // @flow import * as React from 'react'; import type { Selection } from 'lib/shared/typeahead-utils.js'; import type { ClearableTextInputProps } from './clearable-text-input.js'; -import ClearableTextInput from './clearable-text-input.react.js'; +// eslint-disable-next-line import/extensions +import ClearableTextInput from './clearable-text-input.react'; export type SyncedSelectionData = { +text: string, +selection: Selection, }; export type SelectableTextInputProps = { ...ClearableTextInputProps, +clearableTextInputRef: ( clearableTextInput: ?React.ElementRef, ) => mixed, +onUpdateSyncedSelectionData: (data: SyncedSelectionData) => mixed, }; export type SelectableTextInputRef = { +prepareForSelectionMutation: (text: string, selection: Selection) => void, }; diff --git a/native/components/selectable-text-input.react.ios.js b/native/components/selectable-text-input.react.ios.js index e0f42f12c..8874bf4f7 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/typeahead-utils.js'; -import ClearableTextInput from './clearable-text-input.react.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, ): 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 ddd843303..11f74df4a 100644 --- a/native/components/selectable-text-input.react.js +++ b/native/components/selectable-text-input.react.js @@ -1,86 +1,87 @@ // @flow import * as React from 'react'; -import ClearableTextInput from './clearable-text-input.react.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, ): 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;