diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index c2b7e300a..408f28c26 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,665 +1,714 @@ // @flow import type { AppState } from '../redux/redux-setup'; import type { DispatchActionPayload, DispatchActionPromise, } from 'lib/utils/action-utils'; import { messageTypes } from 'lib/types/message-types'; import { type ThreadInfo, threadInfoPropType, threadPermissions, type ClientThreadJoinRequest, type ThreadJoinPayload, } from 'lib/types/thread-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import { loadingStatusPropType } from 'lib/types/loading-types'; import type { CalendarQuery } from 'lib/types/entry-types'; import { type KeyboardState, keyboardStatePropType, withKeyboardState, } from '../keyboard/keyboard-state'; import { messageListRoutePropType, messageListNavPropType, } from './message-list-types'; import { type InputState, inputStatePropType, withInputState, } from '../input/input-state'; import type { ChatNavigationProp } from './chat.react'; import type { NavigationRoute } from '../navigation/route-names'; import * as React from 'react'; import { View, TextInput, TouchableOpacity, Platform, Text, ActivityIndicator, TouchableWithoutFeedback, } from 'react-native'; import Icon from 'react-native-vector-icons/Ionicons'; import FAIcon from 'react-native-vector-icons/FontAwesome'; import PropTypes from 'prop-types'; import invariant from 'invariant'; import Animated, { Easing } from 'react-native-reanimated'; import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input'; import _throttle from 'lodash/throttle'; import { connect } from 'lib/utils/redux-utils'; import { saveDraftActionType } from 'lib/actions/miscellaneous-action-types'; import { threadHasPermission, viewerIsMember } from 'lib/shared/thread-utils'; import { joinThreadActionTypes, joinThread } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { trimMessage } from 'lib/shared/message-utils'; import Button from '../components/button.react'; -import { nonThreadCalendarQuery } from '../navigation/nav-selectors'; +import { + nonThreadCalendarQuery, + activeThreadSelector, +} from '../navigation/nav-selectors'; import { getKeyboardHeight } from '../keyboard/keyboard'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../themes/colors'; import { CameraModalRouteName } from '../navigation/route-names'; import KeyboardInputHost from '../keyboard/keyboard-input-host.react'; import { connectNav, type NavContextType, } from '../navigation/navigation-context'; import ClearableTextInput from '../components/clearable-text-input.react'; const draftKeyFromThreadID = (threadID: string) => `${threadID}/message_composer`; type Props = {| threadInfo: ThreadInfo, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, + isActive: boolean, // Redux state viewerID: ?string, draft: string, joinThreadLoadingStatus: LoadingStatus, calendarQuery: () => CalendarQuery, nextLocalID: number, colors: Colors, styles: typeof styles, // withKeyboardState keyboardState: ?KeyboardState, // Redux dispatch functions dispatchActionPayload: DispatchActionPayload, dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs joinThread: (request: ClientThreadJoinRequest) => Promise, // withInputState inputState: ?InputState, |}; type State = {| text: string, buttonsExpanded: boolean, |}; class ChatInputBar extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, navigation: messageListNavPropType.isRequired, route: messageListRoutePropType.isRequired, + isActive: PropTypes.bool.isRequired, viewerID: PropTypes.string, draft: PropTypes.string.isRequired, joinThreadLoadingStatus: loadingStatusPropType.isRequired, calendarQuery: PropTypes.func.isRequired, nextLocalID: PropTypes.number.isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, keyboardState: keyboardStatePropType, dispatchActionPayload: PropTypes.func.isRequired, dispatchActionPromise: PropTypes.func.isRequired, joinThread: PropTypes.func.isRequired, inputState: inputStatePropType, }; textInput: ?React.ElementRef; clearableTextInput: ?ClearableTextInput; expandOpacity: Animated.Value; expandoButtonsOpacity: Animated.Value; expandoButtonsWidth: Animated.Value; sendButtonContainerOpen: Animated.Value; sendButtonContainerWidth: Animated.Value; constructor(props: Props) { super(props); this.state = { text: props.draft, buttonsExpanded: true, }; // eslint-disable-next-line import/no-named-as-default-member this.expandoButtonsOpacity = new Animated.Value(1); this.expandOpacity = Animated.sub(1, this.expandoButtonsOpacity); this.expandoButtonsWidth = Animated.interpolate( this.expandoButtonsOpacity, { inputRange: [0, 1], outputRange: [22, 60], }, ); // eslint-disable-next-line import/no-named-as-default-member this.sendButtonContainerOpen = new Animated.Value( trimMessage(props.draft) ? 1 : 0, ); this.sendButtonContainerWidth = Animated.interpolate( this.sendButtonContainerOpen, { inputRange: [0, 1], outputRange: [4, 38], }, ); } 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); } updateSendButton(currentText: string) { // eslint-disable-next-line import/no-named-as-default-member Animated.timing(this.sendButtonContainerOpen, { duration: 150, toValue: currentText === '' ? 0 : 1, easing: Easing.inOut(Easing.ease), }).start(); } + componentDidMount() { + if (this.props.isActive) { + this.addReplyListener(); + } + } + + componentWillUnmount() { + if (this.props.isActive) { + this.removeReplyListener(); + } + } + componentDidUpdate(prevProps: Props, prevState: State) { + 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 expandoButtonsStyle() { return { ...this.props.styles.expandoButtons, width: this.expandoButtonsWidth, }; } get cameraRollIconStyle() { return { ...this.props.styles.cameraRollIcon, opacity: this.expandoButtonsOpacity, }; } get cameraIconStyle() { return { ...this.props.styles.cameraIcon, opacity: this.expandoButtonsOpacity, }; } get expandIconStyle() { return { ...this.props.styles.expandIcon, opacity: this.expandOpacity, }; } render() { const isMember = viewerIsMember(this.props.threadInfo); let joinButton = null; if ( !isMember && threadHasPermission(this.props.threadInfo, threadPermissions.JOIN_THREAD) ) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( ); } else { buttonContent = ( Join Thread ); } joinButton = ( ); } let content; if (threadHasPermission(this.props.threadInfo, threadPermissions.VOICED)) { content = this.renderInput(); } else if (isMember) { content = ( You don't have permission to send messages. ); } else { const defaultRoleID = Object.keys(this.props.threadInfo.roles).find( roleID => this.props.threadInfo.roles[roleID].isDefault, ); invariant( defaultRoleID !== undefined, 'all threads should have a default role', ); const defaultRole = this.props.threadInfo.roles[defaultRoleID]; const membersAreVoiced = !!defaultRole.permissions[ threadPermissions.VOICED ]; if (membersAreVoiced) { content = ( Join this thread to send messages. ); } else { content = ( You don't have permission to send messages. ); } } const keyboardInputHost = Platform.OS === 'android' ? null : ( ); return ( {joinButton} {content} {keyboardInputHost} ); } renderInput() { const expandoButton = ( ); const sendButtonContainerStyle = { width: this.sendButtonContainerWidth }; return ( {this.state.buttonsExpanded ? expandoButton : null} {this.state.buttonsExpanded ? null : expandoButton} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; clearableTextInputRef = (clearableTextInput: ?ClearableTextInput) => { this.clearableTextInput = clearableTextInput; }; updateText = (text: string) => { this.setState({ text }); this.saveDraft(text); }; saveDraft = _throttle((text: string) => { this.props.dispatchActionPayload(saveDraftActionType, { key: draftKeyFromThreadID(this.props.threadInfo.id), draft: text, }); }, 400); + focusAndUpdateText = (text: string) => { + this.updateText(text); + invariant(this.textInput, 'textInput should be set in focusAndUpdateText'); + this.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 = `local${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(), }); }; 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; } // eslint-disable-next-line import/no-named-as-default-member Animated.timing(this.expandoButtonsOpacity, { duration: 500, toValue: 1, easing: Easing.inOut(Easing.ease), }).start(); this.setState({ buttonsExpanded: true }); }; hideButtons() { if ( ChatInputBar.mediaGalleryOpen(this.props) || !this.systemKeyboardShowing || !this.state.buttonsExpanded ) { return; } // eslint-disable-next-line import/no-named-as-default-member Animated.timing(this.expandoButtonsOpacity, { duration: 500, toValue: 0, easing: Easing.inOut(Easing.ease), }).start(); this.setState({ buttonsExpanded: false }); } openCamera = async () => { this.dismissKeyboard(); this.props.navigation.navigate({ name: CameraModalRouteName, params: { presentedFrom: this.props.route.key, threadID: this.props.threadInfo.id, }, }); }; showMediaGallery = () => { const { keyboardState } = this.props; invariant(keyboardState, 'keyboardState should be initialized'); keyboardState.showMediaGallery(this.props.threadInfo.id); }; dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const styles = { cameraIcon: { paddingBottom: 10, paddingRight: 3, }, cameraRollIcon: { paddingBottom: Platform.OS === 'ios' ? 5 : 8, paddingRight: 8, }, container: { backgroundColor: 'listBackground', }, expandButton: { bottom: 0, position: 'absolute', right: 0, }, expandIcon: { paddingBottom: Platform.OS === 'ios' ? 10 : 12, }, 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: { backgroundColor: 'mintButton', borderRadius: 5, flex: 1, justifyContent: 'center', marginHorizontal: 12, marginVertical: 3, paddingBottom: 5, paddingTop: 3, }, joinButtonContainer: { flexDirection: 'row', height: 36, }, joinButtonText: { color: 'listBackground', fontSize: 20, textAlign: 'center', }, joinThreadLoadingIndicator: { paddingVertical: 2, }, sendButton: { position: 'absolute', bottom: Platform.OS === 'android' ? 3 : 0, left: 0, }, sendIcon: { paddingLeft: 9, paddingRight: 8, paddingVertical: 5, }, textInput: { backgroundColor: 'listInputBackground', borderRadius: 10, color: 'listForegroundLabel', fontSize: 16, marginLeft: 4, marginVertical: 5, maxHeight: 250, paddingHorizontal: 10, paddingVertical: 5, }, }; const stylesSelector = styleSelector(styles); const joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); -export default connectNav((context: ?NavContextType) => ({ - navContext: context, -}))( +export default connectNav( + (context: ?NavContextType, ownProps: { threadInfo: ThreadInfo }) => ({ + navContext: context, + isActive: ownProps.threadInfo.id === activeThreadSelector(context), + }), +)( connect( ( state: AppState, ownProps: { threadInfo: ThreadInfo, navContext: ?NavContextType }, ) => { const draft = state.drafts[draftKeyFromThreadID(ownProps.threadInfo.id)]; return { viewerID: state.currentUserInfo && state.currentUserInfo.id, draft: draft ? draft : '', joinThreadLoadingStatus: joinThreadLoadingStatusSelector(state), calendarQuery: nonThreadCalendarQuery({ redux: state, navContext: ownProps.navContext, }), nextLocalID: state.nextLocalID, colors: colorsSelector(state), styles: stylesSelector(state), }; }, { joinThread }, )(withKeyboardState(withInputState(ChatInputBar))), ); diff --git a/native/chat/text-message-tooltip-modal.react.js b/native/chat/text-message-tooltip-modal.react.js index 95aa8acf4..7092cabf3 100644 --- a/native/chat/text-message-tooltip-modal.react.js +++ b/native/chat/text-message-tooltip-modal.react.js @@ -1,36 +1,65 @@ // @flow import type { ChatTextMessageInfoItemWithHeight } from './text-message.react'; +import type { + DispatchFunctions, + ActionFunc, + BoundServerCall, +} from 'lib/utils/action-utils'; +import { type InputState } from '../input/input-state'; import Clipboard from '@react-native-community/clipboard'; +import invariant from 'invariant'; import { createTooltip, tooltipHeight, type TooltipParams, } from '../navigation/tooltip.react'; import TextMessageTooltipButton from './text-message-tooltip-button.react'; import { displayActionResultModal } from '../navigation/action-result-modal'; type CustomProps = { item: ChatTextMessageInfoItemWithHeight, }; export type TextMessageTooltipModalParams = TooltipParams; const confirmCopy = () => displayActionResultModal('copied!'); function onPressCopy(props: CustomProps) { Clipboard.setString(props.item.messageInfo.text); setTimeout(confirmCopy); } +const createReply = (message: string) => { + // add `>` to each line to include empty lines in the quote + const quotedMessage = message.replace(/^/gm, '> '); + return quotedMessage + '\n\n'; +}; + +function onPressReply( + props: CustomProps, + dispatchFunctions: DispatchFunctions, + bindServerCall: (serverCall: ActionFunc) => BoundServerCall, + inputState: ?InputState, +) { + invariant( + inputState, + 'inputState should be set in TextMessageTooltipModal.onPressReply', + ); + inputState.addReply(createReply(props.item.messageInfo.text)); +} + const spec = { - entries: [{ id: 'copy', text: 'Copy', onPress: onPressCopy }], + entries: [ + { id: 'copy', text: 'Copy', onPress: onPressCopy }, + { id: 'reply', text: 'Reply', onPress: onPressReply }, + ], }; const TextMessageTooltipModal = createTooltip(TextMessageTooltipButton, spec); const textMessageTooltipHeight = tooltipHeight(spec.entries.length); export { TextMessageTooltipModal, textMessageTooltipHeight }; diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js index e145c5d5d..dd60db10f 100644 --- a/native/input/input-state-container.react.js +++ b/native/input/input-state-container.react.js @@ -1,1054 +1,1072 @@ // @flow import type { AppState } from '../redux/redux-setup'; import type { DispatchActionPayload, DispatchActionPromise, } from 'lib/utils/action-utils'; import type { UploadMultimediaResult, Media, NativeMediaSelection, MediaMissionResult, MediaMission, } from 'lib/types/media-types'; import { messageTypes, type RawMessageInfo, type RawMultimediaMessageInfo, type SendMessageResult, type SendMessagePayload, type RawImagesMessageInfo, type RawMediaMessageInfo, type RawTextMessageInfo, } from 'lib/types/message-types'; import { type MediaMissionReportCreationRequest, reportTypes, } from 'lib/types/report-types'; import type { FetchJSONOptions, FetchJSONServerResponse, } from 'lib/utils/fetch-json'; import * as React from 'react'; import PropTypes from 'prop-types'; import invariant from 'invariant'; import { createSelector } from 'reselect'; import * as Upload from 'react-native-background-upload'; import { Platform } from 'react-native'; import { connect } from 'lib/utils/redux-utils'; import { uploadMultimedia, updateMultimediaMessageMediaActionType, type MultimediaUploadCallbacks, type MultimediaUploadExtras, } from 'lib/actions/upload-actions'; import { createLocalMessageActionType, sendMultimediaMessageActionTypes, sendMultimediaMessage, sendTextMessageActionTypes, sendTextMessage, } from 'lib/actions/message-actions'; import { createMediaMessageInfo } from 'lib/shared/message-utils'; import { queueReportsActionType } from 'lib/actions/report-actions'; import { getConfig } from 'lib/utils/config'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors'; import { pathFromURI } from 'lib/media/file-utils'; import { isStaff } from 'lib/shared/user-utils'; import { videoDurationLimit } from 'lib/media/video-utils'; import { getMessageForException } from 'lib/utils/errors'; import { InputStateContext, type PendingMultimediaUploads, } from './input-state'; import { processMedia } from '../media/media-utils'; import { displayActionResultModal } from '../navigation/action-result-modal'; import { disposeTempFile } from '../media/file-utils'; let nextLocalUploadID = 0; function getNewLocalID() { return `localUpload${nextLocalUploadID++}`; } type SelectionWithID = {| selection: NativeMediaSelection, localID: string, |}; type CompletedUploads = { [localMessageID: string]: ?Set }; type Props = {| children: React.Node, // Redux state viewerID: ?string, nextLocalID: number, messageStoreMessages: { [id: string]: RawMessageInfo }, ongoingMessageCreation: boolean, hasWiFi: boolean, // Redux dispatch functions dispatchActionPayload: DispatchActionPayload, dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs uploadMultimedia: ( multimedia: Object, extras: MultimediaUploadExtras, callbacks: MultimediaUploadCallbacks, ) => Promise, sendMultimediaMessage: ( threadID: string, localID: string, mediaIDs: $ReadOnlyArray, ) => Promise, sendTextMessage: ( threadID: string, localID: string, text: string, ) => Promise, |}; type State = {| pendingUploads: PendingMultimediaUploads, |}; class InputStateContainer extends React.PureComponent { static propTypes = { children: PropTypes.node.isRequired, viewerID: PropTypes.string, nextLocalID: PropTypes.number.isRequired, messageStoreMessages: PropTypes.object.isRequired, ongoingMessageCreation: PropTypes.bool.isRequired, hasWiFi: PropTypes.bool.isRequired, dispatchActionPayload: PropTypes.func.isRequired, dispatchActionPromise: PropTypes.func.isRequired, uploadMultimedia: PropTypes.func.isRequired, sendMultimediaMessage: PropTypes.func.isRequired, sendTextMessage: PropTypes.func.isRequired, }; state = { pendingUploads: {}, }; sendCallbacks: Array<() => void> = []; activeURIs = new Map(); + replyCallbacks: Array<(message: string) => void> = []; static getCompletedUploads(props: Props, state: State): CompletedUploads { const completedUploads = {}; for (let localMessageID in state.pendingUploads) { const messagePendingUploads = state.pendingUploads[localMessageID]; const rawMessageInfo = props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); const completed = []; let allUploadsComplete = true; for (let localUploadID in messagePendingUploads) { let media; for (let singleMedia of rawMessageInfo.media) { if (singleMedia.id === localUploadID) { media = singleMedia; break; } } if (media) { allUploadsComplete = false; } else { completed.push(localUploadID); } } if (allUploadsComplete) { completedUploads[localMessageID] = null; } else if (completed.length > 0) { completedUploads[localMessageID] = new Set(completed); } } return completedUploads; } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.viewerID !== prevProps.viewerID) { this.setState({ pendingUploads: {} }); return; } const currentlyComplete = InputStateContainer.getCompletedUploads( this.props, this.state, ); const previouslyComplete = InputStateContainer.getCompletedUploads( prevProps, prevState, ); const newPendingUploads = {}; let pendingUploadsChanged = false; const readyMessageIDs = []; for (let localMessageID in this.state.pendingUploads) { const messagePendingUploads = this.state.pendingUploads[localMessageID]; const prevRawMessageInfo = prevProps.messageStoreMessages[localMessageID]; const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; const completedUploadIDs = currentlyComplete[localMessageID]; const previouslyCompletedUploadIDs = previouslyComplete[localMessageID]; if (!rawMessageInfo && prevRawMessageInfo) { pendingUploadsChanged = true; continue; } else if (completedUploadIDs === null) { // All of this message's uploads have been completed newPendingUploads[localMessageID] = {}; if (previouslyCompletedUploadIDs !== null) { readyMessageIDs.push(localMessageID); pendingUploadsChanged = true; } continue; } else if (!completedUploadIDs) { // Nothing has been completed newPendingUploads[localMessageID] = messagePendingUploads; continue; } const newUploads = {}; let uploadsChanged = false; for (let localUploadID in messagePendingUploads) { if (!completedUploadIDs.has(localUploadID)) { newUploads[localUploadID] = messagePendingUploads[localUploadID]; } else if ( !previouslyCompletedUploadIDs || !previouslyCompletedUploadIDs.has(localUploadID) ) { uploadsChanged = true; } } if (uploadsChanged) { pendingUploadsChanged = true; newPendingUploads[localMessageID] = newUploads; } else { newPendingUploads[localMessageID] = messagePendingUploads; } } if (pendingUploadsChanged) { this.setState({ pendingUploads: newPendingUploads }); } for (let localMessageID of readyMessageIDs) { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); this.dispatchMultimediaMessageAction(rawMessageInfo); } } dispatchMultimediaMessageAction(messageInfo: RawMultimediaMessageInfo) { this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(messageInfo), undefined, messageInfo, ); } async sendMultimediaMessageAction( messageInfo: RawMultimediaMessageInfo, ): Promise { const { localID, threadID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const mediaIDs = []; for (let { id } of messageInfo.media) { mediaIDs.push(id); } try { const result = await this.props.sendMultimediaMessage( threadID, localID, mediaIDs, ); return { localID, serverID: result.id, threadID, time: result.time, }; } catch (e) { e.localID = localID; e.threadID = threadID; throw e; } } inputStateSelector = createSelector( (state: State) => state.pendingUploads, (pendingUploads: PendingMultimediaUploads) => ({ pendingUploads, sendTextMessage: this.sendTextMessage, sendMultimediaMessage: this.sendMultimediaMessage, + addReply: this.addReply, + addReplyListener: this.addReplyListener, + removeReplyListener: this.removeReplyListener, messageHasUploadFailure: this.messageHasUploadFailure, retryMultimediaMessage: this.retryMultimediaMessage, registerSendCallback: this.registerSendCallback, unregisterSendCallback: this.unregisterSendCallback, uploadInProgress: this.uploadInProgress, reportURIDisplayed: this.reportURIDisplayed, }), ); uploadInProgress = () => { if (this.props.ongoingMessageCreation) { return true; } for (let localMessageID in this.state.pendingUploads) { const messagePendingUploads = this.state.pendingUploads[localMessageID]; for (let localUploadID in messagePendingUploads) { const { failed } = messagePendingUploads[localUploadID]; if (!failed) { return true; } } } return false; }; sendTextMessage = (messageInfo: RawTextMessageInfo) => { this.sendCallbacks.forEach(callback => callback()); this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction(messageInfo), undefined, messageInfo, ); }; async sendTextMessageAction( messageInfo: RawTextMessageInfo, ): Promise { try { const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const result = await this.props.sendTextMessage( messageInfo.threadID, localID, messageInfo.text, ); return { localID, serverID: result.id, threadID: messageInfo.threadID, time: result.time, }; } catch (e) { e.localID = messageInfo.localID; e.threadID = messageInfo.threadID; throw e; } } sendMultimediaMessage = async ( threadID: string, selections: $ReadOnlyArray, ) => { this.sendCallbacks.forEach(callback => callback()); const localMessageID = `local${this.props.nextLocalID}`; const selectionsWithIDs = selections.map(selection => ({ selection, localID: getNewLocalID(), })); const pendingUploads = {}; for (let { localID } of selectionsWithIDs) { pendingUploads[localID] = { failed: null, progressPercent: 0, }; } this.setState( prevState => { return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, }; }, () => { const creatorID = this.props.viewerID; invariant(creatorID, 'need viewer ID in order to send a message'); const media = selectionsWithIDs.map(({ localID, selection }) => { if (selection.step === 'photo_library') { return { id: localID, uri: selection.uri, type: 'photo', dimensions: selection.dimensions, localMediaSelection: selection, }; } else if (selection.step === 'photo_capture') { return { id: localID, uri: selection.uri, type: 'photo', dimensions: selection.dimensions, localMediaSelection: selection, }; } else if (selection.step === 'video_library') { return { id: localID, uri: selection.uri, type: 'video', dimensions: selection.dimensions, localMediaSelection: selection, loop: false, }; } invariant(false, `invalid selection ${JSON.stringify(selection)}`); }); const messageInfo = createMediaMessageInfo({ localID: localMessageID, threadID, creatorID, media, }); this.props.dispatchActionPayload( createLocalMessageActionType, messageInfo, ); }, ); await this.uploadFiles(localMessageID, selectionsWithIDs); }; async uploadFiles( localMessageID: string, selectionsWithIDs: $ReadOnlyArray, ) { const results = await Promise.all( selectionsWithIDs.map(selectionWithID => this.uploadFile(localMessageID, selectionWithID), ), ); const errors = [...new Set(results.filter(Boolean))]; if (errors.length > 0) { displayActionResultModal(errors.join(', ') + ' :('); } } async uploadFile( localMessageID: string, selectionWithID: SelectionWithID, ): Promise { const { localID, selection } = selectionWithID; const start = selection.sendTime; let steps = [selection], serverID, userTime, errorMessage; let reportPromise; const finish = async (result: MediaMissionResult) => { if (reportPromise) { const finalSteps = await reportPromise; steps.push(...finalSteps); } const totalTime = Date.now() - start; userTime = userTime ? userTime : totalTime; this.queueMediaMissionReport( { localID, localMessageID, serverID }, { steps, result, totalTime, userTime }, ); return errorMessage; }; const fail = (message: string) => { errorMessage = message; this.handleUploadFailure(localMessageID, localID, message); userTime = Date.now() - start; }; let processedMedia; const processingStart = Date.now(); try { const processMediaReturn = processMedia( selection, this.mediaProcessConfig(), ); reportPromise = processMediaReturn.reportPromise; const processResult = await processMediaReturn.resultPromise; if (!processResult.success) { const message = processResult.reason === 'video_too_long' ? `can't do vids longer than ${videoDurationLimit}min` : 'processing failed'; fail(message); return await finish(processResult); } processedMedia = processResult; } catch (e) { fail('processing failed'); return await finish({ success: false, reason: 'processing_exception', time: Date.now() - processingStart, exceptionMessage: getMessageForException(e), }); } const { uploadURI, shouldDisposePath, filename, mime } = processedMedia; const { hasWiFi } = this.props; const uploadStart = Date.now(); let uploadExceptionMessage, uploadResult, mediaMissionResult; try { uploadResult = await this.props.uploadMultimedia( { uri: uploadURI, name: filename, type: mime }, { ...processedMedia.dimensions, loop: processedMedia.loop }, { onProgress: (percent: number) => this.setProgress(localMessageID, localID, percent), uploadBlob: this.uploadBlob, }, ); mediaMissionResult = { success: true }; } catch (e) { uploadExceptionMessage = getMessageForException(e); fail('upload failed'); mediaMissionResult = { success: false, reason: 'http_upload_failed', exceptionMessage: uploadExceptionMessage, }; } if (uploadResult) { const { id, mediaType, uri, dimensions, loop } = uploadResult; serverID = id; this.props.dispatchActionPayload(updateMultimediaMessageMediaActionType, { messageID: localMessageID, currentMediaID: localID, mediaUpdate: { id, type: mediaType, uri, dimensions, localMediaSelection: undefined, loop, }, }); userTime = Date.now() - start; } const processSteps = await reportPromise; reportPromise = null; steps.push(...processSteps); steps.push({ step: 'upload', success: !!uploadResult, exceptionMessage: uploadExceptionMessage, time: Date.now() - uploadStart, inputFilename: filename, outputMediaType: uploadResult && uploadResult.mediaType, outputURI: uploadResult && uploadResult.uri, outputDimensions: uploadResult && uploadResult.dimensions, outputLoop: uploadResult && uploadResult.loop, hasWiFi, }); const promises = []; if (shouldDisposePath) { // If processMedia needed to do any transcoding before upload, we dispose // of the resultant temporary file here. Since the transcoded temporary // file is only used for upload, we can dispose of it after processMedia // (reportPromise) and the upload are complete promises.push( (async () => { const disposeStep = await disposeTempFile(shouldDisposePath); steps.push(disposeStep); })(), ); } if (selection.captureTime) { // If we are uploading a newly captured photo, we dispose of the original // file here. Note that we try to save photo captures to the camera roll // if we have permission. Even if we fail, this temporary file isn't // visible to the user, so there's no point in keeping it around. Since // the initial URI is used in rendering paths, we have to wait for it to // be replaced with the remote URI before we can dispose const captureURI = selection.uri; promises.push( (async () => { const { steps: clearSteps, result: capturePath, } = await this.waitForCaptureURIUnload(captureURI); steps.push(...clearSteps); if (!capturePath) { return; } const disposeStep = await disposeTempFile(capturePath); steps.push(disposeStep); })(), ); } await Promise.all(promises); return await finish(mediaMissionResult); } mediaProcessConfig() { const { hasWiFi, viewerID } = this.props; if (__DEV__ || (viewerID && isStaff(viewerID))) { return { hasWiFi, finalFileHeaderCheck: true, }; } return { hasWiFi }; } setProgress( localMessageID: string, localUploadID: string, progressPercent: number, ) { this.setState(prevState => { const pendingUploads = prevState.pendingUploads[localMessageID]; if (!pendingUploads) { return {}; } const pendingUpload = pendingUploads[localUploadID]; if (!pendingUpload) { return {}; } const newOutOfHundred = Math.floor(progressPercent * 100); const oldOutOfHundred = Math.floor(pendingUpload.progressPercent * 100); if (newOutOfHundred === oldOutOfHundred) { return {}; } const newPendingUploads = { ...pendingUploads, [localUploadID]: { ...pendingUpload, progressPercent, }, }; return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: newPendingUploads, }, }; }); } uploadBlob = async ( url: string, cookie: ?string, sessionID: ?string, input: { [key: string]: mixed }, options?: ?FetchJSONOptions, ): Promise => { invariant( cookie && input.multimedia && Array.isArray(input.multimedia) && input.multimedia.length === 1 && input.multimedia[0] && typeof input.multimedia[0] === 'object', 'InputStateContainer.uploadBlob sent incorrect input', ); const { uri, name, type } = input.multimedia[0]; invariant( typeof uri === 'string' && typeof name === 'string' && typeof type === 'string', 'InputStateContainer.uploadBlob sent incorrect input', ); const parameters = {}; parameters.cookie = cookie; parameters.filename = name; for (let key in input) { if ( key === 'multimedia' || key === 'cookie' || key === 'sessionID' || key === 'filename' ) { continue; } const value = input[key]; invariant( typeof value === 'string', 'blobUpload calls can only handle string values for non-multimedia keys', ); parameters[key] = value; } let path = uri; if (Platform.OS === 'android') { const resolvedPath = pathFromURI(uri); if (resolvedPath) { path = resolvedPath; } } const uploadID = await Upload.startUpload({ url, path, type: 'multipart', headers: { Accept: 'application/json', }, field: 'multimedia', parameters, }); if (options && options.abortHandler) { options.abortHandler(() => { Upload.cancelUpload(uploadID); }); } return await new Promise((resolve, reject) => { Upload.addListener('error', uploadID, data => { reject(data.error); }); Upload.addListener('cancelled', uploadID, () => { reject(new Error('request aborted')); }); Upload.addListener('completed', uploadID, data => { resolve(JSON.parse(data.responseBody)); }); if (options && options.onProgress) { const { onProgress } = options; Upload.addListener('progress', uploadID, data => onProgress(data.progress / 100), ); } }); }; handleUploadFailure( localMessageID: string, localUploadID: string, message: string, ) { this.setState(prevState => { const uploads = prevState.pendingUploads[localMessageID]; const upload = uploads[localUploadID]; if (!upload) { // The upload has been completed before it failed return {}; } return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: { ...uploads, [localUploadID]: { ...upload, failed: message, progressPercent: 0, }, }, }, }; }); } queueMediaMissionReport( ids: {| localID: string, localMessageID: string, serverID: ?string |}, mediaMission: MediaMission, ) { const report: MediaMissionReportCreationRequest = { type: reportTypes.MEDIA_MISSION, time: Date.now(), platformDetails: getConfig().platformDetails, mediaMission, uploadServerID: ids.serverID, uploadLocalID: ids.localID, messageLocalID: ids.localMessageID, }; this.props.dispatchActionPayload(queueReportsActionType, { reports: [report], }); } messageHasUploadFailure = (localMessageID: string) => { const pendingUploads = this.state.pendingUploads[localMessageID]; if (!pendingUploads) { return false; } for (let localUploadID in pendingUploads) { const { failed } = pendingUploads[localUploadID]; if (failed) { return true; } } return false; }; + addReply = (message: string) => { + this.replyCallbacks.forEach(addReplyCallback => addReplyCallback(message)); + }; + + addReplyListener = (callbackReply: (message: string) => void) => { + this.replyCallbacks.push(callbackReply); + }; + + removeReplyListener = (callbackReply: (message: string) => void) => { + this.replyCallbacks = this.replyCallbacks.filter( + candidate => candidate !== callbackReply, + ); + }; + retryMultimediaMessage = async (localMessageID: string) => { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`); let pendingUploads = this.state.pendingUploads[localMessageID]; if (!pendingUploads) { pendingUploads = {}; } const now = Date.now(); const updateMedia = (media: $ReadOnlyArray): T[] => media.map(singleMedia => { const oldID = singleMedia.id; if (!oldID.startsWith('localUpload')) { // already uploaded return singleMedia; } if (pendingUploads[oldID] && !pendingUploads[oldID].failed) { // still being uploaded return singleMedia; } // If we have an incomplete upload that isn't in pendingUploads, that // indicates the app has restarted. We'll reassign a new localID to // avoid collisions. Note that this isn't necessary for the message ID // since the localID reducer prevents collisions there const id = pendingUploads[oldID] ? oldID : getNewLocalID(); const oldSelection = singleMedia.localMediaSelection; invariant( oldSelection, 'localMediaSelection should be set on locally created Media', ); const retries = oldSelection.retries ? oldSelection.retries + 1 : 1; // We switch for Flow let selection; if (oldSelection.step === 'photo_capture') { selection = { ...oldSelection, sendTime: now, retries }; } else if (oldSelection.step === 'photo_library') { selection = { ...oldSelection, sendTime: now, retries }; } else { selection = { ...oldSelection, sendTime: now, retries }; } if (singleMedia.type === 'photo') { return { type: 'photo', ...singleMedia, id, localMediaSelection: selection, }; } else { return { type: 'video', ...singleMedia, id, localMediaSelection: selection, }; } }); let newRawMessageInfo; // This conditional is for Flow if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawMediaMessageInfo); } else if (rawMessageInfo.type === messageTypes.IMAGES) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawImagesMessageInfo); } else { invariant(false, `rawMessageInfo ${localMessageID} should be multimedia`); } const incompleteMedia: Media[] = []; for (let singleMedia of newRawMessageInfo.media) { if (singleMedia.id.startsWith('localUpload')) { incompleteMedia.push(singleMedia); } } if (incompleteMedia.length === 0) { this.dispatchMultimediaMessageAction(newRawMessageInfo); this.setState(prevState => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: {}, }, })); return; } const retryMedia = incompleteMedia.filter( ({ id }) => !pendingUploads[id] || pendingUploads[id].failed, ); if (retryMedia.length === 0) { // All media are already in the process of being uploaded return; } // We're not actually starting the send here, // we just use this action to update the message in Redux this.props.dispatchActionPayload( sendMultimediaMessageActionTypes.started, newRawMessageInfo, ); // We clear out the failed status on individual media here, // which makes the UI show pending status instead of error messages for (let { id } of retryMedia) { pendingUploads[id] = { failed: null, progressPercent: 0, }; } this.setState(prevState => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, })); const selectionsWithIDs = retryMedia.map(singleMedia => { const { id, localMediaSelection } = singleMedia; invariant( localMediaSelection, 'localMediaSelection should be set on locally created Media', ); return { selection: localMediaSelection, localID: id }; }); await this.uploadFiles(localMessageID, selectionsWithIDs); }; registerSendCallback = (callback: () => void) => { this.sendCallbacks.push(callback); }; unregisterSendCallback = (callback: () => void) => { this.sendCallbacks = this.sendCallbacks.filter( candidate => candidate !== callback, ); }; reportURIDisplayed = (uri: string, loaded: boolean) => { const prevActiveURI = this.activeURIs.get(uri); const curCount = prevActiveURI && prevActiveURI.count; const prevCount = curCount ? curCount : 0; const count = loaded ? prevCount + 1 : prevCount - 1; const prevOnClear = prevActiveURI && prevActiveURI.onClear; const onClear = prevOnClear ? prevOnClear : []; const activeURI = { count, onClear }; if (count) { this.activeURIs.set(uri, activeURI); return; } this.activeURIs.delete(uri); for (let callback of onClear) { callback(); } }; waitForCaptureURIUnload(uri: string) { const start = Date.now(); const path = pathFromURI(uri); if (!path) { return Promise.resolve({ result: null, steps: [ { step: 'wait_for_capture_uri_unload', success: false, time: Date.now() - start, uri, }, ], }); } const getResult = () => ({ result: path, steps: [ { step: 'wait_for_capture_uri_unload', success: true, time: Date.now() - start, uri, }, ], }); const activeURI = this.activeURIs.get(uri); if (!activeURI) { return Promise.resolve(getResult()); } return new Promise(resolve => { const finish = () => resolve(getResult()); const newActiveURI = { ...activeURI, onClear: [...activeURI.onClear, finish], }; this.activeURIs.set(uri, newActiveURI); }); } render() { const inputState = this.inputStateSelector(this.state); return ( {this.props.children} ); } } const mediaCreationLoadingStatusSelector = createLoadingStatusSelector( sendMultimediaMessageActionTypes, ); const textCreationLoadingStatusSelector = createLoadingStatusSelector( sendTextMessageActionTypes, ); export default connect( (state: AppState) => ({ viewerID: state.currentUserInfo && state.currentUserInfo.id, nextLocalID: state.nextLocalID, messageStoreMessages: state.messageStore.messages, ongoingMessageCreation: combineLoadingStatuses( mediaCreationLoadingStatusSelector(state), textCreationLoadingStatusSelector(state), ) === 'loading', hasWiFi: state.connectivity.hasWiFi, }), { uploadMultimedia, sendMultimediaMessage, sendTextMessage }, )(InputStateContainer); diff --git a/native/input/input-state.js b/native/input/input-state.js index 215bdd236..f12ca8582 100644 --- a/native/input/input-state.js +++ b/native/input/input-state.js @@ -1,90 +1,96 @@ // @flow import type { NativeMediaSelection } from 'lib/types/media-types'; import type { RawTextMessageInfo } from 'lib/types/message-types'; import * as React from 'react'; import PropTypes from 'prop-types'; export type PendingMultimediaUpload = {| failed: ?string, progressPercent: number, |}; const pendingMultimediaUploadPropType = PropTypes.shape({ failed: PropTypes.string, progressPercent: PropTypes.number.isRequired, }); export type MessagePendingUploads = { [localUploadID: string]: PendingMultimediaUpload, }; const messagePendingUploadsPropType = PropTypes.objectOf( pendingMultimediaUploadPropType, ); export type PendingMultimediaUploads = { [localMessageID: string]: MessagePendingUploads, }; const pendingMultimediaUploadsPropType = PropTypes.objectOf( messagePendingUploadsPropType, ); export type InputState = {| pendingUploads: PendingMultimediaUploads, sendTextMessage: (messageInfo: RawTextMessageInfo) => void, sendMultimediaMessage: ( threadID: string, selections: $ReadOnlyArray, ) => Promise, + addReply: (text: string) => void, + addReplyListener: ((message: string) => void) => void, + removeReplyListener: ((message: string) => void) => void, messageHasUploadFailure: (localMessageID: string) => boolean, retryMultimediaMessage: (localMessageID: string) => Promise, registerSendCallback: (() => void) => void, unregisterSendCallback: (() => void) => void, uploadInProgress: () => boolean, reportURIDisplayed: (uri: string, loaded: boolean) => void, |}; const inputStatePropType = PropTypes.shape({ pendingUploads: pendingMultimediaUploadsPropType.isRequired, sendTextMessage: PropTypes.func.isRequired, sendMultimediaMessage: PropTypes.func.isRequired, + addReply: PropTypes.func.isRequired, + addReplyListener: PropTypes.func.isRequired, + removeReplyListener: PropTypes.func.isRequired, messageHasUploadFailure: PropTypes.func.isRequired, retryMultimediaMessage: PropTypes.func.isRequired, uploadInProgress: PropTypes.func.isRequired, reportURIDisplayed: PropTypes.func.isRequired, }); const InputStateContext = React.createContext(null); function withInputState< AllProps: {}, ComponentType: React.ComponentType, >( Component: ComponentType, ): React.ComponentType< $Diff, { inputState: ?InputState }>, > { class InputStateHOC extends React.PureComponent< $Diff, { inputState: ?InputState }>, > { render() { return ( {value => } ); } } return InputStateHOC; } export { messagePendingUploadsPropType, pendingMultimediaUploadPropType, inputStatePropType, InputStateContext, withInputState, }; diff --git a/native/navigation/tooltip-item.react.js b/native/navigation/tooltip-item.react.js index 7886ad6ea..780fce9ad 100644 --- a/native/navigation/tooltip-item.react.js +++ b/native/navigation/tooltip-item.react.js @@ -1,81 +1,83 @@ // @flow import type { ViewStyle, TextStyle } from '../types/styles'; import type { DispatchFunctions, ActionFunc, BoundServerCall, } from 'lib/utils/action-utils'; +import type { InputState } from '../input/input-state'; import * as React from 'react'; import { TouchableOpacity, StyleSheet, Text, ViewPropTypes, } from 'react-native'; import PropTypes from 'prop-types'; import { SingleLine } from '../components/single-line.react'; export type TooltipEntry = {| id: string, text: string, onPress: ( props: Params, dispatchFunctions: DispatchFunctions, bindServerCall: (serverCall: ActionFunc) => BoundServerCall, + inputState: ?InputState, ) => mixed, |}; type Props> = { spec: Entry, onPress: (entry: Entry) => void, containerStyle?: ViewStyle, labelStyle?: TextStyle, }; class TooltipItem< Params, Entry: TooltipEntry, > extends React.PureComponent> { static propTypes = { spec: PropTypes.shape({ text: PropTypes.string.isRequired, onPress: PropTypes.func.isRequired, }).isRequired, onPress: PropTypes.func.isRequired, containerStyle: ViewPropTypes.style, labelStyle: Text.propTypes.style, }; render() { return ( {this.props.spec.text} ); } onPress = () => { this.props.onPress(this.props.spec); }; } const styles = StyleSheet.create({ itemContainer: { padding: 10, }, label: { color: '#444', fontSize: 14, lineHeight: 17, textAlign: 'center', }, }); export default TooltipItem; diff --git a/native/navigation/tooltip.react.js b/native/navigation/tooltip.react.js index 069416143..dbabcb850 100644 --- a/native/navigation/tooltip.react.js +++ b/native/navigation/tooltip.react.js @@ -1,493 +1,502 @@ // @flow import { type VerticalBounds, verticalBoundsPropType, type LayoutCoordinates, layoutCoordinatesPropType, } from '../types/layout-types'; import type { AppState } from '../redux/redux-setup'; import { type DimensionsInfo, dimensionsInfoPropType, } from '../redux/dimensions-updater.react'; import type { ViewStyle } from '../types/styles'; import type { TooltipEntry } from './tooltip-item.react'; import type { Dispatch } from 'lib/types/redux-types'; import type { DispatchActionPayload, DispatchActionPromise, ActionFunc, } from 'lib/utils/action-utils'; import type { LayoutEvent } from '../types/react-native'; import type { AppNavigationProp } from './app-navigator.react'; import type { TooltipModalParamList } from './route-names'; import type { LeafRoute } from '@react-navigation/native'; +import { + type InputState, + inputStatePropType, + withInputState, +} from '../input/input-state'; import * as React from 'react'; import Animated from 'react-native-reanimated'; import { View, StyleSheet, TouchableWithoutFeedback, Platform, } from 'react-native'; import PropTypes from 'prop-types'; import invariant from 'invariant'; import { type ServerCallState, serverCallStatePropType, serverCallStateSelector, } from 'lib/selectors/server-calls'; import { connect } from 'lib/utils/redux-utils'; import { createBoundServerCallsSelector } from 'lib/utils/action-utils'; import TooltipItem from './tooltip-item.react'; import { withOverlayContext, type OverlayContextType, overlayContextPropType, } from './overlay-context'; /* eslint-disable import/no-named-as-default-member */ const { Value, Extrapolate, add, multiply, interpolate } = Animated; /* eslint-enable import/no-named-as-default-member */ type TooltipSpec = {| entries: $ReadOnlyArray, labelStyle?: ViewStyle, |}; type TooltipCommonProps = { presentedFrom: string, initialCoordinates: LayoutCoordinates, verticalBounds: VerticalBounds, location?: 'above' | 'below', margin?: number, visibleEntryIDs?: $ReadOnlyArray, }; export type TooltipParams = {| ...$Exact, ...$Exact, |}; export type TooltipRoute< RouteName: $Keys, Params = $ElementType, > = {| ...LeafRoute, +params: Params, |}; type ButtonProps = { navigation: Navigation, route: Route, progress: Value, }; type TooltipProps = { navigation: Navigation, route: Route, // Redux state dimensions: DimensionsInfo, serverCallState: ServerCallState, // Redux dispatch functions dispatch: Dispatch, dispatchActionPayload: DispatchActionPayload, dispatchActionPromise: DispatchActionPromise, // withOverlayContext overlayContext: ?OverlayContextType, + // withInputState + inputState: ?InputState, }; function createTooltip< RouteName: $Keys, Navigation: AppNavigationProp, Params: TooltipCommonProps, Route: TooltipRoute, Entry: TooltipEntry, TooltipPropsType: TooltipProps, ButtonComponentType: React.ComponentType>, >(ButtonComponent: ButtonComponentType, tooltipSpec: TooltipSpec) { class Tooltip extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ goBackOnce: PropTypes.func.isRequired, }).isRequired, route: PropTypes.shape({ params: PropTypes.shape({ initialCoordinates: layoutCoordinatesPropType.isRequired, verticalBounds: verticalBoundsPropType.isRequired, location: PropTypes.oneOf(['above', 'below']), margin: PropTypes.number, visibleEntryIDs: PropTypes.arrayOf(PropTypes.string), }).isRequired, }).isRequired, dimensions: dimensionsInfoPropType.isRequired, serverCallState: serverCallStatePropType.isRequired, dispatch: PropTypes.func.isRequired, dispatchActionPayload: PropTypes.func.isRequired, dispatchActionPromise: PropTypes.func.isRequired, overlayContext: overlayContextPropType, + inputState: inputStatePropType, }; backdropOpacity: Value; tooltipContainerOpacity: Value; tooltipVerticalAbove: Value; tooltipVerticalBelow: Value; tooltipHorizontalOffset = new Value(0); tooltipHorizontal: Value; constructor(props: TooltipPropsType) { super(props); const { overlayContext } = props; invariant(overlayContext, 'Tooltip should have OverlayContext'); const { position } = overlayContext; this.backdropOpacity = interpolate(position, { inputRange: [0, 1], outputRange: [0, 0.7], extrapolate: Extrapolate.CLAMP, }); this.tooltipContainerOpacity = interpolate(position, { inputRange: [0, 0.1], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); const { margin } = this; this.tooltipVerticalAbove = interpolate(position, { inputRange: [0, 1], outputRange: [margin + this.tooltipHeight / 2, 0], extrapolate: Extrapolate.CLAMP, }); this.tooltipVerticalBelow = interpolate(position, { inputRange: [0, 1], outputRange: [-margin - this.tooltipHeight / 2, 0], extrapolate: Extrapolate.CLAMP, }); this.tooltipHorizontal = multiply( add(1, multiply(-1, position)), this.tooltipHorizontalOffset, ); } get entries() { const { entries } = tooltipSpec; const { visibleEntryIDs } = this.props.route.params; if (!visibleEntryIDs) { return entries; } const visibleSet = new Set(visibleEntryIDs); return entries.filter(entry => visibleSet.has(entry.id)); } get tooltipHeight(): number { return tooltipHeight(this.entries.length); } get location(): 'above' | 'below' { const { params } = this.props.route; const { location } = params; if (location) { return location; } const { initialCoordinates, verticalBounds } = params; const { y, height } = initialCoordinates; const contentTop = y; const contentBottom = y + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const { margin, tooltipHeight: curTooltipHeight } = this; const fullHeight = curTooltipHeight + margin; if ( contentBottom + fullHeight > boundsBottom && contentTop - fullHeight > boundsTop ) { return 'above'; } return 'below'; } get opacityStyle() { return { ...styles.backdrop, opacity: this.backdropOpacity, }; } get contentContainerStyle() { const { verticalBounds } = this.props.route.params; const fullScreenHeight = this.props.dimensions.height; const top = verticalBounds.y; const bottom = fullScreenHeight - verticalBounds.y - verticalBounds.height; return { ...styles.contentContainer, marginTop: top, marginBottom: bottom, }; } get buttonStyle() { const { params } = this.props.route; const { initialCoordinates, verticalBounds } = params; const { x, y, width, height } = initialCoordinates; return { width: Math.ceil(width), height: Math.ceil(height), marginTop: y - verticalBounds.y, marginLeft: x, }; } get margin() { const customMargin = this.props.route.params.margin; return customMargin !== null && customMargin !== undefined ? customMargin : 20; } get tooltipContainerStyle() { const { dimensions, route } = this.props; const { initialCoordinates, verticalBounds } = route.params; const { x, y, width, height } = initialCoordinates; const { margin, location } = this; const style = {}; style.position = 'absolute'; (style.alignItems = 'center'), (style.opacity = this.tooltipContainerOpacity); style.transform = [{ translateX: this.tooltipHorizontal }]; const extraLeftSpace = x; const extraRightSpace = dimensions.width - width - x; if (extraLeftSpace < extraRightSpace) { style.left = 0; style.minWidth = width + 2 * extraLeftSpace; } else { style.right = 0; style.minWidth = width + 2 * extraRightSpace; } if (location === 'above') { const fullScreenHeight = dimensions.height; style.bottom = fullScreenHeight - Math.max(y, verticalBounds.y) + margin; style.transform.push({ translateY: this.tooltipVerticalAbove }); } else { style.top = Math.min(y + height, verticalBounds.y + verticalBounds.height) + margin; style.transform.push({ translateY: this.tooltipVerticalBelow }); } const { overlayContext } = this.props; invariant(overlayContext, 'Tooltip should have OverlayContext'); const { position } = overlayContext; style.transform.push({ scale: position }); return style; } render() { const { navigation, route, dimensions } = this.props; const { entries } = this; const items = entries.map((entry, index) => { const style = index !== entries.length - 1 ? styles.itemMargin : null; return ( ); }); let triangleStyle; const { initialCoordinates } = route.params; const { x, width } = initialCoordinates; const extraLeftSpace = x; const extraRightSpace = dimensions.width - width - x; if (extraLeftSpace < extraRightSpace) { triangleStyle = { alignSelf: 'flex-start', left: extraLeftSpace + (width - 20) / 2, }; } else { triangleStyle = { alignSelf: 'flex-end', right: extraRightSpace + (width - 20) / 2, }; } let triangleDown = null; let triangleUp = null; const { location } = this; if (location === 'above') { triangleDown = ; } else { triangleUp = ; } const { overlayContext } = this.props; invariant(overlayContext, 'Tooltip should have OverlayContext'); const { position } = overlayContext; return ( {triangleUp} {items} {triangleDown} ); } onPressBackdrop = () => { this.props.navigation.goBackOnce(); }; onPressEntry = (entry: Entry) => { this.props.navigation.goBackOnce(); const dispatchFunctions = { dispatch: this.props.dispatch, dispatchActionPayload: this.props.dispatchActionPayload, dispatchActionPromise: this.props.dispatchActionPromise, }; entry.onPress( this.props.route.params, dispatchFunctions, this.bindServerCall, + this.props.inputState, ); }; bindServerCall = (serverCall: ActionFunc) => { const { cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, } = this.props.serverCallState; return createBoundServerCallsSelector(serverCall)({ dispatch: this.props.dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, }); }; onTooltipContainerLayout = (event: LayoutEvent) => { const { route, dimensions } = this.props; const { x, width } = route.params.initialCoordinates; const extraLeftSpace = x; const extraRightSpace = dimensions.width - width - x; const actualWidth = event.nativeEvent.layout.width; if (extraLeftSpace < extraRightSpace) { const minWidth = width + 2 * extraLeftSpace; this.tooltipHorizontalOffset.setValue((minWidth - actualWidth) / 2); } else { const minWidth = width + 2 * extraRightSpace; this.tooltipHorizontalOffset.setValue((actualWidth - minWidth) / 2); } }; } return connect( (state: AppState) => ({ dimensions: state.dimensions, serverCallState: serverCallStateSelector(state), }), null, true, - )(withOverlayContext(Tooltip)); + )(withOverlayContext(withInputState(Tooltip))); } const styles = StyleSheet.create({ backdrop: { backgroundColor: 'black', bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, }, container: { flex: 1, }, contentContainer: { flex: 1, overflow: 'hidden', }, itemMargin: { borderBottomColor: '#E1E1E1', borderBottomWidth: 1, }, items: { backgroundColor: 'white', borderRadius: 5, overflow: 'hidden', }, triangleDown: { borderBottomColor: 'transparent', borderBottomWidth: 0, borderLeftColor: 'transparent', borderLeftWidth: 10, borderRightColor: 'transparent', borderRightWidth: 10, borderStyle: 'solid', borderTopColor: 'white', borderTopWidth: 10, height: 10, top: Platform.OS === 'android' ? -1 : 0, width: 10, }, triangleUp: { borderBottomColor: 'white', borderBottomWidth: 10, borderLeftColor: 'transparent', borderLeftWidth: 10, borderRightColor: 'transparent', borderRightWidth: 10, borderStyle: 'solid', borderTopColor: 'transparent', borderTopWidth: 0, bottom: Platform.OS === 'android' ? -1 : 0, height: 10, width: 10, }, }); function tooltipHeight(numEntries: number) { // 10 (triangle) + 37 * numEntries (entries) + numEntries - 1 (padding) return 9 + 38 * numEntries; } export { createTooltip, tooltipHeight };