diff --git a/lib/types/media-types.js b/lib/types/media-types.js index 66d36c4bd..ba252a137 100644 --- a/lib/types/media-types.js +++ b/lib/types/media-types.js @@ -1,900 +1,924 @@ // @flow import PropTypes from 'prop-types'; import type { Shape } from './core'; import { type Platform, platformPropType } from './device-types'; export type Dimensions = $ReadOnly<{| height: number, width: number, |}>; export const dimensionsPropType = PropTypes.shape({ height: PropTypes.number.isRequired, width: PropTypes.number.isRequired, }); export type MediaType = 'photo' | 'video'; export type Image = {| id: string, uri: string, type: 'photo', dimensions: Dimensions, // stored on native only during creation in case retry needed after state lost localMediaSelection?: NativeMediaSelection, |}; export type Video = {| id: string, uri: string, type: 'video', dimensions: Dimensions, loop?: boolean, // stored on native only during creation in case retry needed after state lost localMediaSelection?: NativeMediaSelection, |}; export type Media = Image | Video; export type Corners = Shape<{| topLeft: boolean, topRight: boolean, bottomLeft: boolean, bottomRight: boolean, |}>; export type MediaInfo = | {| ...Image, corners: Corners, index: number, |} | {| ...Video, corners: Corners, index: number, |}; export const mediaTypePropType = PropTypes.oneOf(['photo', 'video']); const mediaPropTypes = { id: PropTypes.string.isRequired, uri: PropTypes.string.isRequired, type: mediaTypePropType.isRequired, dimensions: dimensionsPropType.isRequired, filename: PropTypes.string, }; export const mediaPropType = PropTypes.shape(mediaPropTypes); export const cornersPropType = PropTypes.shape({ topLeft: PropTypes.bool, topRight: PropTypes.bool, bottomLeft: PropTypes.bool, bottomRight: PropTypes.bool, }); export const mediaInfoPropType = PropTypes.shape({ ...mediaPropTypes, corners: cornersPropType.isRequired, index: PropTypes.number.isRequired, }); export type UploadMultimediaResult = {| id: string, uri: string, dimensions: Dimensions, mediaType: MediaType, loop: boolean, |}; export type UpdateMultimediaMessageMediaPayload = {| messageID: string, currentMediaID: string, mediaUpdate: Shape, |}; export type UploadDeletionRequest = {| id: string, |}; export type FFmpegStatistics = {| // seconds of video being processed per second +speed: number, // total milliseconds of video processed so far +time: number, // total result file size in bytes so far +size: number, +videoQuality: number, +videoFrameNumber: number, +videoFps: number, +bitrate: number, |}; export type VideoProbeMediaMissionStep = {| step: 'video_probe', success: boolean, exceptionMessage: ?string, time: number, // ms path: string, validFormat: boolean, duration: ?number, // seconds codec: ?string, format: ?$ReadOnlyArray, dimensions: ?Dimensions, |}; export type ReadFileHeaderMediaMissionStep = {| step: 'read_file_header', success: boolean, exceptionMessage: ?string, time: number, // ms uri: string, mime: ?string, mediaType: ?MediaType, |}; export type DetermineFileTypeMediaMissionStep = {| step: 'determine_file_type', success: boolean, exceptionMessage: ?string, time: number, // ms inputFilename: string, outputMIME: ?string, outputMediaType: ?MediaType, outputFilename: ?string, |}; export type FrameCountMediaMissionStep = {| step: 'frame_count', success: boolean, exceptionMessage: ?string, time: number, path: string, mime: string, hasMultipleFrames: ?boolean, |}; export type DisposeTemporaryFileMediaMissionStep = {| step: 'dispose_temporary_file', success: boolean, exceptionMessage: ?string, time: number, // ms path: string, |}; export type MakeDirectoryMediaMissionStep = {| step: 'make_directory', success: boolean, exceptionMessage: ?string, time: number, // ms path: string, |}; export type AndroidScanFileMediaMissionStep = {| step: 'android_scan_file', success: boolean, exceptionMessage: ?string, time: number, // ms path: string, |}; export type FetchFileHashMediaMissionStep = {| step: 'fetch_file_hash', success: boolean, exceptionMessage: ?string, time: number, // ms path: string, hash: ?string, |}; export type CopyFileMediaMissionStep = {| step: 'copy_file', success: boolean, exceptionMessage: ?string, time: number, // ms source: string, destination: string, |}; export type GetOrientationMediaMissionStep = {| step: 'exif_fetch', success: boolean, exceptionMessage: ?string, time: number, // ms orientation: ?number, |}; export type MediaLibrarySelection = | {| step: 'photo_library', dimensions: Dimensions, filename: string, uri: string, mediaNativeID: string, selectTime: number, // ms timestamp sendTime: number, // ms timestamp retries: number, |} | {| step: 'video_library', dimensions: Dimensions, filename: string, uri: string, mediaNativeID: string, selectTime: number, // ms timestamp sendTime: number, // ms timestamp retries: number, duration: number, // seconds |}; const photoLibrarySelectionPropType = PropTypes.shape({ step: PropTypes.oneOf(['photo_library']).isRequired, dimensions: dimensionsPropType.isRequired, filename: PropTypes.string.isRequired, uri: PropTypes.string.isRequired, mediaNativeID: PropTypes.string.isRequired, selectTime: PropTypes.number.isRequired, sendTime: PropTypes.number.isRequired, retries: PropTypes.number.isRequired, }); const videoLibrarySelectionPropType = PropTypes.shape({ step: PropTypes.oneOf(['video_library']).isRequired, dimensions: dimensionsPropType.isRequired, filename: PropTypes.string.isRequired, uri: PropTypes.string.isRequired, mediaNativeID: PropTypes.string.isRequired, selectTime: PropTypes.number.isRequired, sendTime: PropTypes.number.isRequired, retries: PropTypes.number.isRequired, duration: PropTypes.number.isRequired, }); export const mediaLibrarySelectionPropType = PropTypes.oneOfType([ photoLibrarySelectionPropType, videoLibrarySelectionPropType, ]); export type PhotoCapture = {| step: 'photo_capture', time: number, // ms dimensions: Dimensions, filename: string, uri: string, captureTime: number, // ms timestamp selectTime: number, // ms timestamp sendTime: number, // ms timestamp retries: number, |}; -export type NativeMediaSelection = MediaLibrarySelection | PhotoCapture; +export type PhotoPaste = {| + +step: 'photo_paste', + +dimensions: Dimensions, + +filename: string, + +uri: string, + +selectTime: number, // ms timestamp + +sendTime: number, // ms timestamp + +retries: number, +|}; + +const photoPasteSelectionPropType = PropTypes.exact({ + step: PropTypes.oneOf(['photo_paste']).isRequired, + dimensions: dimensionsPropType.isRequired, + filename: PropTypes.string.isRequired, + uri: PropTypes.string.isRequired, + selectTime: PropTypes.number.isRequired, + sendTime: PropTypes.number.isRequired, + retries: PropTypes.number.isRequired, +}); + +export type NativeMediaSelection = + | MediaLibrarySelection + | PhotoCapture + | PhotoPaste; export type MediaMissionStep = | NativeMediaSelection | {| step: 'web_selection', filename: string, size: number, // in bytes mime: string, selectTime: number, // ms timestamp |} | {| step: 'asset_info_fetch', success: boolean, exceptionMessage: ?string, time: number, // ms localURI: ?string, orientation: ?number, |} | {| step: 'stat_file', success: boolean, exceptionMessage: ?string, time: number, // ms uri: string, fileSize: ?number, |} | ReadFileHeaderMediaMissionStep | DetermineFileTypeMediaMissionStep | FrameCountMediaMissionStep | {| step: 'photo_manipulation', success: boolean, exceptionMessage: ?string, time: number, // ms manipulation: Object, newMIME: ?string, newDimensions: ?Dimensions, newURI: ?string, |} | VideoProbeMediaMissionStep | {| step: 'video_ffmpeg_transcode', success: boolean, exceptionMessage: ?string, time: number, // ms returnCode: ?number, newPath: ?string, stats: ?FFmpegStatistics, |} | DisposeTemporaryFileMediaMissionStep | {| step: 'save_media', uri: string, time: number, // ms timestamp |} | {| step: 'permissions_check', success: boolean, exceptionMessage: ?string, time: number, // ms platform: Platform, permissions: $ReadOnlyArray, |} | MakeDirectoryMediaMissionStep | AndroidScanFileMediaMissionStep | {| step: 'ios_save_to_library', success: boolean, exceptionMessage: ?string, time: number, // ms uri: string, |} | {| step: 'fetch_blob', success: boolean, exceptionMessage: ?string, time: number, // ms inputURI: string, uri: string, size: ?number, mime: ?string, |} | {| step: 'data_uri_from_blob', success: boolean, exceptionMessage: ?string, time: number, // ms first255Chars: ?string, |} | {| step: 'array_buffer_from_blob', success: boolean, exceptionMessage: ?string, time: number, // ms |} | {| step: 'mime_check', success: boolean, exceptionMessage: ?string, time: number, // ms mime: ?string, |} | {| step: 'write_file', success: boolean, exceptionMessage: ?string, time: number, // ms path: string, length: number, |} | FetchFileHashMediaMissionStep | CopyFileMediaMissionStep | GetOrientationMediaMissionStep | {| step: 'preload_image', success: boolean, exceptionMessage: ?string, time: number, // ms uri: string, dimensions: ?Dimensions, |} | {| step: 'reorient_image', success: boolean, exceptionMessage: ?string, time: number, // ms uri: ?string, |} | {| step: 'upload', success: boolean, exceptionMessage: ?string, time: number, // ms inputFilename: string, outputMediaType: ?MediaType, outputURI: ?string, outputDimensions: ?Dimensions, outputLoop: ?boolean, hasWiFi?: boolean, |} | {| step: 'wait_for_capture_uri_unload', success: boolean, time: number, // ms uri: string, |}; export type MediaMissionFailure = | {| success: false, reason: 'no_file_path', |} | {| success: false, reason: 'file_stat_failed', uri: string, |} | {| success: false, reason: 'photo_manipulation_failed', size: number, // in bytes |} | {| success: false, reason: 'media_type_fetch_failed', detectedMIME: ?string, |} | {| success: false, reason: 'mime_type_mismatch', reportedMediaType: MediaType, reportedMIME: string, detectedMIME: string, |} | {| success: false, reason: 'http_upload_failed', exceptionMessage: ?string, |} | {| success: false, reason: 'video_too_long', duration: number, // in seconds |} | {| success: false, reason: 'video_probe_failed', |} | {| success: false, reason: 'video_transcode_failed', |} | {| success: false, reason: 'processing_exception', time: number, // ms exceptionMessage: ?string, |} | {| success: false, reason: 'save_unsupported', |} | {| success: false, reason: 'missing_permission', |} | {| success: false, reason: 'make_directory_failed', |} | {| success: false, reason: 'resolve_failed', uri: string, |} | {| success: false, reason: 'save_to_library_failed', uri: string, |} | {| success: false, reason: 'fetch_failed', |} | {| success: false, reason: 'data_uri_failed', |} | {| success: false, reason: 'array_buffer_failed', |} | {| success: false, reason: 'mime_check_failed', mime: ?string, |} | {| success: false, reason: 'write_file_failed', |} | {| success: false, reason: 'fetch_file_hash_failed', |} | {| success: false, reason: 'copy_file_failed', |} | {| success: false, reason: 'exif_fetch_failed', |} | {| success: false, reason: 'reorient_image_failed', |} | {| success: false, reason: 'web_sibling_validation_failed', |}; export type MediaMissionResult = MediaMissionFailure | {| success: true |}; export type MediaMission = {| steps: $ReadOnlyArray, result: MediaMissionResult, userTime: number, totalTime: number, |}; export const mediaMissionStepPropType = PropTypes.oneOfType([ photoLibrarySelectionPropType, videoLibrarySelectionPropType, + photoPasteSelectionPropType, PropTypes.shape({ step: PropTypes.oneOf(['photo_capture']).isRequired, time: PropTypes.number.isRequired, dimensions: dimensionsPropType.isRequired, filename: PropTypes.string.isRequired, uri: PropTypes.string.isRequired, captureTime: PropTypes.number.isRequired, selectTime: PropTypes.number.isRequired, sendTime: PropTypes.number.isRequired, retries: PropTypes.number.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['web_selection']).isRequired, filename: PropTypes.string.isRequired, size: PropTypes.number.isRequired, mime: PropTypes.string.isRequired, selectTime: PropTypes.number.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['asset_info_fetch']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, localURI: PropTypes.string, orientation: PropTypes.number, }), PropTypes.shape({ step: PropTypes.oneOf(['stat_file']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, uri: PropTypes.string.isRequired, fileSize: PropTypes.number, }), PropTypes.shape({ step: PropTypes.oneOf(['read_file_header']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, uri: PropTypes.string.isRequired, mime: PropTypes.string, mediaType: mediaTypePropType, }), PropTypes.shape({ step: PropTypes.oneOf(['determine_file_type']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, inputFilename: PropTypes.string.isRequired, outputMIME: PropTypes.string, outputMediaType: mediaTypePropType, outputFilename: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['frame_count']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, mime: PropTypes.string.isRequired, hasMultipleFrames: PropTypes.bool, }), PropTypes.shape({ step: PropTypes.oneOf(['photo_manipulation']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, manipulation: PropTypes.object.isRequired, newMIME: PropTypes.string, newDimensions: dimensionsPropType, newURI: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['video_probe']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, validFormat: PropTypes.bool.isRequired, duration: PropTypes.number, codec: PropTypes.string, format: PropTypes.arrayOf(PropTypes.string), dimensions: dimensionsPropType, }), PropTypes.shape({ step: PropTypes.oneOf(['video_ffmpeg_transcode']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, returnCode: PropTypes.number, newPath: PropTypes.string, stats: PropTypes.object, }), PropTypes.shape({ step: PropTypes.oneOf(['dispose_temporary_file']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['save_media']).isRequired, uri: PropTypes.string.isRequired, time: PropTypes.number.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['permissions_check']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, platform: platformPropType.isRequired, permissions: PropTypes.arrayOf(PropTypes.string).isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['make_directory']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['android_scan_file']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['fetch_file_hash']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, hash: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['copy_file']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, source: PropTypes.string.isRequired, destination: PropTypes.string.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['ios_save_to_library']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, uri: PropTypes.string.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['fetch_blob']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, inputURI: PropTypes.string.isRequired, uri: PropTypes.string.isRequired, size: PropTypes.number, mime: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['data_uri_from_blob']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, first255Chars: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['array_buffer_from_blob']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['mime_check']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, mime: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['write_file']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, length: PropTypes.number.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['exif_fetch']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, orientation: PropTypes.number, }), PropTypes.shape({ step: PropTypes.oneOf(['preload_image']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, uri: PropTypes.string.isRequired, dimensions: dimensionsPropType, }), PropTypes.shape({ step: PropTypes.oneOf(['reorient_image']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, uri: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['upload']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, inputFilename: PropTypes.string.isRequired, outputMediaType: mediaTypePropType, outputURI: PropTypes.string, outputDimensions: dimensionsPropType, outputLoop: PropTypes.bool, hasWiFi: PropTypes.bool, }), PropTypes.shape({ step: PropTypes.oneOf(['wait_for_capture_uri_unload']).isRequired, success: PropTypes.bool.isRequired, time: PropTypes.number.isRequired, uri: PropTypes.string.isRequired, }), ]); export const mediaMissionPropType = PropTypes.shape({ steps: PropTypes.arrayOf(mediaMissionStepPropType).isRequired, result: PropTypes.oneOfType([ PropTypes.shape({ success: PropTypes.oneOf([true]).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['no_file_path']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['file_stat_failed']).isRequired, uri: PropTypes.string.isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['photo_manipulation_failed']).isRequired, size: PropTypes.number.isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['media_type_fetch_failed']).isRequired, detectedMIME: PropTypes.string, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['mime_type_mismatch']).isRequired, reportedMediaType: mediaTypePropType.isRequired, reportedMIME: PropTypes.string.isRequired, detectedMIME: PropTypes.string.isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['http_upload_failed']).isRequired, exceptionMessage: PropTypes.string, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['video_too_long']).isRequired, duration: PropTypes.number.isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['video_probe_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['video_transcode_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['processing_exception']).isRequired, time: PropTypes.number.isRequired, exceptionMessage: PropTypes.string, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['save_unsupported']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['missing_permission']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['make_directory_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['resolve_failed']).isRequired, uri: PropTypes.string.isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['save_to_library_failed']).isRequired, uri: PropTypes.string.isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['fetch_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['data_uri_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['array_buffer_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['mime_check_failed']).isRequired, mime: PropTypes.string, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['write_file_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['fetch_file_hash_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['copy_file_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['exif_fetch_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['reorient_image_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['web_sibling_validation_failed']).isRequired, }), ]), userTime: PropTypes.number.isRequired, totalTime: PropTypes.number.isRequired, }); diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js index c792f71ca..443430534 100644 --- a/native/input/input-state-container.react.js +++ b/native/input/input-state-container.react.js @@ -1,1081 +1,1091 @@ // @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, type RawImagesMessageInfo, type RawMediaMessageInfo, type RawTextMessageInfo, } from 'lib/types/message-types'; 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, } 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(), ); 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) => { 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, }; } 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);