diff --git a/lib/actions/message-actions.js b/lib/actions/message-actions.js index 038448f9f..5e7e7718d 100644 --- a/lib/actions/message-actions.js +++ b/lib/actions/message-actions.js @@ -1,180 +1,180 @@ // @flow import invariant from 'invariant'; import type { FetchMessageInfosPayload, SendMessageResult, SimpleMessagesPayload, } from '../types/message-types'; import type { FetchJSON, FetchResultInfo } from '../utils/fetch-json'; const fetchMessagesBeforeCursorActionTypes = Object.freeze({ started: 'FETCH_MESSAGES_BEFORE_CURSOR_STARTED', success: 'FETCH_MESSAGES_BEFORE_CURSOR_SUCCESS', failed: 'FETCH_MESSAGES_BEFORE_CURSOR_FAILED', }); const fetchMessagesBeforeCursor = ( fetchJSON: FetchJSON, ): (( threadID: string, beforeMessageID: string, ) => Promise) => async ( threadID, beforeMessageID, ) => { const response = await fetchJSON('fetch_messages', { cursors: { [threadID]: beforeMessageID, }, }); return { threadID, rawMessageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses[threadID], }; }; const fetchMostRecentMessagesActionTypes = Object.freeze({ started: 'FETCH_MOST_RECENT_MESSAGES_STARTED', success: 'FETCH_MOST_RECENT_MESSAGES_SUCCESS', failed: 'FETCH_MOST_RECENT_MESSAGES_FAILED', }); const fetchMostRecentMessages = ( fetchJSON: FetchJSON, ): (( threadID: string, ) => Promise) => async threadID => { const response = await fetchJSON('fetch_messages', { cursors: { [threadID]: null, }, }); return { threadID, rawMessageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses[threadID], }; }; const fetchSingleMostRecentMessagesFromThreadsActionTypes = Object.freeze({ started: 'FETCH_SINGLE_MOST_RECENT_MESSAGES_FROM_THREADS_STARTED', success: 'FETCH_SINGLE_MOST_RECENT_MESSAGES_FROM_THREADS_SUCCESS', failed: 'FETCH_SINGLE_MOST_RECENT_MESSAGES_FROM_THREADS_FAILED', }); const fetchSingleMostRecentMessagesFromThreads = ( fetchJSON: FetchJSON, ): (( threadIDs: $ReadOnlyArray, ) => Promise) => async threadIDs => { const cursors = Object.fromEntries( threadIDs.map(threadID => [threadID, null]), ); const response = await fetchJSON('fetch_messages', { cursors, numberPerThread: 1, }); return { rawMessageInfos: response.rawMessageInfos, truncationStatuses: response.truncationStatuses, }; }; const sendTextMessageActionTypes = Object.freeze({ started: 'SEND_TEXT_MESSAGE_STARTED', success: 'SEND_TEXT_MESSAGE_SUCCESS', failed: 'SEND_TEXT_MESSAGE_FAILED', }); const sendTextMessage = ( fetchJSON: FetchJSON, ): (( threadID: string, localID: string, text: string, ) => Promise) => async (threadID, localID, text) => { let resultInfo; const getResultInfo = (passedResultInfo: FetchResultInfo) => { resultInfo = passedResultInfo; }; const response = await fetchJSON( 'create_text_message', { threadID, localID, text, }, { getResultInfo }, ); const resultInterface = resultInfo?.interface; invariant( resultInterface, 'getResultInfo not called before fetchJSON resolves', ); return { id: response.newMessageInfo.id, time: response.newMessageInfo.time, interface: resultInterface, }; }; const createLocalMessageActionType = 'CREATE_LOCAL_MESSAGE'; const sendMultimediaMessageActionTypes = Object.freeze({ started: 'SEND_MULTIMEDIA_MESSAGE_STARTED', success: 'SEND_MULTIMEDIA_MESSAGE_SUCCESS', failed: 'SEND_MULTIMEDIA_MESSAGE_FAILED', }); -const sendMultimediaMessage = ( +const legacySendMultimediaMessage = ( fetchJSON: FetchJSON, ): (( threadID: string, localID: string, mediaIDs: $ReadOnlyArray, ) => Promise) => async (threadID, localID, mediaIDs) => { let resultInfo; const getResultInfo = (passedResultInfo: FetchResultInfo) => { resultInfo = passedResultInfo; }; const response = await fetchJSON( 'create_multimedia_message', { threadID, localID, mediaIDs, }, { getResultInfo }, ); const resultInterface = resultInfo?.interface; invariant( resultInterface, 'getResultInfo not called before fetchJSON resolves', ); return { id: response.newMessageInfo.id, time: response.newMessageInfo.time, interface: resultInterface, }; }; const saveMessagesActionType = 'SAVE_MESSAGES'; const processMessagesActionType = 'PROCESS_MESSAGES'; const messageStorePruneActionType = 'MESSAGE_STORE_PRUNE'; const setMessageStoreMessages = 'SET_MESSAGE_STORE_MESSAGES'; export { fetchMessagesBeforeCursorActionTypes, fetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, fetchSingleMostRecentMessagesFromThreadsActionTypes, fetchSingleMostRecentMessagesFromThreads, sendTextMessageActionTypes, sendTextMessage, createLocalMessageActionType, sendMultimediaMessageActionTypes, - sendMultimediaMessage, + legacySendMultimediaMessage, saveMessagesActionType, processMessagesActionType, messageStorePruneActionType, setMessageStoreMessages, }; diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js index c6f52babe..ebb18847e 100644 --- a/native/input/input-state-container.react.js +++ b/native/input/input-state-container.react.js @@ -1,1413 +1,1415 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Platform } from 'react-native'; import * as Upload from 'react-native-background-upload'; import { useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import { createLocalMessageActionType, sendMultimediaMessageActionTypes, - sendMultimediaMessage, + legacySendMultimediaMessage, sendTextMessageActionTypes, sendTextMessage, } from 'lib/actions/message-actions'; import { queueReportsActionType } from 'lib/actions/report-actions'; import { newThread } from 'lib/actions/thread-actions'; import { uploadMultimedia, updateMultimediaMessageMediaActionType, type MultimediaUploadCallbacks, type MultimediaUploadExtras, } from 'lib/actions/upload-actions'; import { pathFromURI, replaceExtension } from 'lib/media/file-utils'; import { isLocalUploadID, getNextLocalUploadID } from 'lib/media/media-utils'; import { videoDurationLimit } from 'lib/media/video-utils'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors'; import { createMediaMessageInfo, localIDPrefix, } from 'lib/shared/message-utils'; import { createRealThreadFromPendingThread, threadIsPending, } from 'lib/shared/thread-utils'; import { isStaff } from 'lib/shared/user-utils'; import type { CalendarQuery } from 'lib/types/entry-types'; 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 { Dispatch } from 'lib/types/redux-types'; import { type MediaMissionReportCreationRequest, reportTypes, } from 'lib/types/report-types'; import type { ClientNewThreadRequest, NewThreadResult, ThreadInfo, } from 'lib/types/thread-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } 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 { values } from 'lib/utils/objects'; import { useIsReportEnabled } from 'lib/utils/report-utils'; import { disposeTempFile } from '../media/file-utils'; import { processMedia } from '../media/media-utils'; import { displayActionResultModal } from '../navigation/action-result-modal'; import { useCalendarQuery } from '../navigation/nav-selectors'; import { useSelector } from '../redux/redux-utils'; import { InputStateContext, type PendingMultimediaUploads, type MultimediaProcessingStep, } from './input-state'; type MediaIDs = | { +type: 'photo', +localMediaID: string } | { +type: 'video', +localMediaID: string, +localThumbnailID: string }; type UploadFileInput = { +selection: NativeMediaSelection, +ids: MediaIDs, }; type CompletedUploads = { +[localMessageID: string]: ?Set }; type BaseProps = { +children: React.Node, }; type Props = { ...BaseProps, +viewerID: ?string, +nextLocalID: number, +messageStoreMessages: { +[id: string]: RawMessageInfo }, +ongoingMessageCreation: boolean, +hasWiFi: boolean, +mediaReportsEnabled: boolean, +calendarQuery: () => CalendarQuery, +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, +uploadMultimedia: ( multimedia: Object, extras: MultimediaUploadExtras, callbacks: MultimediaUploadCallbacks, ) => Promise, +sendMultimediaMessage: ( threadID: string, localID: string, mediaIDs: $ReadOnlyArray, ) => Promise, +sendTextMessage: ( threadID: string, localID: string, text: string, ) => Promise, +newThread: (request: ClientNewThreadRequest) => Promise, }; type State = { +pendingUploads: PendingMultimediaUploads, }; class InputStateContainer extends React.PureComponent { state: State = { pendingUploads: {}, }; sendCallbacks: Array<() => void> = []; activeURIs = new Map(); replyCallbacks: Array<(message: string) => void> = []; pendingThreadCreations = new Map>(); static getCompletedUploads(props: Props, state: State): CompletedUploads { const completedUploads = {}; for (const 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`, ); let allUploadsComplete = true; const completedUploadIDs = new Set(Object.keys(messagePendingUploads)); for (const singleMedia of rawMessageInfo.media) { if (isLocalUploadID(singleMedia.id)) { allUploadsComplete = false; completedUploadIDs.delete(singleMedia.id); } } if (allUploadsComplete) { completedUploads[localMessageID] = null; } else if (completedUploadIDs.size > 0) { completedUploads[localMessageID] = completedUploadIDs; } } 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 (const 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 (const 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 (const 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); } } async dispatchMultimediaMessageAction(messageInfo: RawMultimediaMessageInfo) { if (!threadIsPending(messageInfo.threadID)) { this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(messageInfo), undefined, messageInfo, ); return; } this.props.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: messageInfo, }); let newThreadID = null; try { const threadCreationPromise = this.pendingThreadCreations.get( messageInfo.threadID, ); if (!threadCreationPromise) { // When we create or retry multimedia message, we add a promise to // pendingThreadCreations map. This promise can be removed in // sendMultimediaMessage and sendTextMessage methods. When any of these // method remove the promise, it has to be settled. If the promise was // fulfilled, this method would be called with realized thread, so we // can conclude that the promise was rejected. We don't have enough info // here to retry the thread creation, but we can mark the message as // failed. Then the retry will be possible and promise will be created // again. throw new Error('Thread creation failed'); } newThreadID = await threadCreationPromise; } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; this.props.dispatch({ type: sendMultimediaMessageActionTypes.failed, payload: copy, error: true, }); return; } finally { this.pendingThreadCreations.delete(messageInfo.threadID); } const newMessageInfo = { ...messageInfo, threadID: newThreadID, time: Date.now(), }; this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(newMessageInfo), undefined, newMessageInfo, ); } async sendMultimediaMessageAction( messageInfo: RawMultimediaMessageInfo, ): Promise { const { localID, threadID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const mediaIDs = []; for (const { 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, retryMessage: this.retryMessage, registerSendCallback: this.registerSendCallback, unregisterSendCallback: this.unregisterSendCallback, uploadInProgress: this.uploadInProgress, reportURIDisplayed: this.reportURIDisplayed, }), ); uploadInProgress = () => { if (this.props.ongoingMessageCreation) { return true; } const { pendingUploads } = this.state; return values(pendingUploads).some(messagePendingUploads => values(messagePendingUploads).some(upload => !upload.failed), ); }; sendTextMessage = async ( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); if (!threadIsPending(threadInfo.id)) { this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction(messageInfo), undefined, messageInfo, ); return; } this.props.dispatch({ type: sendTextMessageActionTypes.started, payload: messageInfo, }); let newThreadID = null; try { newThreadID = await this.startThreadCreation(threadInfo); } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; this.props.dispatch({ type: sendTextMessageActionTypes.failed, payload: copy, error: true, }); return; } finally { this.pendingThreadCreations.delete(threadInfo.id); } const newMessageInfo = { ...messageInfo, threadID: newThreadID, time: Date.now(), }; this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction(newMessageInfo), undefined, newMessageInfo, ); }; async startThreadCreation(threadInfo: ThreadInfo): Promise { if (!threadIsPending(threadInfo.id)) { return threadInfo.id; } let threadCreationPromise = this.pendingThreadCreations.get(threadInfo.id); if (!threadCreationPromise) { const calendarQuery = this.props.calendarQuery(); threadCreationPromise = createRealThreadFromPendingThread({ threadInfo, dispatchActionPromise: this.props.dispatchActionPromise, createNewThread: this.props.newThread, sourceMessageID: threadInfo.sourceMessageID, viewerID: this.props.viewerID, calendarQuery, }); this.pendingThreadCreations.set(threadInfo.id, threadCreationPromise); } return threadCreationPromise; } 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 ( selections: $ReadOnlyArray, threadInfo: ThreadInfo, ) => { this.sendCallbacks.forEach(callback => callback()); const localMessageID = `${localIDPrefix}${this.props.nextLocalID}`; this.startThreadCreation(threadInfo); const uploadFileInputs = [], media = []; for (const selection of selections) { const localMediaID = getNextLocalUploadID(); let ids; if ( selection.step === 'photo_library' || selection.step === 'photo_capture' || selection.step === 'photo_paste' ) { media.push({ id: localMediaID, uri: selection.uri, type: 'photo', dimensions: selection.dimensions, localMediaSelection: selection, }); ids = { type: 'photo', localMediaID }; } const localThumbnailID = getNextLocalUploadID(); if (selection.step === 'video_library') { media.push({ id: localMediaID, uri: selection.uri, type: 'video', dimensions: selection.dimensions, localMediaSelection: selection, loop: false, thumbnailID: localThumbnailID, thumbnailURI: selection.uri, }); ids = { type: 'video', localMediaID, localThumbnailID }; } invariant(ids, `unexpected MediaSelection ${selection.step}`); uploadFileInputs.push({ selection, ids }); } const pendingUploads = {}; for (const uploadFileInput of uploadFileInputs) { const { localMediaID } = uploadFileInput.ids; pendingUploads[localMediaID] = { failed: false, progressPercent: 0, processingStep: null, }; if (uploadFileInput.ids.type === 'video') { const { localThumbnailID } = uploadFileInput.ids; pendingUploads[localThumbnailID] = { failed: false, progressPercent: 0, processingStep: null, }; } } 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 messageInfo = createMediaMessageInfo({ localID: localMessageID, threadID: threadInfo.id, creatorID, media, }); this.props.dispatch({ type: createLocalMessageActionType, payload: messageInfo, }); }, ); await this.uploadFiles(localMessageID, uploadFileInputs); }; async uploadFiles( localMessageID: string, uploadFileInputs: $ReadOnlyArray, ) { const results = await Promise.all( uploadFileInputs.map(uploadFileInput => this.uploadFile(localMessageID, uploadFileInput), ), ); const errors = [...new Set(results.filter(Boolean))]; if (errors.length > 0) { displayActionResultModal(errors.join(', ') + ' :('); } } async uploadFile( localMessageID: string, uploadFileInput: UploadFileInput, ): Promise { const { ids, selection } = uploadFileInput; const { localMediaID } = ids; const start = selection.sendTime; const steps = [selection]; let serverID; let userTime; let errorMessage; let reportPromise; const onUploadFinished = async (result: MediaMissionResult) => { if (!this.props.mediaReportsEnabled) { return errorMessage; } if (reportPromise) { const finalSteps = await reportPromise; steps.push(...finalSteps); } const totalTime = Date.now() - start; userTime = userTime ? userTime : totalTime; this.queueMediaMissionReport( { localID: localMediaID, localMessageID, serverID }, { steps, result, totalTime, userTime }, ); return errorMessage; }; const onUploadFailed = (mediaID: string, message: string) => { errorMessage = message; this.handleUploadFailure(localMessageID, mediaID); userTime = Date.now() - start; }; let processedMedia; const processingStart = Date.now(); try { const processMediaReturn = processMedia( selection, this.mediaProcessConfig(localMessageID, localMediaID), ); 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'; onUploadFailed(localMediaID, message); return await onUploadFinished(processResult); } processedMedia = processResult; } catch (e) { onUploadFailed(localMediaID, 'processing failed'); return await onUploadFinished({ 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, uploadThumbnailResult, mediaMissionResult; try { const uploadPromises = []; uploadPromises.push( this.props.uploadMultimedia( { uri: uploadURI, name: filename, type: mime }, { ...processedMedia.dimensions, loop: processedMedia.mediaType === 'video' ? processedMedia.loop : undefined, }, { onProgress: (percent: number) => this.setProgress( localMessageID, localMediaID, 'uploading', percent, ), uploadBlob: this.uploadBlob, }, ), ); if (processedMedia.mediaType === 'video') { uploadPromises.push( this.props.uploadMultimedia( { uri: processedMedia.uploadThumbnailURI, name: replaceExtension(`thumb${filename}`, 'jpg'), type: 'image/jpeg', }, { ...processedMedia.dimensions, loop: false, }, { uploadBlob: this.uploadBlob, }, ), ); } [uploadResult, uploadThumbnailResult] = await Promise.all(uploadPromises); mediaMissionResult = { success: true }; } catch (e) { uploadExceptionMessage = getMessageForException(e); onUploadFailed(localMediaID, 'upload failed'); mediaMissionResult = { success: false, reason: 'http_upload_failed', exceptionMessage: uploadExceptionMessage, }; } if ( (processedMedia.mediaType === 'photo' && uploadResult) || (processedMedia.mediaType === 'video' && uploadResult && uploadThumbnailResult) ) { const { id, uri, dimensions, loop } = uploadResult; serverID = id; let updateMediaPayload = { messageID: localMessageID, currentMediaID: localMediaID, mediaUpdate: { id, type: uploadResult.mediaType, uri, dimensions, localMediaSelection: undefined, loop: uploadResult.mediaType === 'video' ? loop : undefined, }, }; if (processedMedia.mediaType === 'video') { invariant(uploadThumbnailResult, 'uploadThumbnailResult exists'); const { uri: thumbnailURI } = uploadThumbnailResult; updateMediaPayload = { ...updateMediaPayload, mediaUpdate: { ...updateMediaPayload.mediaUpdate, thumbnailURI, }, }; } // When we dispatch this action, it updates Redux and triggers the // componentDidUpdate in this class. componentDidUpdate will handle // calling dispatchMultimediaMessageAction once all the uploads are // complete, and does not wait until this function concludes. this.props.dispatch({ type: updateMultimediaMessageMediaActionType, payload: updateMediaPayload, }); 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 cleanupPromises = []; 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 cleanupPromises.push( (async () => { const disposeStep = await disposeTempFile(shouldDisposePath); steps.push(disposeStep); })(), ); } // if there's a thumbnail we'll temporarily unlink it here // instead of in media-utils, will be changed in later diffs if (processedMedia.mediaType === 'video') { const { uploadThumbnailURI } = processedMedia; cleanupPromises.push( (async () => { const disposeStep = await disposeTempFile(uploadThumbnailURI); 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. Check out the // Multimedia component to see how the URIs get switched out. const captureURI = selection.uri; cleanupPromises.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(cleanupPromises); return await onUploadFinished(mediaMissionResult); } mediaProcessConfig(localMessageID: string, localID: string) { const { hasWiFi, viewerID } = this.props; const onTranscodingProgress = (percent: number) => { 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 (const 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) { 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: true, 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.dispatch({ type: queueReportsActionType, payload: { reports: [report], }, }); } messageHasUploadFailure = (localMessageID: string) => { const pendingUploads = this.state.pendingUploads[localMessageID]; if (!pendingUploads) { return false; } return values(pendingUploads).some(upload => upload.failed); }; 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, ); }; retryTextMessage = async ( rawMessageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, ) => { await this.sendTextMessage( { ...rawMessageInfo, time: Date.now(), }, threadInfo, ); }; retryMultimediaMessage = async ( rawMessageInfo: RawMultimediaMessageInfo, localMessageID: string, threadInfo: ThreadInfo, ) => { const pendingUploads = this.state.pendingUploads[localMessageID] ?? {}; const now = Date.now(); this.startThreadCreation(threadInfo); const updateMedia = (media: $ReadOnlyArray): T[] => media.map(singleMedia => { let updatedMedia = singleMedia; const oldMediaID = updatedMedia.id; if ( // not complete isLocalUploadID(oldMediaID) && // not still ongoing (!pendingUploads[oldMediaID] || pendingUploads[oldMediaID].failed) ) { // 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 mediaID = pendingUploads[oldMediaID] ? oldMediaID : getNextLocalUploadID(); if (updatedMedia.type === 'photo') { updatedMedia = { type: 'photo', ...updatedMedia, id: mediaID, }; } else { updatedMedia = { type: 'video', ...updatedMedia, id: mediaID, }; } } if (updatedMedia.type === 'video') { const oldThumbnailID = updatedMedia.thumbnailID; invariant(oldThumbnailID, 'oldThumbnailID not null or undefined'); if ( // not complete isLocalUploadID(oldThumbnailID) && // not still ongoing (!pendingUploads[oldThumbnailID] || pendingUploads[oldThumbnailID].failed) ) { const thumbnailID = pendingUploads[oldThumbnailID] ? oldThumbnailID : getNextLocalUploadID(); updatedMedia = { ...updatedMedia, thumbnailID, }; } } if (updatedMedia === singleMedia) { return singleMedia; } const oldSelection = updatedMedia.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 (updatedMedia.type === 'photo') { return { type: 'photo', ...updatedMedia, localMediaSelection: selection, }; } return { type: 'video', ...updatedMedia, 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 (const singleMedia of newRawMessageInfo.media) { if (isLocalUploadID(singleMedia.id)) { 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.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: newRawMessageInfo, }); // We clear out the failed status on individual media here, // which makes the UI show pending status instead of error messages for (const singleMedia of retryMedia) { pendingUploads[singleMedia.id] = { failed: false, progressPercent: 0, processingStep: null, }; if (singleMedia.type === 'video') { const { thumbnailID } = singleMedia; invariant(thumbnailID, 'thumbnailID not null or undefined'); pendingUploads[thumbnailID] = { failed: false, progressPercent: 0, processingStep: null, }; } } this.setState(prevState => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, })); const uploadFileInputs = retryMedia.map(singleMedia => { invariant( singleMedia.localMediaSelection, 'localMediaSelection should be set on locally created Media', ); let ids; if (singleMedia.type === 'photo') { ids = { type: 'photo', localMediaID: singleMedia.id }; } else { invariant( singleMedia.thumbnailID, 'singleMedia.thumbnailID should be set for videos', ); ids = { type: 'video', localMediaID: singleMedia.id, localThumbnailID: singleMedia.thumbnailID, }; } return { selection: singleMedia.localMediaSelection, ids, }; }); await this.uploadFiles(localMessageID, uploadFileInputs); }; retryMessage = async (localMessageID: string, threadInfo: ThreadInfo) => { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`); if (rawMessageInfo.type === messageTypes.TEXT) { await this.retryTextMessage(rawMessageInfo, threadInfo); } else if ( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ) { await this.retryMultimediaMessage( rawMessageInfo, localMessageID, threadInfo, ); } }; 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 (const 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, ); const ConnectedInputStateContainer: React.ComponentType = React.memo( function ConnectedInputStateContainer(props: BaseProps) { const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const nextLocalID = useSelector(state => state.nextLocalID); const messageStoreMessages = useSelector( state => state.messageStore.messages, ); const ongoingMessageCreation = useSelector( state => combineLoadingStatuses( mediaCreationLoadingStatusSelector(state), textCreationLoadingStatusSelector(state), ) === 'loading', ); const hasWiFi = useSelector(state => state.connectivity.hasWiFi); const calendarQuery = useCalendarQuery(); const callUploadMultimedia = useServerCall(uploadMultimedia); - const callSendMultimediaMessage = useServerCall(sendMultimediaMessage); + const callSendMultimediaMessage = useServerCall( + legacySendMultimediaMessage, + ); const callSendTextMessage = useServerCall(sendTextMessage); const callNewThread = useServerCall(newThread); const dispatchActionPromise = useDispatchActionPromise(); const dispatch = useDispatch(); const mediaReportsEnabled = useIsReportEnabled('mediaReports'); return ( ); }, ); export default ConnectedInputStateContainer; diff --git a/web/input/input-state-container.react.js b/web/input/input-state-container.react.js index ef70090cf..344803c25 100644 --- a/web/input/input-state-container.react.js +++ b/web/input/input-state-container.react.js @@ -1,1295 +1,1297 @@ // @flow import { detect as detectBrowser } from 'detect-browser'; import invariant from 'invariant'; import _groupBy from 'lodash/fp/groupBy'; import _keyBy from 'lodash/fp/keyBy'; import _omit from 'lodash/fp/omit'; import _partition from 'lodash/fp/partition'; import _sortBy from 'lodash/fp/sortBy'; import _memoize from 'lodash/memoize'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; import { createLocalMessageActionType, sendMultimediaMessageActionTypes, - sendMultimediaMessage, + legacySendMultimediaMessage, sendTextMessageActionTypes, sendTextMessage, } from 'lib/actions/message-actions'; import { queueReportsActionType } from 'lib/actions/report-actions'; import { newThread } from 'lib/actions/thread-actions'; import { uploadMultimedia, updateMultimediaMessageMediaActionType, deleteUpload, type MultimediaUploadCallbacks, type MultimediaUploadExtras, } from 'lib/actions/upload-actions'; import { getNextLocalUploadID } from 'lib/media/media-utils'; import { pendingToRealizedThreadIDsSelector } from 'lib/selectors/thread-selectors'; import { createMediaMessageInfo, localIDPrefix, } from 'lib/shared/message-utils'; import { createRealThreadFromPendingThread, threadIsPending, } from 'lib/shared/thread-utils'; import type { CalendarQuery } from 'lib/types/entry-types'; import type { UploadMultimediaResult, MediaMissionStep, MediaMissionFailure, 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 { Dispatch } from 'lib/types/redux-types'; import { reportTypes } from 'lib/types/report-types'; import type { ClientNewThreadRequest, NewThreadResult, ThreadInfo, } from 'lib/types/thread-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import { getConfig } from 'lib/utils/config'; import { getMessageForException, cloneError } from 'lib/utils/errors'; import { validateFile, preloadImage } from '../media/media-utils'; import InvalidUploadModal from '../modals/chat/invalid-upload.react'; import { useModalContext } from '../modals/modal-provider.react'; import { useSelector } from '../redux/redux-utils'; import { nonThreadCalendarQuery } from '../selectors/nav-selectors'; import { type PendingMultimediaUpload, InputStateContext } from './input-state'; type BaseProps = { +children: React.Node, }; type Props = { ...BaseProps, +activeChatThreadID: ?string, +viewerID: ?string, +messageStoreMessages: { +[id: string]: RawMessageInfo }, +exifRotate: boolean, +pendingRealizedThreadIDs: $ReadOnlyMap, +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, +calendarQuery: () => CalendarQuery, +uploadMultimedia: ( multimedia: Object, extras: MultimediaUploadExtras, callbacks: MultimediaUploadCallbacks, ) => Promise, +deleteUpload: (id: string) => Promise, +sendMultimediaMessage: ( threadID: string, localID: string, mediaIDs: $ReadOnlyArray, ) => Promise, +sendTextMessage: ( threadID: string, localID: string, text: string, ) => Promise, +newThread: (request: ClientNewThreadRequest) => Promise, +pushModal: (modal: React.Node) => void, +sendCallbacks: $ReadOnlyArray<() => mixed>, +registerSendCallback: (() => mixed) => void, +unregisterSendCallback: (() => mixed) => void, }; type State = { +pendingUploads: { [threadID: string]: { [localUploadID: string]: PendingMultimediaUpload }, }, +drafts: { [threadID: string]: string }, }; class InputStateContainer extends React.PureComponent { state: State = { pendingUploads: {}, drafts: {}, }; replyCallbacks: Array<(message: string) => void> = []; pendingThreadCreations = new Map>(); static reassignToRealizedThreads( state: { +[threadID: string]: T }, props: Props, ): ?{ [threadID: string]: T } { const newState = {}; let updated = false; for (const threadID in state) { const newThreadID = props.pendingRealizedThreadIDs.get(threadID) ?? threadID; if (newThreadID !== threadID) { updated = true; } newState[newThreadID] = state[threadID]; } return updated ? newState : null; } static getDerivedStateFromProps(props: Props, state: State) { const drafts = InputStateContainer.reassignToRealizedThreads( state.drafts, props, ); const pendingUploads = InputStateContainer.reassignToRealizedThreads( state.pendingUploads, props, ); if (!drafts && !pendingUploads) { return null; } const stateUpdate = {}; if (drafts) { stateUpdate.drafts = drafts; } if (pendingUploads) { stateUpdate.pendingUploads = pendingUploads; } return stateUpdate; } static completedMessageIDs(state: State) { const completed = new Map(); for (const threadID in state.pendingUploads) { const pendingUploads = state.pendingUploads[threadID]; for (const localUploadID in pendingUploads) { const upload = pendingUploads[localUploadID]; const { messageID, serverID, failed } = upload; if (!messageID || !messageID.startsWith(localIDPrefix)) { continue; } if (!serverID || failed) { completed.set(messageID, false); continue; } if (completed.get(messageID) === undefined) { completed.set(messageID, true); } } } const messageIDs = new Set(); for (const [messageID, isCompleted] of completed) { if (isCompleted) { messageIDs.add(messageID); } } return messageIDs; } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.viewerID !== prevProps.viewerID) { this.setState({ pendingUploads: {} }); return; } const previouslyAssignedMessageIDs = new Set(); for (const threadID in prevState.pendingUploads) { const pendingUploads = prevState.pendingUploads[threadID]; for (const localUploadID in pendingUploads) { const { messageID } = pendingUploads[localUploadID]; if (messageID) { previouslyAssignedMessageIDs.add(messageID); } } } const newlyAssignedUploads = new Map(); for (const threadID in this.state.pendingUploads) { const pendingUploads = this.state.pendingUploads[threadID]; for (const localUploadID in pendingUploads) { const upload = pendingUploads[localUploadID]; const { messageID } = upload; if ( !messageID || !messageID.startsWith(localIDPrefix) || previouslyAssignedMessageIDs.has(messageID) ) { continue; } let assignedUploads = newlyAssignedUploads.get(messageID); if (!assignedUploads) { assignedUploads = { threadID, uploads: [] }; newlyAssignedUploads.set(messageID, assignedUploads); } assignedUploads.uploads.push(upload); } } const newMessageInfos = new Map(); for (const [messageID, assignedUploads] of newlyAssignedUploads) { const { uploads, threadID } = assignedUploads; const creatorID = this.props.viewerID; invariant(creatorID, 'need viewer ID in order to send a message'); const media = uploads.map( ({ localID, serverID, uri, mediaType, dimensions }) => { // We can get into this state where dimensions are null if the user is // uploading a file type that the browser can't render. In that case // we fake the dimensions here while we wait for the server to tell us // the true dimensions. We actually don't use the dimensions on the // web side currently, but if we ever change that (for instance if we // want to render a properly sized loading overlay like we do on // native), 0,0 is probably a good default. const shimmedDimensions = dimensions ?? { height: 0, width: 0 }; invariant( mediaType === 'photo', "web InputStateContainer can't handle video", ); return { id: serverID ? serverID : localID, uri, type: 'photo', dimensions: shimmedDimensions, }; }, ); const messageInfo = createMediaMessageInfo({ localID: messageID, threadID, creatorID, media, }); newMessageInfos.set(messageID, messageInfo); } const currentlyCompleted = InputStateContainer.completedMessageIDs( this.state, ); const previouslyCompleted = InputStateContainer.completedMessageIDs( prevState, ); for (const messageID of currentlyCompleted) { if (previouslyCompleted.has(messageID)) { continue; } let rawMessageInfo = newMessageInfos.get(messageID); if (rawMessageInfo) { newMessageInfos.delete(messageID); } else { rawMessageInfo = this.getRawMultimediaMessageInfo(messageID); } this.sendMultimediaMessage(rawMessageInfo); } for (const [, messageInfo] of newMessageInfos) { this.props.dispatch({ type: createLocalMessageActionType, payload: messageInfo, }); } } getRawMultimediaMessageInfo( localMessageID: string, ): RawMultimediaMessageInfo { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`); invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); return rawMessageInfo; } async sendMultimediaMessage(messageInfo: RawMultimediaMessageInfo) { if (!threadIsPending(messageInfo.threadID)) { this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(messageInfo), undefined, messageInfo, ); return; } this.props.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: messageInfo, }); let newThreadID = null; try { const threadCreationPromise = this.pendingThreadCreations.get( messageInfo.threadID, ); if (!threadCreationPromise) { // When we create or retry multimedia message, we add a promise to // pendingThreadCreations map. This promise can be removed in // sendMultimediaMessage and sendTextMessage methods. When any of these // method remove the promise, it has to be settled. If the promise was // fulfilled, this method would be called with realized thread, so we // can conclude that the promise was rejected. We don't have enough info // here to retry the thread creation, but we can mark the message as // failed. Then the retry will be possible and promise will be created // again. throw new Error('Thread creation failed'); } newThreadID = await threadCreationPromise; } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; this.props.dispatch({ type: sendMultimediaMessageActionTypes.failed, payload: copy, error: true, }); return; } finally { this.pendingThreadCreations.delete(messageInfo.threadID); } const newMessageInfo = { ...messageInfo, threadID: newThreadID, time: Date.now(), }; this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(newMessageInfo), undefined, newMessageInfo, ); } async sendMultimediaMessageAction( messageInfo: RawMultimediaMessageInfo, ): Promise { const { localID, threadID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const mediaIDs = []; for (const { id } of messageInfo.media) { mediaIDs.push(id); } try { const result = await this.props.sendMultimediaMessage( threadID, localID, mediaIDs, ); this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const prevUploads = prevState.pendingUploads[newThreadID]; const newUploads = {}; for (const localUploadID in prevUploads) { const upload = prevUploads[localUploadID]; if (upload.messageID !== localID) { newUploads[localUploadID] = upload; } else if (!upload.uriIsReal) { newUploads[localUploadID] = { ...upload, messageID: result.id, }; } } return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: newUploads, }, }; }); 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; } } async startThreadCreation(threadInfo: ThreadInfo): Promise { if (!threadIsPending(threadInfo.id)) { return threadInfo.id; } let threadCreationPromise = this.pendingThreadCreations.get(threadInfo.id); if (!threadCreationPromise) { const calendarQuery = this.props.calendarQuery(); threadCreationPromise = createRealThreadFromPendingThread({ threadInfo, dispatchActionPromise: this.props.dispatchActionPromise, createNewThread: this.props.newThread, sourceMessageID: threadInfo.sourceMessageID, viewerID: this.props.viewerID, calendarQuery, }); this.pendingThreadCreations.set(threadInfo.id, threadCreationPromise); } return threadCreationPromise; } inputStateSelector = _memoize((threadID: string) => createSelector( (state: State) => state.pendingUploads[threadID], (state: State) => state.drafts[threadID], ( pendingUploads: ?{ [localUploadID: string]: PendingMultimediaUpload }, draft: ?string, ) => { let threadPendingUploads = []; const assignedUploads = {}; if (pendingUploads) { const [uploadsWithMessageIDs, uploadsWithoutMessageIDs] = _partition( 'messageID', )(pendingUploads); threadPendingUploads = _sortBy('localID')(uploadsWithoutMessageIDs); const threadAssignedUploads = _groupBy('messageID')( uploadsWithMessageIDs, ); for (const messageID in threadAssignedUploads) { // lodash libdefs don't return $ReadOnlyArray assignedUploads[messageID] = [...threadAssignedUploads[messageID]]; } } return { pendingUploads: threadPendingUploads, assignedUploads, draft: draft ? draft : '', appendFiles: (files: $ReadOnlyArray) => this.appendFiles(threadID, files), cancelPendingUpload: (localUploadID: string) => this.cancelPendingUpload(threadID, localUploadID), sendTextMessage: ( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, ) => this.sendTextMessage(messageInfo, threadInfo), createMultimediaMessage: (localID: number, threadInfo: ThreadInfo) => this.createMultimediaMessage(localID, threadInfo), setDraft: (newDraft: string) => this.setDraft(threadID, newDraft), messageHasUploadFailure: (localMessageID: string) => this.messageHasUploadFailure(assignedUploads[localMessageID]), retryMultimediaMessage: ( localMessageID: string, threadInfo: ThreadInfo, ) => this.retryMultimediaMessage( localMessageID, threadInfo, assignedUploads[localMessageID], ), addReply: (message: string) => this.addReply(message), addReplyListener: this.addReplyListener, removeReplyListener: this.removeReplyListener, registerSendCallback: this.props.registerSendCallback, unregisterSendCallback: this.props.unregisterSendCallback, }; }, ), ); getRealizedOrPendingThreadID(threadID: string): string { return this.props.pendingRealizedThreadIDs.get(threadID) ?? threadID; } async appendFiles( threadID: string, files: $ReadOnlyArray, ): Promise { const selectionTime = Date.now(); const { pushModal } = this.props; const appendResults = await Promise.all( files.map(file => this.appendFile(file, selectionTime)), ); if (appendResults.some(({ result }) => !result.success)) { pushModal(); const time = Date.now() - selectionTime; const reports = []; for (const appendResult of appendResults) { const { steps } = appendResult; let { result } = appendResult; let uploadLocalID; if (result.success) { uploadLocalID = result.pendingUpload.localID; result = { success: false, reason: 'web_sibling_validation_failed' }; } const mediaMission = { steps, result, userTime: time, totalTime: time }; reports.push({ mediaMission, uploadLocalID }); } this.queueMediaMissionReports(reports); return false; } const newUploads = appendResults.map(({ result }) => { invariant(result.success, 'any failed validation should be caught above'); return result.pendingUpload; }); const newUploadsObject = _keyBy('localID')(newUploads); this.setState( prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const prevUploads = prevState.pendingUploads[newThreadID]; const mergedUploads = prevUploads ? { ...prevUploads, ...newUploadsObject } : newUploadsObject; return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: mergedUploads, }, }; }, () => this.uploadFiles(threadID, newUploads), ); return true; } async appendFile( file: File, selectTime: number, ): Promise<{ steps: $ReadOnlyArray, result: | MediaMissionFailure | { success: true, pendingUpload: PendingMultimediaUpload }, }> { const steps = [ { step: 'web_selection', filename: file.name, size: file.size, mime: file.type, selectTime, }, ]; let response; const validationStart = Date.now(); try { response = await validateFile(file, this.props.exifRotate); } catch (e) { return { steps, result: { success: false, reason: 'processing_exception', time: Date.now() - validationStart, exceptionMessage: getMessageForException(e), }, }; } const { steps: validationSteps, result } = response; steps.push(...validationSteps); if (!result.success) { return { steps, result }; } const { uri, file: fixedFile, mediaType, dimensions } = result; return { steps, result: { success: true, pendingUpload: { localID: getNextLocalUploadID(), serverID: null, messageID: null, failed: false, file: fixedFile, mediaType, dimensions, uri, loop: false, uriIsReal: false, progressPercent: 0, abort: null, steps, selectTime, }, }, }; } uploadFiles( threadID: string, uploads: $ReadOnlyArray, ) { return Promise.all( uploads.map(upload => this.uploadFile(threadID, upload)), ); } async uploadFile(threadID: string, upload: PendingMultimediaUpload) { const { selectTime, localID } = upload; const steps = [...upload.steps]; let userTime; const sendReport = (missionResult: MediaMissionResult) => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const latestUpload = this.state.pendingUploads[newThreadID][localID]; invariant( latestUpload, `pendingUpload ${localID} for ${newThreadID} missing in sendReport`, ); const { serverID, messageID } = latestUpload; const totalTime = Date.now() - selectTime; userTime = userTime ? userTime : totalTime; const mission = { steps, result: missionResult, totalTime, userTime }; this.queueMediaMissionReports([ { mediaMission: mission, uploadLocalID: localID, uploadServerID: serverID, messageLocalID: messageID, }, ]); }; let uploadResult, uploadExceptionMessage; const uploadStart = Date.now(); try { uploadResult = await this.props.uploadMultimedia( upload.file, { ...upload.dimensions, loop: false }, { onProgress: (percent: number) => this.setProgress(threadID, localID, percent), abortHandler: (abort: () => void) => this.handleAbortCallback(threadID, localID, abort), }, ); } catch (e) { uploadExceptionMessage = getMessageForException(e); this.handleUploadFailure(threadID, localID); } userTime = Date.now() - selectTime; steps.push({ step: 'upload', success: !!uploadResult, exceptionMessage: uploadExceptionMessage, time: Date.now() - uploadStart, inputFilename: upload.file.name, outputMediaType: uploadResult && uploadResult.mediaType, outputURI: uploadResult && uploadResult.uri, outputDimensions: uploadResult && uploadResult.dimensions, outputLoop: uploadResult && uploadResult.loop, }); if (!uploadResult) { sendReport({ success: false, reason: 'http_upload_failed', exceptionMessage: uploadExceptionMessage, }); return; } const result = uploadResult; const successThreadID = this.getRealizedOrPendingThreadID(threadID); const uploadAfterSuccess = this.state.pendingUploads[successThreadID][ localID ]; invariant( uploadAfterSuccess, `pendingUpload ${localID}/${result.id} for ${successThreadID} missing ` + `after upload`, ); if (uploadAfterSuccess.messageID) { this.props.dispatch({ type: updateMultimediaMessageMediaActionType, payload: { messageID: uploadAfterSuccess.messageID, currentMediaID: localID, mediaUpdate: { id: result.id, }, }, }); } this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const uploads = prevState.pendingUploads[newThreadID]; const currentUpload = uploads[localID]; invariant( currentUpload, `pendingUpload ${localID}/${result.id} for ${newThreadID} ` + `missing while assigning serverID`, ); return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: { ...uploads, [localID]: { ...currentUpload, serverID: result.id, abort: null, }, }, }, }; }); const { steps: preloadSteps } = await preloadImage(result.uri); steps.push(...preloadSteps); sendReport({ success: true }); const preloadThreadID = this.getRealizedOrPendingThreadID(threadID); const uploadAfterPreload = this.state.pendingUploads[preloadThreadID][ localID ]; invariant( uploadAfterPreload, `pendingUpload ${localID}/${result.id} for ${preloadThreadID} missing ` + `after preload`, ); if (uploadAfterPreload.messageID) { const { mediaType, uri, dimensions, loop } = result; this.props.dispatch({ type: updateMultimediaMessageMediaActionType, payload: { messageID: uploadAfterPreload.messageID, currentMediaID: uploadAfterPreload.serverID ? uploadAfterPreload.serverID : uploadAfterPreload.localID, mediaUpdate: { type: mediaType, uri, dimensions, loop }, }, }); } this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const uploads = prevState.pendingUploads[newThreadID]; const currentUpload = uploads[localID]; invariant( currentUpload, `pendingUpload ${localID}/${result.id} for ${newThreadID} ` + `missing while assigning URI`, ); const { messageID } = currentUpload; if (messageID && !messageID.startsWith(localIDPrefix)) { const newPendingUploads = _omit([localID])(uploads); return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: newPendingUploads, }, }; } return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: { ...uploads, [localID]: { ...currentUpload, uri: result.uri, mediaType: result.mediaType, dimensions: result.dimensions, uriIsReal: true, loop: result.loop, }, }, }, }; }); } handleAbortCallback( threadID: string, localUploadID: string, abort: () => void, ) { this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const uploads = prevState.pendingUploads[newThreadID]; const upload = uploads[localUploadID]; if (!upload) { // The upload has been cancelled before we were even handed the // abort function. We should immediately abort. abort(); } return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: { ...uploads, [localUploadID]: { ...upload, abort, }, }, }, }; }); } handleUploadFailure(threadID: string, localUploadID: string) { this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const uploads = prevState.pendingUploads[newThreadID]; const upload = uploads[localUploadID]; if (!upload || !upload.abort || upload.serverID) { // The upload has been cancelled or completed before it failed return {}; } return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: { ...uploads, [localUploadID]: { ...upload, failed: true, progressPercent: 0, abort: null, }, }, }, }; }); } queueMediaMissionReports( partials: $ReadOnlyArray<{ mediaMission: MediaMission, uploadLocalID?: ?string, uploadServerID?: ?string, messageLocalID?: ?string, }>, ) { const reports = partials.map( ({ mediaMission, uploadLocalID, uploadServerID, messageLocalID }) => ({ type: reportTypes.MEDIA_MISSION, time: Date.now(), platformDetails: getConfig().platformDetails, mediaMission, uploadServerID, uploadLocalID, messageLocalID, }), ); this.props.dispatch({ type: queueReportsActionType, payload: { reports } }); } cancelPendingUpload(threadID: string, localUploadID: string) { let revokeURL, abortRequest; this.setState( prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const currentPendingUploads = prevState.pendingUploads[newThreadID]; if (!currentPendingUploads) { return {}; } const pendingUpload = currentPendingUploads[localUploadID]; if (!pendingUpload) { return {}; } if (!pendingUpload.uriIsReal) { revokeURL = pendingUpload.uri; } if (pendingUpload.abort) { abortRequest = pendingUpload.abort; } if (pendingUpload.serverID) { this.props.deleteUpload(pendingUpload.serverID); } const newPendingUploads = _omit([localUploadID])(currentPendingUploads); return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: newPendingUploads, }, }; }, () => { if (revokeURL) { URL.revokeObjectURL(revokeURL); } if (abortRequest) { abortRequest(); } }, ); } async sendTextMessage( messageInfo: RawTextMessageInfo, threadInfo: ThreadInfo, ) { this.props.sendCallbacks.forEach(callback => callback()); if (!threadIsPending(threadInfo.id)) { this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction(messageInfo), undefined, messageInfo, ); return; } this.props.dispatch({ type: sendTextMessageActionTypes.started, payload: messageInfo, }); let newThreadID = null; try { newThreadID = await this.startThreadCreation(threadInfo); } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; this.props.dispatch({ type: sendTextMessageActionTypes.failed, payload: copy, error: true, }); return; } finally { this.pendingThreadCreations.delete(threadInfo.id); } const newMessageInfo = { ...messageInfo, threadID: newThreadID, time: Date.now(), }; this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction(newMessageInfo), undefined, newMessageInfo, ); } 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; } } // Creates a MultimediaMessage from the unassigned pending uploads, // if there are any createMultimediaMessage(localID: number, threadInfo: ThreadInfo) { const localMessageID = `${localIDPrefix}${localID}`; this.startThreadCreation(threadInfo); this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadInfo.id); const currentPendingUploads = prevState.pendingUploads[newThreadID]; if (!currentPendingUploads) { return {}; } const newPendingUploads = {}; let uploadAssigned = false; for (const localUploadID in currentPendingUploads) { const upload = currentPendingUploads[localUploadID]; if (upload.messageID) { newPendingUploads[localUploadID] = upload; } else { const newUpload = { ...upload, messageID: localMessageID, }; uploadAssigned = true; newPendingUploads[localUploadID] = newUpload; } } if (!uploadAssigned) { return {}; } return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: newPendingUploads, }, }; }); } setDraft(threadID: string, draft: string) { this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); return { drafts: { ...prevState.drafts, [newThreadID]: draft, }, }; }); } setProgress( threadID: string, localUploadID: string, progressPercent: number, ) { this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadID); const pendingUploads = prevState.pendingUploads[newThreadID]; if (!pendingUploads) { return {}; } const pendingUpload = pendingUploads[localUploadID]; if (!pendingUpload) { return {}; } const newPendingUploads = { ...pendingUploads, [localUploadID]: { ...pendingUpload, progressPercent, }, }; return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: newPendingUploads, }, }; }); } messageHasUploadFailure( pendingUploads: ?$ReadOnlyArray, ) { if (!pendingUploads) { return false; } return pendingUploads.some(upload => upload.failed); } retryMultimediaMessage( localMessageID: string, threadInfo: ThreadInfo, pendingUploads: ?$ReadOnlyArray, ) { const rawMessageInfo = this.getRawMultimediaMessageInfo(localMessageID); let newRawMessageInfo; // This conditional is for Flow if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { newRawMessageInfo = ({ ...rawMessageInfo, time: Date.now(), }: RawMediaMessageInfo); } else { newRawMessageInfo = ({ ...rawMessageInfo, time: Date.now(), }: RawImagesMessageInfo); } this.startThreadCreation(threadInfo); const completed = InputStateContainer.completedMessageIDs(this.state); if (completed.has(localMessageID)) { this.sendMultimediaMessage(newRawMessageInfo); return; } if (!pendingUploads) { return; } // We're not actually starting the send here, // we just use this action to update the message's timestamp in Redux this.props.dispatch({ type: sendMultimediaMessageActionTypes.started, payload: newRawMessageInfo, }); const uploadIDsToRetry = new Set(); const uploadsToRetry = []; for (const pendingUpload of pendingUploads) { const { serverID, messageID, localID, abort } = pendingUpload; if (serverID || messageID !== localMessageID) { continue; } if (abort) { abort(); } uploadIDsToRetry.add(localID); uploadsToRetry.push(pendingUpload); } this.setState(prevState => { const newThreadID = this.getRealizedOrPendingThreadID(threadInfo.id); const prevPendingUploads = prevState.pendingUploads[newThreadID]; if (!prevPendingUploads) { return {}; } const newPendingUploads = {}; let pendingUploadChanged = false; for (const localID in prevPendingUploads) { const pendingUpload = prevPendingUploads[localID]; if (uploadIDsToRetry.has(localID) && !pendingUpload.serverID) { newPendingUploads[localID] = { ...pendingUpload, failed: false, progressPercent: 0, abort: null, }; pendingUploadChanged = true; } else { newPendingUploads[localID] = pendingUpload; } } if (!pendingUploadChanged) { return {}; } return { pendingUploads: { ...prevState.pendingUploads, [newThreadID]: newPendingUploads, }, }; }); this.uploadFiles(threadInfo.id, uploadsToRetry); } 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, ); }; render() { const { activeChatThreadID } = this.props; const inputState = activeChatThreadID ? this.inputStateSelector(activeChatThreadID)(this.state) : null; return ( {this.props.children} ); } } const ConnectedInputStateContainer: React.ComponentType = React.memo( function ConnectedInputStateContainer(props) { const exifRotate = useSelector(state => { const browser = detectBrowser(state.userAgent); return ( !browser || (browser.name !== 'safari' && browser.name !== 'chrome') ); }); const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const messageStoreMessages = useSelector( state => state.messageStore.messages, ); const pendingToRealizedThreadIDs = useSelector(state => pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos), ); const calendarQuery = useSelector(nonThreadCalendarQuery); const callUploadMultimedia = useServerCall(uploadMultimedia); const callDeleteUpload = useServerCall(deleteUpload); - const callSendMultimediaMessage = useServerCall(sendMultimediaMessage); + const callSendMultimediaMessage = useServerCall( + legacySendMultimediaMessage, + ); const callSendTextMessage = useServerCall(sendTextMessage); const callNewThread = useServerCall(newThread); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const modalContext = useModalContext(); const [sendCallbacks, setSendCallbacks] = React.useState< $ReadOnlyArray<() => mixed>, >([]); const registerSendCallback = React.useCallback((callback: () => mixed) => { setSendCallbacks(prevCallbacks => [...prevCallbacks, callback]); }, []); const unregisterSendCallback = React.useCallback( (callback: () => mixed) => { setSendCallbacks(prevCallbacks => prevCallbacks.filter(candidate => candidate !== callback), ); }, [], ); return ( ); }, ); export default ConnectedInputStateContainer;