diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index 0800c5dd9..1678c28c2 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,1572 +1,1608 @@ // @flow import Icon from '@expo/vector-icons/Ionicons.js'; import type { GenericNavigationAction } from '@react-navigation/core'; import invariant from 'invariant'; import _throttle from 'lodash/throttle.js'; import * as React from 'react'; import { ActivityIndicator, NativeAppEventEmitter, Platform, Text, TextInput, TouchableOpacity, TouchableWithoutFeedback, View, } from 'react-native'; import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input'; import Animated, { EasingNode, FadeInDown, FadeOutDown, } from 'react-native-reanimated'; import { moveDraftActionType, updateDraftActionType, } from 'lib/actions/draft-actions.js'; import { joinThreadActionTypes, newThreadActionTypes, useJoinThread, } from 'lib/actions/thread-actions.js'; import type { UseJoinThreadInput } from 'lib/actions/thread-actions.js'; import { useChatMentionContext, useThreadChatMentionCandidates, } from 'lib/hooks/chat-mention-hooks.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { useEditMessage } from 'lib/shared/edit-messages-utils.js'; import { getTypeaheadRegexMatches, type MentionTypeaheadSuggestionItem, type Selection, type TypeaheadMatchedStrings, useMentionTypeaheadChatSuggestions, useMentionTypeaheadUserSuggestions, useUserMentionsCandidates, } from 'lib/shared/mention-utils.js'; import { messageKey, type MessagePreviewResult, trimMessage, useMessagePreview, getNextLocalID, } from 'lib/shared/message-utils.js'; import SentencePrefixSearchIndex from 'lib/shared/sentence-prefix-search-index.js'; import { checkIfDefaultMembersAreVoiced, draftKeyFromThreadID, threadActualMembers, useThreadFrozenDueToViewerBlock, useThreadHasPermission, viewerIsMember, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { SetState } from 'lib/types/hook-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-enum.js'; import type { MessageInfo } from 'lib/types/message-types.js'; import type { RelativeMemberInfo, ThreadInfo, RawThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import type { ChatMentionCandidates, ThreadJoinPayload, } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { ChatContext } from './chat-context.js'; import type { ChatNavigationProp } from './chat.react.js'; import { MessageEditingContext, type MessageEditingContextType, } from './message-editing-context.react.js'; import type { RemoveEditMode } from './message-list-types.js'; import TypeaheadTooltip from './typeahead-tooltip.react.js'; import MentionTypeaheadTooltipButton from '../chat/mention-typeahead-tooltip-button.react.js'; import Button from '../components/button.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'; // eslint-disable-next-line import/extensions import SelectableTextInput from '../components/selectable-text-input.react'; import SingleLine from '../components/single-line.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { type EditInputBarMessageParameters, - type InputState, InputStateContext, } from '../input/input-state.js'; import KeyboardInputHost from '../keyboard/keyboard-input-host.react.js'; import { KeyboardContext, type KeyboardState, } from '../keyboard/keyboard-state.js'; import { getKeyboardHeight } from '../keyboard/keyboard.js'; import { getDefaultTextMessageRules } from '../markdown/rules.react.js'; import { activeThreadSelector, nonThreadCalendarQuery, } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; -import type { OverlayContextType } from '../navigation/overlay-context.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import { ChatCameraModalRouteName, ImagePasteModalRouteName, type NavigationRoute, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; import type { ImagePasteEvent, LayoutEvent } from '../types/react-native.js'; import { AnimatedView, type AnimatedViewStyle, type ViewStyle, } from '../types/styles.js'; import Alert from '../utils/alert.js'; import { runTiming } from '../utils/animation-utils.js'; import { exitEditAlert } from '../utils/edit-messages-utils.js'; import { mentionTypeaheadTooltipActions, nativeMentionTypeaheadRegex, } from '../utils/typeahead-utils.js'; const { Value, Clock, block, set, cond, neq, sub, interpolateNode, stopClock, useValue, } = Animated; const expandoButtonsAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; 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, }, editView: { marginLeft: 20, marginRight: 20, padding: 10, flexDirection: 'row', justifyContent: 'space-between', }, editViewContent: { flex: 1, paddingRight: 6, }, exitEditButton: { marginTop: 6, }, editingLabel: { paddingBottom: 4, }, editingMessagePreview: { color: 'listForegroundLabel', }, 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: 8, color: 'listForegroundLabel', fontSize: 16, marginLeft: 4, marginRight: 4, marginTop: 6, marginBottom: 8, maxHeight: 110, paddingHorizontal: 10, paddingVertical: 5, }, }; type BaseProps = { +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, - +viewerID: ?string, +rawThreadInfo: RawThreadInfo, +draft: string, +joinThreadLoadingStatus: LoadingStatus, +threadCreationInProgress: boolean, +calendarQuery: () => CalendarQuery, +colors: Colors, +styles: $ReadOnly, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, +isActive: boolean, +keyboardState: ?KeyboardState, +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, +joinThread: (input: UseJoinThreadInput) => Promise, - +inputState: ?InputState, +userMentionsCandidates: $ReadOnlyArray, +chatMentionSearchIndex: ?SentencePrefixSearchIndex, +chatMentionCandidates: ChatMentionCandidates, - +parentThreadInfo: ?ThreadInfo, +editedMessagePreview: ?MessagePreviewResult, +editedMessageInfo: ?MessageInfo, - +editMessage: (messageID: string, text: string) => Promise, +navigation: ?ChatNavigationProp<'MessageList'>, - +overlayContext: ?OverlayContextType, +messageEditingContext: ?MessageEditingContextType, +selectionState: SyncedSelectionData, +setSelectionState: SetState, +suggestions: $ReadOnlyArray, +typeaheadMatchedStrings: ?TypeaheadMatchedStrings, - +currentUserIsVoiced: boolean, +currentUserCanJoin: boolean, +threadFrozen: boolean, +text: string, +setText: (text: string) => void, +textEdited: boolean, - +setTextEdited: (edited: boolean) => void, +buttonsExpanded: boolean, +isExitingDuringEditModeRef: { current: boolean }, +expandoButtonsStyle: AnimatedViewStyle, +cameraRollIconStyle: AnimatedViewStyle, +cameraIconStyle: AnimatedViewStyle, +expandIconStyle: AnimatedViewStyle, +sendButtonContainerStyle: AnimatedViewStyle, +shouldShowTextInput: () => boolean, +isEditMode: () => boolean, - +immediatelyShowSendButton: () => void, +updateSendButton: (currentText: string) => void, +expandButtons: () => void, +hideButtons: () => void, - +immediatelyHideButtons: () => void, +textInputRef: { current: ?React.ElementRef }, +clearableTextInputRef: { current: ?ClearableTextInput }, +selectableTextInputRef: { current: ?React.ElementRef, }, +setTextInputRef: (ref: ?React.ElementRef) => void, +setClearableTextInputRef: (ref: ?ClearableTextInput) => void, + +addEditInputMessageListener: () => void, + +removeEditInputMessageListener: () => void, + +focusAndUpdateTextAndSelection: ( + newText: string, + selection: Selection, + ) => void, + +scrollToEditedMessage: () => void, + +onPressExitEditMode: () => void, + +updateText: (newText: string) => void, + +onSend: () => Promise, + +removeEditMode: RemoveEditMode, + +exitEditMode: () => void, + +isMessageEdited: (newText?: string) => boolean, }; class ChatInputBar extends React.PureComponent { clearBeforeRemoveListener: () => void; clearFocusListener: () => void; clearBlurListener: () => void; static mediaGalleryOpen(props: Props): boolean { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } static systemKeyboardShowing(props: Props): boolean { const { keyboardState } = props; return !!(keyboardState && keyboardState.systemKeyboardShowing); } get systemKeyboardShowing(): boolean { return ChatInputBar.systemKeyboardShowing(this.props); } componentDidMount() { const { isActive, navigation } = this.props; if (isActive) { - this.addEditInputMessageListener(); + this.props.addEditInputMessageListener(); } if (!navigation) { return; } this.clearBeforeRemoveListener = navigation.addListener( 'beforeRemove', this.onNavigationBeforeRemove, ); this.clearFocusListener = navigation.addListener( 'focus', this.onNavigationFocus, ); this.clearBlurListener = navigation.addListener( 'blur', this.onNavigationBlur, ); } componentWillUnmount() { if (this.props.isActive) { - this.removeEditInputMessageListener(); + this.props.removeEditInputMessageListener(); } if (this.clearBeforeRemoveListener) { this.clearBeforeRemoveListener(); } if (this.clearFocusListener) { this.clearFocusListener(); } if (this.clearBlurListener) { this.clearBlurListener(); } } componentDidUpdate(prevProps: Props) { if ( this.props.textEdited && this.props.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.props.textEdited && this.props.draft !== prevProps.draft) { this.props.setText(this.props.draft); } if (this.props.isActive && !prevProps.isActive) { - this.addEditInputMessageListener(); + this.props.addEditInputMessageListener(); } else if (!this.props.isActive && prevProps.isActive) { - this.removeEditInputMessageListener(); + this.props.removeEditInputMessageListener(); } const currentText = trimMessage(this.props.text); const prevText = trimMessage(prevProps.text); if ( (currentText === '' && prevText !== '') || (currentText !== '' && prevText === '') ) { this.props.updateSendButton(currentText); } const systemKeyboardIsShowing = ChatInputBar.systemKeyboardShowing( this.props, ); const systemKeyboardWasShowing = ChatInputBar.systemKeyboardShowing(prevProps); if (systemKeyboardIsShowing && !systemKeyboardWasShowing) { this.props.hideButtons(); } else if (!systemKeyboardIsShowing && systemKeyboardWasShowing) { this.props.expandButtons(); } const imageGalleryIsOpen = ChatInputBar.mediaGalleryOpen(this.props); const imageGalleryWasOpen = ChatInputBar.mediaGalleryOpen(prevProps); if (!imageGalleryIsOpen && imageGalleryWasOpen) { this.props.hideButtons(); } else if (imageGalleryIsOpen && !imageGalleryWasOpen) { this.props.expandButtons(); this.setIOSKeyboardHeight(); } if ( this.props.messageEditingContext?.editState.editedMessage && !prevProps.messageEditingContext?.editState.editedMessage ) { this.blockNavigation(); } } - addEditInputMessageListener() { - invariant( - this.props.inputState, - 'inputState should be set in addEditInputMessageListener', - ); - this.props.inputState.addEditInputMessageListener(this.focusAndUpdateText); - } - - removeEditInputMessageListener() { - invariant( - this.props.inputState, - 'inputState should be set in removeEditInputMessageListener', - ); - this.props.inputState.removeEditInputMessageListener( - this.focusAndUpdateText, - ); - } - setIOSKeyboardHeight() { if (Platform.OS !== 'ios') { return; } const textInput = this.props.textInputRef.current; if (!textInput) { return; } const keyboardHeight = getKeyboardHeight(); if (keyboardHeight === null || keyboardHeight === undefined) { return; } TextInputKeyboardMangerIOS.setKeyboardHeight(textInput, keyboardHeight); } render(): React.Node { const isMember = viewerIsMember(this.props.threadInfo); let joinButton = null; const threadColor = `#${this.props.threadInfo.color}`; const isEditMode = this.props.isEditMode(); if ( !isMember && this.props.currentUserCanJoin && !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 = ( ); } let typeaheadTooltip = null; if ( this.props.suggestions.length > 0 && this.props.typeaheadMatchedStrings && !isEditMode ) { typeaheadTooltip = ( ); } let content; const defaultMembersAreVoiced = checkIfDefaultMembersAreVoiced( this.props.threadInfo, ); if (this.props.shouldShowTextInput()) { content = this.renderInput(); } else if ( this.props.threadFrozen && 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 && this.props.currentUserCanJoin) { content = null; } else { content = ( You don’t have permission to send messages. ); } const keyboardInputHost = Platform.OS === 'android' ? null : ( ); let editedMessage; if (isEditMode && this.props.editedMessagePreview) { const { message } = this.props.editedMessagePreview; editedMessage = ( Editing message {message.text} ); } return ( {typeaheadTooltip} {joinButton} {editedMessage} {content} {keyboardInputHost} ); } renderInput(): React.Node { const expandoButton = ( ); const threadColor = `#${this.props.threadInfo.color}`; const expandoButtonsViewStyle: Array = [ this.props.styles.innerExpandoButtons, ]; if (this.props.isEditMode()) { expandoButtonsViewStyle.push({ display: 'none' }); } return ( {this.props.buttonsExpanded ? expandoButton : null} {this.props.buttonsExpanded ? null : expandoButton} ); } - updateText = (text: string) => { - if (this.props.isExitingDuringEditModeRef.current) { - return; - } - this.props.setText(text); - this.props.setTextEdited(true); - this.props.messageEditingContext?.setEditedMessageChanged( - this.isMessageEdited(text), - ); - if (this.props.isEditMode()) { - return; - } - this.saveDraft(text); - }; - - saveDraft: (text: string) => void = _throttle(text => { - this.props.dispatch({ - type: updateDraftActionType, - payload: { - key: draftKeyFromThreadID(this.props.threadInfo.id), - text, - }, - }); - }, 400); - - focusAndUpdateTextAndSelection = (text: string, selection: Selection) => { - this.props.selectableTextInputRef.current?.prepareForSelectionMutation( - text, - selection, - ); - this.props.setText(text); - this.props.setTextEdited(true); - this.props.setSelectionState({ text, selection }); - this.saveDraft(text); - - this.focusAndUpdateButtonsVisibility(); - }; - - focusAndUpdateText = (params: EditInputBarMessageParameters) => { - const { message: text, mode } = params; - const currentText = this.props.text; - if (mode === 'replace') { - this.updateText(text); - } else if (!currentText.startsWith(text)) { - const prependedText = text.concat(currentText); - this.updateText(prependedText); - } - - this.focusAndUpdateButtonsVisibility(); - }; - - focusAndUpdateButtonsVisibility = () => { - const textInput = this.props.textInputRef.current; - - if (!textInput) { - return; - } - - this.props.immediatelyShowSendButton(); - this.props.immediatelyHideButtons(); - textInput.focus(); - }; - - onSend = async () => { - if (!trimMessage(this.props.text)) { - return; - } - - const editedMessage = this.getEditedMessage(); - if (editedMessage && editedMessage.id) { - await this.editMessage(editedMessage.id, this.props.text); - return; - } - - this.props.updateSendButton(''); - - const clearableTextInput = this.props.clearableTextInputRef.current; - invariant( - clearableTextInput, - 'clearableTextInput should be sent in onSend', - ); - let text = await clearableTextInput.getValueAndReset(); - text = trimMessage(text); - if (!text) { - return; - } - - const localID = getNextLocalID(); - 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', - ); - - await this.props.inputState.sendTextMessage( - { - type: messageTypes.TEXT, - localID, - threadID: this.props.threadInfo.id, - text, - creatorID, - time: Date.now(), - }, - this.props.threadInfo, - this.props.parentThreadInfo, - ); - }; - - isMessageEdited = (newText?: string): boolean => { - let text = newText ?? this.props.text; - text = trimMessage(text); - const originalText = this.props.editedMessageInfo?.text; - return text !== originalText; - }; - - unblockNavigation = () => { - const { navigation } = this.props; - if (!navigation) { - return; - } - navigation.setParams({ removeEditMode: null }); - }; - - removeEditMode: RemoveEditMode = action => { - const { navigation } = this.props; - if (!navigation || this.props.isExitingDuringEditModeRef.current) { - return 'ignore_action'; - } - if (!this.isMessageEdited()) { - this.unblockNavigation(); - return 'reduce_action'; - } - const unblockAndDispatch = () => { - this.unblockNavigation(); - navigation.dispatch(action); - }; - const onContinueEditing = () => { - this.props.overlayContext?.resetScrollBlockingModalStatus(); - }; - exitEditAlert({ - onDiscard: unblockAndDispatch, - onContinueEditing, - }); - return 'ignore_action'; - }; - blockNavigation = () => { const { navigation } = this.props; if (!navigation || !navigation.isFocused()) { return; } navigation.setParams({ - removeEditMode: this.removeEditMode, - }); - }; - - editMessage = async (messageID: string, text: string) => { - if (!this.isMessageEdited()) { - this.exitEditMode(); - return; - } - text = trimMessage(text); - try { - await this.props.editMessage(messageID, text); - this.exitEditMode(); - } catch (error) { - Alert.alert( - 'Couldn’t edit the message', - 'Please try again later', - [{ text: 'OK' }], - { - cancelable: true, - }, - ); - } - }; - - getEditedMessage = (): ?MessageInfo => { - const editState = this.props.messageEditingContext?.editState; - return editState?.editedMessage; - }; - - onPressExitEditMode = () => { - if (!this.isMessageEdited()) { - this.exitEditMode(); - return; - } - exitEditAlert({ - onDiscard: this.exitEditMode, - }); - }; - - scrollToEditedMessage = () => { - const editedMessage = this.getEditedMessage(); - if (!editedMessage) { - return; - } - const editedMessageKey = messageKey(editedMessage); - this.props.inputState?.scrollToMessage(editedMessageKey); - }; - - exitEditMode = () => { - this.props.messageEditingContext?.setEditedMessage(null, () => { - this.unblockNavigation(); - this.updateText(this.props.draft); - this.focusAndUpdateButtonsVisibility(); - this.props.updateSendButton(this.props.draft); + removeEditMode: this.props.removeEditMode, }); }; onNavigationFocus = () => { this.props.isExitingDuringEditModeRef.current = false; }; onNavigationBlur = () => { if (!this.props.isEditMode()) { return; } this.props.setText(this.props.draft); this.props.isExitingDuringEditModeRef.current = true; - this.exitEditMode(); + this.props.exitEditMode(); }; onNavigationBeforeRemove = (e: { +data: { +action: GenericNavigationAction }, +preventDefault: () => void, ... }) => { if (!this.props.isEditMode()) { return; } const { action } = e.data; e.preventDefault(); const saveExit = () => { this.props.messageEditingContext?.setEditedMessage(null, () => { this.props.isExitingDuringEditModeRef.current = true; this.props.navigation?.dispatch(action); }); }; - if (!this.isMessageEdited()) { + if (!this.props.isMessageEdited()) { saveExit(); return; } exitEditAlert({ onDiscard: saveExit, }); }; onPressJoin = () => { void this.props.dispatchActionPromise( joinThreadActionTypes, this.joinAction(), ); }; async joinAction(): Promise { let joinThreadInput; if (this.props.rawThreadInfo.thick) { joinThreadInput = { thick: true, rawThreadInfo: this.props.rawThreadInfo, }; } else { const query = this.props.calendarQuery(); joinThreadInput = { thick: false, threadID: this.props.threadInfo.id, calendarQuery: { startDate: query.startDate, endDate: query.endDate, filters: [ ...query.filters, { type: 'threads', threadIDs: [this.props.threadInfo.id] }, ], }, }; } return await this.props.joinThread(joinThreadInput); } 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 joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); const createThreadLoadingStatusSelector = createLoadingStatusSelector(newThreadActionTypes); type ConnectedChatInputBarBaseProps = { ...BaseProps, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, +navigation?: ChatNavigationProp<'MessageList'>, }; function ConnectedChatInputBarBase(props: ConnectedChatInputBarBaseProps) { const navContext = React.useContext(NavContext); const keyboardState = React.useContext(KeyboardContext); const inputState = React.useContext(InputStateContext); const overlayContext = React.useContext(OverlayContext); 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 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 rawThreadInfo = useSelector( state => state.threadStore.threadInfos[props.threadInfo.id], ); const callJoinThread = useJoinThread(); const { getChatMentionSearchIndex } = useChatMentionContext(); const chatMentionSearchIndex = getChatMentionSearchIndex(props.threadInfo); const { parentThreadID, community } = props.threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const threadFrozen = useThreadFrozenDueToViewerBlock( props.threadInfo, communityThreadInfo, viewerID, userInfos, ); const userMentionsCandidates = useUserMentionsCandidates( props.threadInfo, parentThreadInfo, ); const chatMentionCandidates = useThreadChatMentionCandidates( props.threadInfo, ); const messageEditingContext = React.useContext(MessageEditingContext); const editedMessageInfo = messageEditingContext?.editState.editedMessage; const editedMessagePreview = useMessagePreview( editedMessageInfo, props.threadInfo, getDefaultTextMessageRules(chatMentionCandidates).simpleMarkdownRules, ); const editMessage = useEditMessage(props.threadInfo); const [selectionState, setSelectionState] = React.useState({ text: draft, selection: { start: 0, end: 0 }, }); const [text, setText] = React.useState(draft); const [textEdited, setTextEdited] = React.useState(false); const [buttonsExpanded, setButtonsExpanded] = React.useState(true); const typeaheadRegexMatches = React.useMemo( () => getTypeaheadRegexMatches( selectionState.text, selectionState.selection, nativeMentionTypeaheadRegex, ), [selectionState.text, selectionState.selection], ); const typeaheadMatchedStrings: ?TypeaheadMatchedStrings = React.useMemo(() => { if (typeaheadRegexMatches === null) { return null; } return { textBeforeAtSymbol: typeaheadRegexMatches[1] ?? '', query: typeaheadRegexMatches[4] ?? '', }; }, [typeaheadRegexMatches]); const suggestedUsers = useMentionTypeaheadUserSuggestions( userMentionsCandidates, typeaheadMatchedStrings, ); const suggestedChats = useMentionTypeaheadChatSuggestions( chatMentionSearchIndex, chatMentionCandidates, typeaheadMatchedStrings, ); const suggestions: $ReadOnlyArray = React.useMemo( () => [...suggestedUsers, ...suggestedChats], [suggestedUsers, suggestedChats], ); const currentUserIsVoiced = useThreadHasPermission( props.threadInfo, threadPermissions.VOICED, ); const currentUserCanJoin = useThreadHasPermission( props.threadInfo, threadPermissions.JOIN_THREAD, ); const isExitingDuringEditModeRef = React.useRef(false); const expandoButtonsOpen = useValue(1); const targetExpandoButtonsOpen = useValue(1); const initialSendButtonContainerOpen = trimMessage(draft) ? 1 : 0; const sendButtonContainerOpen = useValue(initialSendButtonContainerOpen); const targetSendButtonContainerOpen = useValue( initialSendButtonContainerOpen, ); const iconsOpacity = React.useMemo(() => { const prevTargetExpandoButtonsOpen = new Value(1); const expandoButtonClock = new Clock(); return block([ cond(neq(targetExpandoButtonsOpen, prevTargetExpandoButtonsOpen), [ stopClock(expandoButtonClock), set(prevTargetExpandoButtonsOpen, targetExpandoButtonsOpen), ]), cond( neq(expandoButtonsOpen, targetExpandoButtonsOpen), set( expandoButtonsOpen, runTiming( expandoButtonClock, expandoButtonsOpen, targetExpandoButtonsOpen, true, expandoButtonsAnimationConfig, ), ), ), expandoButtonsOpen, ]); }, [expandoButtonsOpen, targetExpandoButtonsOpen]); const expandoButtonsWidth = React.useMemo( () => interpolateNode(iconsOpacity, { inputRange: [0, 1], outputRange: [26, 66], }), [iconsOpacity], ); const expandOpacity = React.useMemo( () => sub(1, iconsOpacity), [iconsOpacity], ); const sendButtonContainerWidth = React.useMemo(() => { const prevTargetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const sendButtonClock = new Clock(); const animatedSendButtonContainerOpen = block([ cond( neq(targetSendButtonContainerOpen, prevTargetSendButtonContainerOpen), [ stopClock(sendButtonClock), set(prevTargetSendButtonContainerOpen, targetSendButtonContainerOpen), ], ), cond( neq(sendButtonContainerOpen, targetSendButtonContainerOpen), set( sendButtonContainerOpen, runTiming( sendButtonClock, sendButtonContainerOpen, targetSendButtonContainerOpen, true, sendButtonAnimationConfig, ), ), ), sendButtonContainerOpen, ]); return interpolateNode(animatedSendButtonContainerOpen, { inputRange: [0, 1], outputRange: [4, 38], }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [sendButtonContainerOpen, targetSendButtonContainerOpen]); const cameraRollIconStyle = React.useMemo( () => ({ ...unboundStyles.cameraRollIcon, opacity: iconsOpacity, }), [iconsOpacity], ); const cameraIconStyle = React.useMemo( () => ({ ...unboundStyles.cameraIcon, opacity: iconsOpacity, }), [iconsOpacity], ); const expandoButtonsStyle = React.useMemo( () => ({ ...unboundStyles.expandoButtons, width: expandoButtonsWidth, }), [expandoButtonsWidth], ); const expandIconStyle = React.useMemo( () => ({ ...unboundStyles.expandIcon, opacity: expandOpacity, }), [expandOpacity], ); const sendButtonContainerStyle = React.useMemo( () => ({ width: sendButtonContainerWidth, }), [sendButtonContainerWidth], ); const shouldShowTextInput = React.useCallback(() => { if (currentUserIsVoiced) { 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 (!threadCreationInProgress) { return false; } return checkIfDefaultMembersAreVoiced(props.threadInfo); }, [currentUserIsVoiced, props.threadInfo, threadCreationInProgress]); const isEditMode = React.useCallback(() => { const editState = messageEditingContext?.editState; const isThisThread = editState?.editedMessage?.threadID === props.threadInfo.id; return editState?.editedMessage !== null && isThisThread; }, [messageEditingContext?.editState, props.threadInfo.id]); const immediatelyShowSendButton = React.useCallback(() => { sendButtonContainerOpen.setValue(1); targetSendButtonContainerOpen.setValue(1); }, [sendButtonContainerOpen, targetSendButtonContainerOpen]); const updateSendButton = React.useCallback( (currentText: string) => { const targetValue = currentText === '' ? 0 : 1; targetSendButtonContainerOpen.setValue(targetValue); if (!shouldShowTextInput()) { sendButtonContainerOpen.setValue(targetValue); } }, [ sendButtonContainerOpen, shouldShowTextInput, targetSendButtonContainerOpen, ], ); const expandButtons = React.useCallback(() => { if (buttonsExpanded || isEditMode()) { return; } targetExpandoButtonsOpen.setValue(1); setButtonsExpanded(true); }, [buttonsExpanded, isEditMode, targetExpandoButtonsOpen]); const hideButtons = React.useCallback(() => { if ( keyboardState?.mediaGalleryOpen || !keyboardState?.systemKeyboardShowing || !buttonsExpanded ) { return; } targetExpandoButtonsOpen.setValue(0); setButtonsExpanded(false); }, [ buttonsExpanded, keyboardState?.mediaGalleryOpen, keyboardState?.systemKeyboardShowing, targetExpandoButtonsOpen, ]); const immediatelyHideButtons = React.useCallback(() => { expandoButtonsOpen.setValue(0); targetExpandoButtonsOpen.setValue(0); setButtonsExpanded(false); }, [expandoButtonsOpen, targetExpandoButtonsOpen]); const textInputRef = React.useRef>(); const clearableTextInputRef = React.useRef(); const selectableTextInputRef = React.useRef>(); const setTextInputRef = React.useCallback( (ref: ?React.ElementRef) => { textInputRef.current = ref; }, [], ); const setClearableTextInputRef = React.useCallback( (ref: ?ClearableTextInput) => { clearableTextInputRef.current = ref; }, [], ); + const saveDraft = React.useMemo( + () => + _throttle(newText => { + dispatch({ + type: updateDraftActionType, + payload: { + key: draftKeyFromThreadID(props.threadInfo.id), + text: newText, + }, + }); + }, 400), + [dispatch, props.threadInfo.id], + ); + + const isMessageEdited = React.useCallback( + (newText?: string): boolean => { + let updatedText = newText ?? text; + updatedText = trimMessage(updatedText); + const originalText = editedMessageInfo?.text; + return updatedText !== originalText; + }, + [editedMessageInfo?.text, text], + ); + + const updateText = React.useCallback( + (newText: string) => { + if (isExitingDuringEditModeRef.current) { + return; + } + setText(newText); + setTextEdited(true); + messageEditingContext?.setEditedMessageChanged(isMessageEdited(newText)); + if (isEditMode()) { + return; + } + saveDraft(newText); + }, + [isEditMode, isMessageEdited, messageEditingContext, saveDraft], + ); + + const focusAndUpdateButtonsVisibility = React.useCallback(() => { + const textInput = textInputRef.current; + + if (!textInput) { + return; + } + + immediatelyShowSendButton(); + immediatelyHideButtons(); + textInput.focus(); + }, [immediatelyHideButtons, immediatelyShowSendButton]); + + const unblockNavigation = React.useCallback(() => { + const { navigation } = props; + if (!navigation) { + return; + } + navigation.setParams({ removeEditMode: null }); + }, [props]); + + const exitEditMode = React.useCallback(() => { + messageEditingContext?.setEditedMessage(null, () => { + unblockNavigation(); + updateText(draft); + focusAndUpdateButtonsVisibility(); + updateSendButton(draft); + }); + }, [ + draft, + focusAndUpdateButtonsVisibility, + messageEditingContext, + unblockNavigation, + updateSendButton, + updateText, + ]); + + const editMessageInner = React.useCallback( + async (messageID: string, newText: string) => { + if (!isMessageEdited()) { + exitEditMode(); + return; + } + newText = trimMessage(newText); + try { + await editMessage(messageID, newText); + exitEditMode(); + } catch (error) { + Alert.alert( + 'Couldn’t edit the message', + 'Please try again later', + [{ text: 'OK' }], + { + cancelable: true, + }, + ); + } + }, + [editMessage, exitEditMode, isMessageEdited], + ); + + const focusAndUpdateText = React.useCallback( + (params: EditInputBarMessageParameters) => { + const { message, mode } = params; + const currentText = text; + if (mode === 'replace') { + updateText(message); + } else if (!currentText.startsWith(message)) { + const prependedText = message.concat(currentText); + updateText(prependedText); + } + + focusAndUpdateButtonsVisibility(); + }, + [focusAndUpdateButtonsVisibility, text, updateText], + ); + + const addEditInputMessageListener = React.useCallback(() => { + invariant( + inputState, + 'inputState should be set in addEditInputMessageListener', + ); + inputState.addEditInputMessageListener(focusAndUpdateText); + }, [focusAndUpdateText, inputState]); + + const removeEditInputMessageListener = React.useCallback(() => { + invariant( + inputState, + 'inputState should be set in removeEditInputMessageListener', + ); + inputState.removeEditInputMessageListener(focusAndUpdateText); + }, [focusAndUpdateText, inputState]); + + const focusAndUpdateTextAndSelection = React.useCallback( + (newText: string, selection: Selection) => { + selectableTextInputRef.current?.prepareForSelectionMutation( + newText, + selection, + ); + setText(newText); + setTextEdited(true); + setSelectionState({ text: newText, selection }); + saveDraft(newText); + + focusAndUpdateButtonsVisibility(); + }, + [focusAndUpdateButtonsVisibility, saveDraft], + ); + + const getEditedMessage = React.useCallback((): ?MessageInfo => { + const editState = messageEditingContext?.editState; + return editState?.editedMessage; + }, [messageEditingContext?.editState]); + + const onSend = React.useCallback(async () => { + if (!trimMessage(text)) { + return; + } + + const editedMessage = getEditedMessage(); + if (editedMessage && editedMessage.id) { + await editMessageInner(editedMessage.id, text); + return; + } + + updateSendButton(''); + + const clearableTextInput = clearableTextInputRef.current; + invariant( + clearableTextInput, + 'clearableTextInput should be sent in onSend', + ); + let newText = await clearableTextInput.getValueAndReset(); + newText = trimMessage(newText); + if (!newText) { + return; + } + + const localID = getNextLocalID(); + const creatorID = viewerID; + invariant(creatorID, 'should have viewer ID in order to send a message'); + invariant(inputState, 'inputState should be set in ChatInputBar.onSend'); + + await inputState.sendTextMessage( + { + type: messageTypes.TEXT, + localID, + threadID: props.threadInfo.id, + text: newText, + creatorID, + time: Date.now(), + }, + props.threadInfo, + parentThreadInfo, + ); + }, [ + editMessageInner, + getEditedMessage, + inputState, + parentThreadInfo, + props.threadInfo, + text, + updateSendButton, + viewerID, + ]); + + const removeEditMode: RemoveEditMode = React.useCallback( + action => { + const { navigation } = props; + if (!navigation || isExitingDuringEditModeRef.current) { + return 'ignore_action'; + } + if (!isMessageEdited()) { + unblockNavigation(); + return 'reduce_action'; + } + const unblockAndDispatch = () => { + unblockNavigation(); + navigation.dispatch(action); + }; + const onContinueEditing = () => { + overlayContext?.resetScrollBlockingModalStatus(); + }; + exitEditAlert({ + onDiscard: unblockAndDispatch, + onContinueEditing, + }); + return 'ignore_action'; + }, + [isMessageEdited, overlayContext, props, unblockNavigation], + ); + + const onPressExitEditMode = React.useCallback(() => { + if (!isMessageEdited()) { + exitEditMode(); + return; + } + exitEditAlert({ + onDiscard: exitEditMode, + }); + }, [exitEditMode, isMessageEdited]); + + const scrollToEditedMessage = React.useCallback(() => { + const editedMessage = getEditedMessage(); + if (!editedMessage) { + return; + } + const editedMessageKey = messageKey(editedMessage); + inputState?.scrollToMessage(editedMessageKey); + }, [getEditedMessage, inputState]); + 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: ImagePasteEvent) => { 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<'ChatCameraModal'>({ name: ChatCameraModalRouteName, params: { presentedFrom: route.key, thread: threadInfo, }, }); }, [keyboardState, navigation, route.key, threadInfo]); return ( ); }); export { ConnectedChatInputBar as ChatInputBar, DummyChatInputBar };