diff --git a/native/chat/inline-multimedia.react.js b/native/chat/inline-multimedia.react.js index 47013f3ca..909f7573f 100644 --- a/native/chat/inline-multimedia.react.js +++ b/native/chat/inline-multimedia.react.js @@ -1,103 +1,139 @@ // @flow import * as React from 'react'; -import { View, StyleSheet } from 'react-native'; +import { View, StyleSheet, Text } from 'react-native'; import * as Progress from 'react-native-progress'; import Icon from 'react-native-vector-icons/Feather'; +import tinycolor from 'tinycolor2'; import type { MediaInfo } from 'lib/types/media-types'; import GestureTouchableOpacity from '../components/gesture-touchable-opacity.react'; import type { PendingMultimediaUpload } from '../input/input-state'; import { KeyboardContext } from '../keyboard/keyboard-state'; import Multimedia from '../media/multimedia.react'; -const formatProgressText = (progress: number) => - `${Math.floor(progress * 100)}%`; - type Props = {| +mediaInfo: MediaInfo, +onPress: () => void, +onLongPress: () => void, +postInProgress: boolean, +pendingUpload: ?PendingMultimediaUpload, +spinnerColor: string, |}; function InlineMultimedia(props: Props) { const { mediaInfo, pendingUpload, postInProgress } = props; let failed = mediaInfo.id.startsWith('localUpload') && !postInProgress; let progressPercent = 1; + let processingStep; if (pendingUpload) { - ({ progressPercent, failed } = pendingUpload); + ({ progressPercent, failed, processingStep } = pendingUpload); } let progressIndicator; if (failed) { progressIndicator = ( ); } else if (progressPercent !== 1) { + const progressOverlay = ( + + + {`${Math.floor(progressPercent * 100).toString()}%`} + + + {processingStep ? processingStep : 'pending'} + + + ); + + const primaryColor = tinycolor(props.spinnerColor); + const secondaryColor = primaryColor.isDark() + ? primaryColor.lighten(20).toString() + : primaryColor.darken(20).toString(); + + const progressSpinnerProps = { + size: 120, + indeterminate: progressPercent === 0, + progress: progressPercent, + fill: secondaryColor, + unfilledColor: secondaryColor, + color: props.spinnerColor, + thickness: 10, + borderWidth: 0, + }; + + let progressSpinner; + if (processingStep === 'transcoding') { + progressSpinner = ; + } else { + progressSpinner = ; + } + progressIndicator = ( - + {progressSpinner} + {progressOverlay} ); } const keyboardState = React.useContext(KeyboardContext); const keyboardShowing = keyboardState?.keyboardShowing; return ( {progressIndicator} ); } const styles = StyleSheet.create({ centerContainer: { alignItems: 'center', bottom: 0, justifyContent: 'center', left: 0, position: 'absolute', right: 0, top: 0, }, expand: { flex: 1, }, - progressIndicatorText: { - color: 'black', - fontSize: 21, + processingStepText: { + color: 'white', + fontSize: 12, + textShadowColor: '#000', + textShadowRadius: 1, + }, + progressOverlay: { + alignItems: 'center', + justifyContent: 'center', + position: 'absolute', + }, + progressPercentText: { + color: 'white', + fontSize: 24, + fontWeight: 'bold', + textShadowColor: '#000', + textShadowRadius: 1, }, uploadError: { color: 'white', textShadowColor: '#000', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 1, }, }); export default InlineMultimedia; diff --git a/native/chat/multimedia-message-multimedia.react.js b/native/chat/multimedia-message-multimedia.react.js index e5fa66c2e..9917ce236 100644 --- a/native/chat/multimedia-message-multimedia.react.js +++ b/native/chat/multimedia-message-multimedia.react.js @@ -1,346 +1,346 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, StyleSheet } from 'react-native'; import Animated from 'react-native-reanimated'; import { useSelector } from 'react-redux'; import { messageKey } from 'lib/shared/message-utils'; import { relationshipBlockedInEitherDirection } from 'lib/shared/relationship-utils'; import { threadHasPermission } from 'lib/shared/thread-utils'; import { type MediaInfo } from 'lib/types/media-types'; import { threadPermissions } from 'lib/types/thread-types'; import type { UserInfo } from 'lib/types/user-types'; import { type PendingMultimediaUpload } from '../input/input-state'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { MultimediaModalRouteName, MultimediaTooltipModalRouteName, } from '../navigation/route-names'; import { type Colors, useColors } from '../themes/colors'; import { type VerticalBounds } from '../types/layout-types'; import type { ViewStyle } from '../types/styles'; import type { ChatNavigationProp } from './chat.react'; import InlineMultimedia from './inline-multimedia.react'; import type { ChatMultimediaMessageInfoItem } from './multimedia-message.react'; import { multimediaTooltipHeight } from './multimedia-tooltip-modal.react'; /* eslint-disable import/no-named-as-default-member */ const { Value, sub, interpolate, Extrapolate } = Animated; /* eslint-enable import/no-named-as-default-member */ type BaseProps = {| +mediaInfo: MediaInfo, +item: ChatMultimediaMessageInfoItem, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, +verticalBounds: ?VerticalBounds, +verticalOffset: number, +style: ViewStyle, +postInProgress: boolean, +pendingUpload: ?PendingMultimediaUpload, +messageFocused: boolean, +toggleMessageFocus: (messageKey: string) => void, |}; type Props = {| ...BaseProps, // Redux state +colors: Colors, +messageCreatorUserInfo: UserInfo, // withKeyboardState +keyboardState: ?KeyboardState, // withOverlayContext +overlayContext: ?OverlayContextType, |}; type State = {| +opacity: number | Value, |}; class MultimediaMessageMultimedia extends React.PureComponent { view: ?React.ElementRef; clickable = true; constructor(props: Props) { super(props); this.state = { opacity: this.getOpacity(), }; } static getStableKey(props: Props) { const { item, mediaInfo } = props; return `multimedia|${messageKey(item.messageInfo)}|${mediaInfo.index}`; } static getOverlayContext(props: Props) { const { overlayContext } = props; invariant( overlayContext, 'MultimediaMessageMultimedia should have OverlayContext', ); return overlayContext; } static getModalOverlayPosition(props: Props) { const overlayContext = MultimediaMessageMultimedia.getOverlayContext(props); const { visibleOverlays } = overlayContext; for (let overlay of visibleOverlays) { if ( overlay.routeName === MultimediaModalRouteName && overlay.presentedFrom === props.route.key && overlay.routeKey === MultimediaMessageMultimedia.getStableKey(props) ) { return overlay.position; } } return undefined; } getOpacity() { const overlayPosition = MultimediaMessageMultimedia.getModalOverlayPosition( this.props, ); if (!overlayPosition) { return 1; } return sub( 1, interpolate(overlayPosition, { inputRange: [0.1, 0.11], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }), ); } componentDidUpdate(prevProps: Props) { const overlayPosition = MultimediaMessageMultimedia.getModalOverlayPosition( this.props, ); const prevOverlayPosition = MultimediaMessageMultimedia.getModalOverlayPosition( prevProps, ); if (overlayPosition !== prevOverlayPosition) { this.setState({ opacity: this.getOpacity() }); } const scrollIsDisabled = MultimediaMessageMultimedia.getOverlayContext(this.props) .scrollBlockingModalStatus !== 'closed'; const scrollWasDisabled = MultimediaMessageMultimedia.getOverlayContext(prevProps) .scrollBlockingModalStatus !== 'closed'; if (!scrollIsDisabled && scrollWasDisabled) { this.clickable = true; } } render() { const { opacity } = this.state; const wrapperStyles = [styles.container, { opacity }, this.props.style]; const { mediaInfo, pendingUpload, postInProgress } = this.props; return ( ); } onLayout = () => {}; viewRef = (view: ?React.ElementRef) => { this.view = view; }; onPress = () => { if (this.dismissKeyboardIfShowing()) { return; } const { view, props: { verticalBounds }, } = this; if (!view || !verticalBounds) { return; } if (!this.clickable) { return; } this.clickable = false; const overlayContext = MultimediaMessageMultimedia.getOverlayContext( this.props, ); overlayContext.setScrollBlockingModalStatus('open'); const { mediaInfo, item } = this.props; view.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; this.props.navigation.navigate({ name: MultimediaModalRouteName, key: MultimediaMessageMultimedia.getStableKey(this.props), params: { presentedFrom: this.props.route.key, mediaInfo, item, initialCoordinates: coordinates, verticalBounds, }, }); }); }; visibleEntryIDs() { const result = ['save']; const canCreateSidebars = threadHasPermission( this.props.item.threadInfo, threadPermissions.CREATE_SIDEBARS, ); const creatorRelationship = this.props.messageCreatorUserInfo .relationshipStatus; const creatorRelationshipHasBlock = creatorRelationship && relationshipBlockedInEitherDirection(creatorRelationship); if (this.props.item.threadCreatedFromMessage) { result.push('open_sidebar'); } else if (canCreateSidebars && !creatorRelationshipHasBlock) { result.push('create_sidebar'); } return result; } onLongPress = () => { if (this.dismissKeyboardIfShowing()) { return; } const { view, props: { verticalBounds }, } = this; if (!view || !verticalBounds) { return; } if (!this.clickable) { return; } this.clickable = false; const { messageFocused, toggleMessageFocus, item, mediaInfo, verticalOffset, } = this.props; if (!messageFocused) { toggleMessageFocus(messageKey(item.messageInfo)); } const overlayContext = MultimediaMessageMultimedia.getOverlayContext( this.props, ); overlayContext.setScrollBlockingModalStatus('open'); view.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; const multimediaTop = pageY; const multimediaBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = multimediaTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const directlyAboveMargin = isViewer ? 30 : 50; const aboveMargin = verticalOffset === 0 ? directlyAboveMargin : 20; const aboveSpace = multimediaTooltipHeight + aboveMargin; let location = 'below', margin = belowMargin; if ( multimediaBottom + belowSpace > boundsBottom && multimediaTop - aboveSpace > boundsTop ) { location = 'above'; margin = aboveMargin; } this.props.navigation.navigate({ name: MultimediaTooltipModalRouteName, params: { presentedFrom: this.props.route.key, mediaInfo, item, initialCoordinates: coordinates, verticalOffset, verticalBounds, location, margin, visibleEntryIDs: this.visibleEntryIDs(), }, }); }); }; dismissKeyboardIfShowing = () => { const { keyboardState } = this.props; return !!(keyboardState && keyboardState.dismissKeyboardIfShowing()); }; } const styles = StyleSheet.create({ container: { flex: 1, overflow: 'hidden', }, expand: { flex: 1, }, }); export default React.memo( function ConnectedMultimediaMessageMultimedia(props: BaseProps) { const colors = useColors(); const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); const messageCreatorUserInfo = useSelector( (state) => state.userStore.userInfos[props.item.messageInfo.creator.id], ); return ( ); }, ); diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js index 7a3321b83..a437399f7 100644 --- a/native/input/input-state-container.react.js +++ b/native/input/input-state-container.react.js @@ -1,1099 +1,1099 @@ // @flow import invariant from 'invariant'; import PropTypes from 'prop-types'; import * as React from 'react'; import { Platform } from 'react-native'; import * as Upload from 'react-native-background-upload'; import { createSelector } from 'reselect'; import { createLocalMessageActionType, sendMultimediaMessageActionTypes, sendMultimediaMessage, sendTextMessageActionTypes, sendTextMessage, } from 'lib/actions/message-actions'; import { queueReportsActionType } from 'lib/actions/report-actions'; import { uploadMultimedia, updateMultimediaMessageMediaActionType, type MultimediaUploadCallbacks, type MultimediaUploadExtras, } from 'lib/actions/upload-actions'; import { pathFromURI } from 'lib/media/file-utils'; import { videoDurationLimit } from 'lib/media/video-utils'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors'; import { createMediaMessageInfo } from 'lib/shared/message-utils'; import { isStaff } from 'lib/shared/user-utils'; import type { UploadMultimediaResult, Media, NativeMediaSelection, MediaMissionResult, MediaMission, } from 'lib/types/media-types'; import { messageTypes, type RawMessageInfo, type RawMultimediaMessageInfo, type SendMessageResult, type SendMessagePayload, } from 'lib/types/message-types'; import type { RawImagesMessageInfo } from 'lib/types/messages/images'; import type { RawMediaMessageInfo } from 'lib/types/messages/media'; import type { RawTextMessageInfo } from 'lib/types/messages/text'; import { type MediaMissionReportCreationRequest, reportTypes, } from 'lib/types/report-types'; import type { DispatchActionPayload, DispatchActionPromise, } from 'lib/utils/action-utils'; import { getConfig } from 'lib/utils/config'; import { getMessageForException, cloneError } from 'lib/utils/errors'; import type { FetchJSONOptions, FetchJSONServerResponse, } from 'lib/utils/fetch-json'; import { connect } from 'lib/utils/redux-utils'; import { disposeTempFile } from '../media/file-utils'; import { processMedia } from '../media/media-utils'; import { displayActionResultModal } from '../navigation/action-result-modal'; import type { AppState } from '../redux/redux-setup'; import { InputStateContext, type PendingMultimediaUploads, type MultimediaProcessingStep, } from './input-state'; 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: 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, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = localID; copy.threadID = threadID; throw copy; } } 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, interface: result.interface, }; } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; throw copy; } } 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 === 'photo_paste') { 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(localMessageID, localID), ); 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, 'upload', percent), + this.setProgress(localMessageID, localID, 'uploading', 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 || selection.step === 'photo_paste') { // 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(localMessageID: string, localID: string) { const { hasWiFi, viewerID } = this.props; const onTranscodingProgress = (percent: number) => { - this.setProgress(localMessageID, localID, 'transcode', percent); + this.setProgress(localMessageID, localID, 'transcoding', percent); }; if (__DEV__ || (viewerID && isStaff(viewerID))) { return { hasWiFi, finalFileHeaderCheck: true, onTranscodingProgress, }; } return { hasWiFi, onTranscodingProgress }; } setProgress( localMessageID: string, localUploadID: string, processingStep: MultimediaProcessingStep, 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, processingStep, }, }; 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) => { try { resolve(JSON.parse(data.responseBody)); } catch (e) { reject(e); } }); 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 if (oldSelection.step === 'photo_paste') { 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, processingStep: null, }; } 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 f112c54a3..45742df8c 100644 --- a/native/input/input-state.js +++ b/native/input/input-state.js @@ -1,77 +1,77 @@ // @flow import PropTypes from 'prop-types'; import * as React from 'react'; import type { NativeMediaSelection } from 'lib/types/media-types'; import type { RawTextMessageInfo } from 'lib/types/messages/text'; -export type MultimediaProcessingStep = 'transcode' | 'upload'; +export type MultimediaProcessingStep = 'transcoding' | 'uploading'; export type PendingMultimediaUpload = {| +failed: ?string, +progressPercent: number, +processingStep: ?MultimediaProcessingStep, |}; const pendingMultimediaUploadPropType = PropTypes.shape({ failed: PropTypes.string, progressPercent: PropTypes.number.isRequired, - processingStep: PropTypes.oneOf(['transcode', 'upload']), + processingStep: PropTypes.oneOf(['transcoding', 'uploading']), }); 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); export { messagePendingUploadsPropType, pendingMultimediaUploadPropType, inputStatePropType, InputStateContext, };