diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js
index ea191dced..6e288d109 100644
--- a/native/input/input-state-container.react.js
+++ b/native/input/input-state-container.react.js
@@ -1,1746 +1,1752 @@
 // @flow
 
 import * as FileSystem from 'expo-file-system';
 import invariant from 'invariant';
 import * as React from 'react';
 import { Platform } from 'react-native';
 import { createSelector } from 'reselect';
 
 import {
   createLocalMessageActionType,
   sendMultimediaMessageActionTypes,
   sendTextMessageActionTypes,
 } from 'lib/actions/message-actions.js';
 import { queueReportsActionType } from 'lib/actions/report-actions.js';
 import { useNewThinThread } from 'lib/actions/thread-actions.js';
 import {
   type BlobServiceUploadAction,
   type BlobServiceUploadResult,
   updateMultimediaMessageMediaActionType,
   useBlobServiceUpload,
 } from 'lib/actions/upload-actions.js';
 import { useInvalidCSATLogOut } from 'lib/actions/user-actions.js';
 import {
   type SendMultimediaMessagePayload,
   useInputStateContainerSendMultimediaMessage,
   useInputStateContainerSendTextMessage,
 } from 'lib/hooks/input-state-container-hooks.js';
 import { useNewThickThread } from 'lib/hooks/thread-hooks.js';
 import type {
   CallSingleKeyserverEndpointOptions,
   CallSingleKeyserverEndpointResponse,
 } from 'lib/keyserver-conn/call-single-keyserver-endpoint.js';
 import { pathFromURI, replaceExtension } from 'lib/media/file-utils.js';
 import {
   getNextLocalUploadID,
   isLocalUploadID,
 } from 'lib/media/media-utils.js';
 import { videoDurationLimit } from 'lib/media/video-utils.js';
 import {
   combineLoadingStatuses,
   createLoadingStatusSelector,
 } from 'lib/selectors/loading-selectors.js';
 import {
   createMediaMessageInfo,
   useMessageCreationSideEffectsFunc,
   getNextLocalID,
 } from 'lib/shared/message-utils.js';
 import type { CreationSideEffectsFunc } from 'lib/shared/messages/message-spec.js';
 import { createRealThreadFromPendingThread } from 'lib/shared/thread-actions-utils.js';
 import {
   patchThreadInfoToIncludeMentionedMembersOfParent,
   threadIsPending,
   threadIsPendingSidebar,
 } from 'lib/shared/thread-utils.js';
 import type { CalendarQuery } from 'lib/types/entry-types.js';
 import type {
   Media,
   MediaMission,
   MediaMissionResult,
   MediaMissionStep,
   NativeMediaSelection,
 } from 'lib/types/media-types.js';
 import { messageTypes } from 'lib/types/message-types-enum.js';
 import {
   type RawMessageInfo,
   type RawMultimediaMessageInfo,
   type SendMessagePayload,
 } from 'lib/types/message-types.js';
 import type { RawImagesMessageInfo } from 'lib/types/messages/images.js';
 import type { RawMediaMessageInfo } from 'lib/types/messages/media.js';
 import type { RawTextMessageInfo } from 'lib/types/messages/text.js';
 import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
 import type { Dispatch } from 'lib/types/redux-types.js';
 import {
   type ClientMediaMissionReportCreationRequest,
   reportTypes,
 } from 'lib/types/report-types.js';
 import {
   threadTypeIsThick,
   threadTypeIsSidebar,
 } from 'lib/types/thread-types-enum.js';
 import type { ThreadType } from 'lib/types/thread-types-enum.js';
 import {
   type ClientNewThinThreadRequest,
   type NewThreadResult,
   type NewThickThreadRequest,
 } from 'lib/types/thread-types.js';
 import { getConfig } from 'lib/utils/config.js';
 import { getMessageForException, SendMessageError } from 'lib/utils/errors.js';
 import { values } from 'lib/utils/objects.js';
 import {
   type DispatchActionPromise,
   useDispatchActionPromise,
 } from 'lib/utils/redux-promise-utils.js';
 import { useDispatch } from 'lib/utils/redux-utils.js';
 import {
   generateReportID,
   useIsReportEnabled,
 } from 'lib/utils/report-utils.js';
 
 import {
   type EditInputBarMessageParameters,
   type InputState,
   InputStateContext,
   type MessagePendingUploads,
   type MultimediaProcessingStep,
   type PendingMultimediaUploads,
 } from './input-state.js';
 import { encryptMedia } from '../media/encryption-utils.js';
 import { disposeTempFile } from '../media/file-utils.js';
 import { processMedia } from '../media/media-utils.js';
 import { displayActionResultModal } from '../navigation/action-result-modal.js';
 import { useCalendarQuery } from '../navigation/nav-selectors.js';
 import { useSelector } from '../redux/redux-utils.js';
 import blobServiceUploadHandler from '../utils/blob-service-upload.js';
 import { useStaffCanSee } from '../utils/staff-utils.js';
 
 type MediaIDs =
   | { +type: 'photo', +localMediaID: string }
   | { +type: 'video', +localMediaID: string, +localThumbnailID: string };
 type UploadFileInput = {
   +selection: NativeMediaSelection,
   +ids: MediaIDs,
 };
 type WritableCompletedUploads = {
   [localMessageID: string]: ?$ReadOnlySet<string>,
 };
 type CompletedUploads = $ReadOnly<WritableCompletedUploads>;
 type ActiveURI = { +count: number, +onClear: $ReadOnlyArray<() => mixed> };
 
 type BaseProps = {
   +children: React.Node,
 };
 type Props = {
   ...BaseProps,
   +viewerID: ?string,
   +messageStoreMessages: { +[id: string]: RawMessageInfo },
   +ongoingMessageCreation: boolean,
   +hasWiFi: boolean,
   +mediaReportsEnabled: boolean,
   +calendarQuery: () => CalendarQuery,
   +dispatch: Dispatch,
   +staffCanSee: boolean,
   +dispatchActionPromise: DispatchActionPromise,
   +blobServiceUpload: BlobServiceUploadAction,
   +sendMultimediaMessage: (
     messageInfo: RawMultimediaMessageInfo,
     sidebarCreation: boolean,
     isLegacy: boolean,
   ) => Promise<SendMultimediaMessagePayload>,
   +sendTextMessage: (
     messageInfo: RawTextMessageInfo,
     threadInfo: ThreadInfo,
     parentThreadInfo: ?ThreadInfo,
     sidebarCreation: boolean,
   ) => Promise<SendMessagePayload>,
   +newThinThread: (
     request: ClientNewThinThreadRequest,
   ) => Promise<NewThreadResult>,
   +newThickThread: (request: NewThickThreadRequest) => Promise<string>,
   +textMessageCreationSideEffectsFunc: CreationSideEffectsFunc<RawTextMessageInfo>,
   +invalidTokenLogOut: () => Promise<void>,
 };
 type State = {
   +pendingUploads: PendingMultimediaUploads,
 };
 
 class InputStateContainer extends React.PureComponent<Props, State> {
   state: State = {
     pendingUploads: {},
   };
   sendCallbacks: Array<() => void> = [];
   activeURIs: Map<string, ActiveURI> = new Map();
   editInputBarCallbacks: Array<
     (params: EditInputBarMessageParameters) => void,
   > = [];
   scrollToMessageCallbacks: Array<(messageID: string) => void> = [];
   pendingThreadCreations: Map<
     string,
     Promise<{
       +threadID: string,
       +threadType: ThreadType,
     }>,
   > = new Map();
   pendingThreadUpdateHandlers: Map<string, (ThreadInfo) => mixed> = new Map();
 
   // When the user sends a multimedia message that triggers the creation of a
   // sidebar, the sidebar gets created right away, but the message needs to wait
   // for the uploads to complete before sending. We use this Set to track the
   // message localIDs that need sidebarCreation: true.
   pendingSidebarCreationMessageLocalIDs: Set<string> = new Set();
 
   static getCompletedUploads(props: Props, state: State): CompletedUploads {
     const completedUploads: WritableCompletedUploads = {};
     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);
         }
         const { thumbnailID } = singleMedia;
         if (thumbnailID && isLocalUploadID(thumbnailID)) {
           allUploadsComplete = false;
           completedUploadIDs.delete(thumbnailID);
         }
       }
 
       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: PendingMultimediaUploads = {};
     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: MessagePendingUploads = {};
       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`,
       );
       void this.dispatchMultimediaMessageAction(rawMessageInfo);
     }
   }
 
   async dispatchMultimediaMessageAction(
     messageInfo: RawMultimediaMessageInfo,
   ): Promise<void> {
     if (!threadIsPending(messageInfo.threadID)) {
       void 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');
       }
       const result = await threadCreationPromise;
       newThreadID = result.threadID;
     } catch (e) {
       const exceptionMessage = getMessageForException(e) ?? '';
       const payload = new SendMessageError(
         `Exception while creating thread: ${exceptionMessage}`,
         messageInfo.localID ?? '',
         messageInfo.threadID,
       );
       this.props.dispatch({
         type: sendMultimediaMessageActionTypes.failed,
         payload,
         error: true,
       });
       return;
     } finally {
       this.pendingThreadCreations.delete(messageInfo.threadID);
     }
 
     const newMessageInfo = {
       ...messageInfo,
       threadID: newThreadID,
       time: Date.now(),
     };
     void this.props.dispatchActionPromise(
       sendMultimediaMessageActionTypes,
       this.sendMultimediaMessageAction(newMessageInfo),
       undefined,
       newMessageInfo,
     );
   }
 
   async sendMultimediaMessageAction(
     messageInfo: RawMultimediaMessageInfo,
   ): Promise<SendMessagePayload> {
     const { localID, threadID } = messageInfo;
     invariant(
       localID !== null && localID !== undefined,
       'localID should be set',
     );
     const sidebarCreation =
       this.pendingSidebarCreationMessageLocalIDs.has(localID);
     try {
       const { result } = await this.props.sendMultimediaMessage(
         messageInfo,
         sidebarCreation,
         false,
       );
       this.pendingSidebarCreationMessageLocalIDs.delete(localID);
       return result;
     } catch (e) {
+      if (e instanceof SendMessageError) {
+        throw e;
+      }
       const exceptionMessage = getMessageForException(e) ?? '';
       if (exceptionMessage === 'invalid_csat') {
         void this.props.invalidTokenLogOut();
       }
       throw new SendMessageError(
         `Exception when sending multimedia message: ${exceptionMessage}`,
         localID,
         threadID,
       );
     }
   }
 
   inputStateSelector: State => InputState = createSelector(
     (state: State) => state.pendingUploads,
     (pendingUploads: PendingMultimediaUploads) =>
       ({
         pendingUploads,
         sendTextMessage: this.sendTextMessage,
         sendMultimediaMessage: this.sendMultimediaMessage,
         editInputMessage: this.editInputMessage,
         addEditInputMessageListener: this.addEditInputMessageListener,
         removeEditInputMessageListener: this.removeEditInputMessageListener,
         messageHasUploadFailure: this.messageHasUploadFailure,
         retryMessage: this.retryMessage,
         registerSendCallback: this.registerSendCallback,
         unregisterSendCallback: this.unregisterSendCallback,
         uploadInProgress: this.uploadInProgress,
         reportURIDisplayed: this.reportURIDisplayed,
         setPendingThreadUpdateHandler: this.setPendingThreadUpdateHandler,
         scrollToMessage: this.scrollToMessage,
         addScrollToMessageListener: this.addScrollToMessageListener,
         removeScrollToMessageListener: this.removeScrollToMessageListener,
       }: InputState),
   );
 
   scrollToMessage = (messageID: string) => {
     this.scrollToMessageCallbacks.forEach(callback => callback(messageID));
   };
 
   addScrollToMessageListener = (callback: (messageID: string) => void) => {
     this.scrollToMessageCallbacks.push(callback);
   };
 
   removeScrollToMessageListener = (
     callbackScrollToMessage: (messageID: string) => void,
   ) => {
     this.scrollToMessageCallbacks = this.scrollToMessageCallbacks.filter(
       candidate => candidate !== callbackScrollToMessage,
     );
   };
 
   uploadInProgress = (): boolean => {
     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,
     inputThreadInfo: ThreadInfo,
     parentThreadInfo: ?ThreadInfo,
   ) => {
     this.sendCallbacks.forEach(callback => callback());
 
     const { localID } = messageInfo;
     invariant(
       localID !== null && localID !== undefined,
       'localID should be set',
     );
     if (threadIsPendingSidebar(inputThreadInfo.id)) {
       this.pendingSidebarCreationMessageLocalIDs.add(localID);
     }
 
     if (!threadIsPending(inputThreadInfo.id)) {
       void this.props.dispatchActionPromise(
         sendTextMessageActionTypes,
         this.sendTextMessageAction(
           messageInfo,
           inputThreadInfo,
           parentThreadInfo,
         ),
         undefined,
         messageInfo,
       );
       return;
     }
 
     this.props.dispatch({
       type: sendTextMessageActionTypes.started,
       payload: messageInfo,
     });
 
     let threadInfo = inputThreadInfo;
     const { viewerID } = this.props;
     if (viewerID && threadTypeIsSidebar(inputThreadInfo.type)) {
       invariant(parentThreadInfo, 'sidebar should have parent');
       threadInfo = patchThreadInfoToIncludeMentionedMembersOfParent(
         inputThreadInfo,
         parentThreadInfo,
         messageInfo.text,
         viewerID,
       );
       if (threadInfo !== inputThreadInfo) {
         const pendingThreadUpdateHandler = this.pendingThreadUpdateHandlers.get(
           threadInfo.id,
         );
         pendingThreadUpdateHandler?.(threadInfo);
       }
     }
 
     let threadCreationResult = null;
     try {
       threadCreationResult = await this.startThreadCreation(threadInfo);
     } catch (e) {
       const exceptionMessage = getMessageForException(e) ?? '';
       const payload = new SendMessageError(
         `Exception while creating thread: ${exceptionMessage}`,
         messageInfo.localID ?? '',
         messageInfo.threadID,
       );
       this.props.dispatch({
         type: sendTextMessageActionTypes.failed,
         payload,
         error: true,
       });
       return;
     } finally {
       this.pendingThreadCreations.delete(threadInfo.id);
     }
 
     const newMessageInfo = {
       ...messageInfo,
       threadID: threadCreationResult?.threadID,
       time: Date.now(),
     };
 
     const newThreadInfo = {
       ...threadInfo,
       id: threadCreationResult?.threadID,
       type: threadCreationResult?.threadType ?? threadInfo.type,
     };
 
     void this.props.dispatchActionPromise(
       sendTextMessageActionTypes,
       this.sendTextMessageAction(
         newMessageInfo,
         newThreadInfo,
         parentThreadInfo,
       ),
       undefined,
       newMessageInfo,
     );
   };
 
   startThreadCreation(
     threadInfo: ThreadInfo,
   ): Promise<{ +threadID: string, +threadType: ThreadType }> {
     if (!threadIsPending(threadInfo.id)) {
       return Promise.resolve({
         threadID: threadInfo.id,
         threadType: threadInfo.type,
       });
     }
     let threadCreationPromise = this.pendingThreadCreations.get(threadInfo.id);
     if (!threadCreationPromise) {
       const calendarQuery = this.props.calendarQuery();
       threadCreationPromise = createRealThreadFromPendingThread({
         threadInfo,
         dispatchActionPromise: this.props.dispatchActionPromise,
         createNewThinThread: this.props.newThinThread,
         createNewThickThread: this.props.newThickThread,
         sourceMessageID: threadInfo.sourceMessageID,
         viewerID: this.props.viewerID,
         calendarQuery,
       });
       this.pendingThreadCreations.set(threadInfo.id, threadCreationPromise);
     }
     return threadCreationPromise;
   }
 
   async sendTextMessageAction(
     messageInfo: RawTextMessageInfo,
     threadInfo: ThreadInfo,
     parentThreadInfo: ?ThreadInfo,
   ): Promise<SendMessagePayload> {
     try {
       if (!threadTypeIsThick(threadInfo.type)) {
         await this.props.textMessageCreationSideEffectsFunc(
           messageInfo,
           threadInfo,
           parentThreadInfo,
         );
       }
       const { localID } = messageInfo;
       invariant(
         localID !== null && localID !== undefined,
         'localID should be set',
       );
       const sidebarCreation =
         this.pendingSidebarCreationMessageLocalIDs.has(localID);
       const result = await this.props.sendTextMessage(
         messageInfo,
         threadInfo,
         parentThreadInfo,
         sidebarCreation,
       );
       if (threadTypeIsThick(threadInfo.type)) {
         await this.props.textMessageCreationSideEffectsFunc(
           messageInfo,
           threadInfo,
           parentThreadInfo,
         );
       }
       this.pendingSidebarCreationMessageLocalIDs.delete(localID);
       return result;
     } catch (e) {
+      if (e instanceof SendMessageError) {
+        throw e;
+      }
       const exceptionMessage = getMessageForException(e) ?? '';
       throw new SendMessageError(
         `Exception when sending text message: ${exceptionMessage}`,
         messageInfo.localID ?? '',
         messageInfo.threadID,
       );
     }
   }
 
   sendMultimediaMessage = async (
     selections: $ReadOnlyArray<NativeMediaSelection>,
     threadInfo: ThreadInfo,
   ) => {
     this.sendCallbacks.forEach(callback => callback());
     const localMessageID = getNextLocalID();
     void this.startThreadCreation(threadInfo);
 
     if (threadIsPendingSidebar(threadInfo.id)) {
       this.pendingSidebarCreationMessageLocalIDs.add(localMessageID);
     }
 
     const uploadFileInputs = [],
       media: Array<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,
           thumbHash: null,
         });
         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,
           thumbnailThumbHash: null,
         });
         ids = { type: 'video', localMediaID, localThumbnailID };
       }
       invariant(ids, `unexpected MediaSelection ${selection.step}`);
       uploadFileInputs.push({ selection, ids });
     }
 
     const pendingUploads: MessagePendingUploads = {};
     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,
           },
           { forceMultimediaMessageType: true },
         );
         this.props.dispatch({
           type: createLocalMessageActionType,
           payload: messageInfo,
         });
       },
     );
 
     await this.uploadFiles(localMessageID, uploadFileInputs, threadInfo);
   };
 
   async uploadFiles(
     localMessageID: string,
     uploadFileInputs: $ReadOnlyArray<UploadFileInput>,
     threadInfo: ThreadInfo,
   ) {
     const results = await Promise.all(
       uploadFileInputs.map(uploadFileInput =>
         this.uploadFile(localMessageID, uploadFileInput, threadInfo),
       ),
     );
     const errors = [...new Set(results.filter(Boolean))];
     if (errors.length > 0) {
       displayActionResultModal(errors.join(', ') + ' :(');
     }
   }
 
   async uploadFile(
     localMessageID: string,
     uploadFileInput: UploadFileInput,
     threadInfo: ThreadInfo,
   ): Promise<?string> {
     const { ids, selection } = uploadFileInput;
     const { localMediaID, localThumbnailID } = ids;
     const start = selection.sendTime;
     const steps: Array<MediaMissionStep> = [selection];
     let encryptionSteps: $ReadOnlyArray<MediaMissionStep> = [];
     let serverID;
     let userTime;
     let errorMessage;
     let reportPromise: ?Promise<$ReadOnlyArray<MediaMissionStep>>;
     const filesToDispose = [];
 
     const onUploadFinished = async (result: MediaMissionResult) => {
       if (!this.props.mediaReportsEnabled) {
         return errorMessage;
       }
       if (reportPromise) {
         const finalSteps = await reportPromise;
         steps.push(...finalSteps);
         steps.push(...encryptionSteps);
       }
       const totalTime = Date.now() - start;
       userTime = userTime ? userTime : totalTime;
       this.queueMediaMissionReport(
         { localID: localMediaID, localMessageID, serverID },
         { steps, result, totalTime, userTime },
       );
       return errorMessage;
     };
     const onUploadFailed = (message: string) => {
       errorMessage = message;
       this.handleUploadFailure(localMessageID, localMediaID, localThumbnailID);
       userTime = Date.now() - start;
     };
 
     const onTranscodingProgress = (percent: number) => {
       this.setProgress(localMessageID, localMediaID, 'transcoding', percent);
     };
 
     let processedMedia;
     const processingStart = Date.now();
     try {
       const processMediaReturn = processMedia(selection, {
         hasWiFi: this.props.hasWiFi,
         finalFileHeaderCheck: this.props.staffCanSee,
         onTranscodingProgress,
       });
       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(message);
         return await onUploadFinished(processResult);
       }
       if (processResult.shouldDisposePath) {
         filesToDispose.push(processResult.shouldDisposePath);
       }
       processedMedia = processResult;
     } catch (e) {
       onUploadFailed('processing failed');
       return await onUploadFinished({
         success: false,
         reason: 'processing_exception',
         time: Date.now() - processingStart,
         exceptionMessage: getMessageForException(e),
       });
     }
 
     const encryptionStart = Date.now();
     try {
       const { result: encryptionResult, ...encryptionReturn } =
         await encryptMedia(processedMedia);
       encryptionSteps = encryptionReturn.steps;
       if (!encryptionResult.success) {
         onUploadFailed(encryptionResult.reason);
         return await onUploadFinished(encryptionResult);
       }
       if (encryptionResult.shouldDisposePath) {
         filesToDispose.push(encryptionResult.shouldDisposePath);
       }
       processedMedia = encryptionResult;
     } catch (e) {
       onUploadFailed('encryption failed');
       return await onUploadFinished({
         success: false,
         reason: 'encryption_exception',
         time: Date.now() - encryptionStart,
         exceptionMessage: getMessageForException(e),
       });
     }
 
     const { uploadURI, filename, mime } = processedMedia;
 
     const { hasWiFi } = this.props;
 
     const uploadStart = Date.now();
     let uploadExceptionMessage,
       uploadResult,
       uploadThumbnailResult,
       mediaMissionResult;
 
     const isThickThread = threadTypeIsThick(threadInfo.type);
     try {
       invariant(
         processedMedia.mediaType === 'encrypted_photo' ||
           processedMedia.mediaType === 'encrypted_video',
         'uploaded media should be encrypted',
       );
       const uploadMetadataToKeyserver = !isThickThread;
       const uploadPromise = this.props.blobServiceUpload({
         uploadInput: {
           blobInput: {
             type: 'uri',
             uri: uploadURI,
             filename: filename,
             mimeType: mime,
           },
           blobHash: processedMedia.blobHash,
           encryptionKey: processedMedia.encryptionKey,
           dimensions: processedMedia.dimensions,
           thumbHash:
             processedMedia.mediaType === 'encrypted_photo'
               ? processedMedia.thumbHash
               : null,
         },
         keyserverOrThreadID: uploadMetadataToKeyserver ? threadInfo.id : null,
         callbacks: {
           blobServiceUploadHandler,
           onProgress: (percent: number) => {
             this.setProgress(
               localMessageID,
               localMediaID,
               'uploading',
               percent,
             );
           },
         },
       });
 
       const uploadThumbnailPromise: Promise<?BlobServiceUploadResult> =
         (async () => {
           if (processedMedia.mediaType !== 'encrypted_video') {
             return undefined;
           }
           return await this.props.blobServiceUpload({
             uploadInput: {
               blobInput: {
                 type: 'uri',
                 uri: processedMedia.uploadThumbnailURI,
                 filename: replaceExtension(`thumb${filename}`, 'jpg'),
                 mimeType: 'image/jpeg',
               },
               blobHash: processedMedia.thumbnailBlobHash,
               encryptionKey: processedMedia.thumbnailEncryptionKey,
               loop: false,
               dimensions: processedMedia.dimensions,
               thumbHash: processedMedia.thumbHash,
             },
             keyserverOrThreadID: uploadMetadataToKeyserver
               ? threadInfo.id
               : null,
             callbacks: {
               blobServiceUploadHandler,
             },
           });
         })();
 
       [uploadResult, uploadThumbnailResult] = await Promise.all([
         uploadPromise,
         uploadThumbnailPromise,
       ]);
 
       mediaMissionResult = { success: true };
     } catch (e) {
       uploadExceptionMessage = getMessageForException(e);
       if (uploadExceptionMessage === 'invalid_csat') {
         void this.props.invalidTokenLogOut();
         return undefined;
       }
       onUploadFailed('upload failed');
       mediaMissionResult = {
         success: false,
         reason: 'http_upload_failed',
         exceptionMessage: uploadExceptionMessage,
       };
     }
 
     if (
       ((processedMedia.mediaType === 'photo' ||
         processedMedia.mediaType === 'encrypted_photo') &&
         uploadResult) ||
       ((processedMedia.mediaType === 'video' ||
         processedMedia.mediaType === 'encrypted_video') &&
         uploadResult &&
         uploadThumbnailResult)
     ) {
       const { encryptionKey } = processedMedia;
       const { id, uri, dimensions, loop } = uploadResult;
       serverID = id;
 
       const mediaSourcePayload =
         processedMedia.mediaType === 'encrypted_photo' ||
         processedMedia.mediaType === 'encrypted_video'
           ? {
               type: processedMedia.mediaType,
               blobURI: uri,
               encryptionKey,
             }
           : {
               type: uploadResult.mediaType,
               uri,
             };
       let updateMediaPayload = {
         messageID: localMessageID,
         currentMediaID: localMediaID,
         mediaUpdate: {
           id,
           ...mediaSourcePayload,
           dimensions,
           localMediaSelection: undefined,
           loop: uploadResult.mediaType === 'video' ? loop : undefined,
         },
       };
 
       if (
         processedMedia.mediaType === 'video' ||
         processedMedia.mediaType === 'encrypted_video'
       ) {
         invariant(uploadThumbnailResult, 'uploadThumbnailResult exists');
         const { uri: thumbnailURI, id: thumbnailID } = uploadThumbnailResult;
         const { thumbnailEncryptionKey, thumbHash: thumbnailThumbHash } =
           processedMedia;
 
         if (processedMedia.mediaType === 'encrypted_video') {
           updateMediaPayload = {
             ...updateMediaPayload,
             mediaUpdate: {
               ...updateMediaPayload.mediaUpdate,
               thumbnailID,
               thumbnailBlobURI: thumbnailURI,
               thumbnailEncryptionKey,
               thumbnailThumbHash,
             },
           };
         } else {
           updateMediaPayload = {
             ...updateMediaPayload,
             mediaUpdate: {
               ...updateMediaPayload.mediaUpdate,
               thumbnailID,
               thumbnailURI,
               thumbnailThumbHash,
             },
           };
         }
       } else {
         updateMediaPayload = {
           ...updateMediaPayload,
           mediaUpdate: {
             ...updateMediaPayload.mediaUpdate,
             thumbHash: processedMedia.thumbHash,
           },
         };
       }
 
       // 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(...encryptionSteps);
     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 (filesToDispose.length > 0) {
       // 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
       filesToDispose.forEach(shouldDisposePath => {
         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 { steps: clearSteps, result: thumbnailPath } =
             await this.waitForCaptureURIUnload(uploadThumbnailURI);
           steps.push(...clearSteps);
           if (!thumbnailPath) {
             return;
           }
           const disposeStep = await disposeTempFile(thumbnailPath);
           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);
   }
 
   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,
         },
       };
     });
   }
 
   performHTTPMultipartUpload = async (
     url: string,
     cookie: ?string,
     sessionID: ?string,
     input: { +[key: string]: mixed },
     options?: ?CallSingleKeyserverEndpointOptions,
   ): Promise<CallSingleKeyserverEndpointResponse> => {
     invariant(
       cookie &&
         input.multimedia &&
         Array.isArray(input.multimedia) &&
         input.multimedia.length === 1 &&
         input.multimedia[0] &&
         typeof input.multimedia[0] === 'object',
       'InputStateContainer.performHTTPMultipartUpload sent incorrect input',
     );
     const { uri, name, type } = input.multimedia[0];
     invariant(
       typeof uri === 'string' &&
         typeof name === 'string' &&
         typeof type === 'string',
       'InputStateContainer.performHTTPMultipartUpload sent incorrect input',
     );
 
     const parameters: { [key: string]: mixed } = {};
     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',
         'performHTTPMultipartUpload 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;
       }
     }
 
     let uploadOptions = {
       uploadType: FileSystem.FileSystemUploadType.MULTIPART,
       fieldName: 'multimedia',
       headers: {
         Accept: 'application/json',
       },
       parameters,
     };
     if (Platform.OS === 'android' && path.endsWith('.dat')) {
       // expo-file-system is not able to deduce the MIME type of .dat files, so
       // we specify it explicitly here. Without this, we get this error:
       //   guessContentTypeFromName(file.name) must not be null
       uploadOptions = {
         ...uploadOptions,
         mimeType: 'application/octet-stream',
       };
     }
 
     const uploadTask = FileSystem.createUploadTask(
       url,
       path,
       uploadOptions,
       uploadProgress => {
         if (options && options.onProgress) {
           const { totalByteSent, totalBytesExpectedToSend } = uploadProgress;
           options.onProgress(totalByteSent / totalBytesExpectedToSend);
         }
       },
     );
     if (options && options.abortHandler) {
       options.abortHandler(() => uploadTask.cancelAsync());
     }
     try {
       const response = await uploadTask.uploadAsync();
       return JSON.parse(response.body);
     } catch (e) {
       throw new Error(
         `Failed to upload blob: ${
           getMessageForException(e) ?? 'unknown error'
         }`,
       );
     }
   };
 
   handleUploadFailure(
     localMessageID: string,
     localUploadID: string,
     localThumbnailID: ?string,
   ) {
     this.setState(prevState => {
       const uploads = prevState.pendingUploads[localMessageID];
 
       const upload = uploads[localUploadID];
       const thumbnailUpload = localThumbnailID
         ? uploads[localThumbnailID]
         : undefined;
       if (!upload && !thumbnailUpload) {
         // The upload has been completed before it failed
         return {};
       }
 
       const newUploads = { ...uploads };
       newUploads[localUploadID] = {
         ...upload,
         failed: true,
         progressPercent: 0,
       };
       if (localThumbnailID) {
         newUploads[localThumbnailID] = {
           processingStep: null,
           ...thumbnailUpload,
           failed: true,
           progressPercent: 0,
         };
       }
 
       return {
         pendingUploads: {
           ...prevState.pendingUploads,
           [localMessageID]: newUploads,
         },
       };
     });
   }
 
   queueMediaMissionReport(
     ids: { localID: string, localMessageID: string, serverID: ?string },
     mediaMission: MediaMission,
   ) {
     const report: ClientMediaMissionReportCreationRequest = {
       type: reportTypes.MEDIA_MISSION,
       time: Date.now(),
       platformDetails: getConfig().platformDetails,
       mediaMission,
       uploadServerID: ids.serverID,
       uploadLocalID: ids.localID,
       messageLocalID: ids.localMessageID,
       id: generateReportID(),
     };
     this.props.dispatch({
       type: queueReportsActionType,
       payload: {
         reports: [report],
       },
     });
   }
 
   messageHasUploadFailure = (localMessageID: string): boolean => {
     const pendingUploads = this.state.pendingUploads[localMessageID];
     if (!pendingUploads) {
       return false;
     }
     return values(pendingUploads).some(upload => upload.failed);
   };
 
   editInputMessage = (params: EditInputBarMessageParameters) => {
     this.editInputBarCallbacks.forEach(addEditInputBarCallback =>
       addEditInputBarCallback(params),
     );
   };
 
   addEditInputMessageListener = (
     callbackEditInputBar: (params: EditInputBarMessageParameters) => void,
   ) => {
     this.editInputBarCallbacks.push(callbackEditInputBar);
   };
 
   removeEditInputMessageListener = (
     callbackEditInputBar: (params: EditInputBarMessageParameters) => void,
   ) => {
     this.editInputBarCallbacks = this.editInputBarCallbacks.filter(
       candidate => candidate !== callbackEditInputBar,
     );
   };
 
   retryTextMessage = async (
     rawMessageInfo: RawTextMessageInfo,
     threadInfo: ThreadInfo,
     parentThreadInfo: ?ThreadInfo,
   ) => {
     await this.sendTextMessage(
       {
         ...rawMessageInfo,
         time: Date.now(),
       },
       threadInfo,
       parentThreadInfo,
     );
   };
 
   retryMultimediaMessage = async (
     rawMessageInfo: RawMultimediaMessageInfo,
     localMessageID: string,
     threadInfo: ThreadInfo,
   ): Promise<void> => {
     const pendingUploads = this.state.pendingUploads[localMessageID] ?? {};
 
     const now = Date.now();
 
     void this.startThreadCreation(threadInfo);
 
     if (threadIsPendingSidebar(threadInfo.id)) {
       this.pendingSidebarCreationMessageLocalIDs.add(localMessageID);
     }
 
     const updateMedia = <T: Media>(media: $ReadOnlyArray<T>): T[] =>
       media.map(singleMedia => {
         invariant(
           singleMedia.type === 'photo' || singleMedia.type === 'video',
           'Retry selection must be unencrypted',
         );
 
         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) {
       void 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, threadInfo);
   };
 
   retryMessage = async (
     localMessageID: string,
     threadInfo: ThreadInfo,
     parentThreadInfo: ?ThreadInfo,
   ) => {
     this.sendCallbacks.forEach(callback => callback());
 
     const rawMessageInfo = this.props.messageStoreMessages[localMessageID];
     invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`);
 
     if (rawMessageInfo.type === messageTypes.TEXT) {
       await this.retryTextMessage(rawMessageInfo, threadInfo, parentThreadInfo);
     } 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): Promise<{
     +steps: $ReadOnlyArray<MediaMissionStep>,
     +result: ?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);
     });
   }
 
   setPendingThreadUpdateHandler = (
     threadID: string,
     pendingThreadUpdateHandler: ?(ThreadInfo) => mixed,
   ) => {
     if (!pendingThreadUpdateHandler) {
       this.pendingThreadUpdateHandlers.delete(threadID);
     } else {
       this.pendingThreadUpdateHandlers.set(
         threadID,
         pendingThreadUpdateHandler,
       );
     }
   };
 
   render(): React.Node {
     const inputState = this.inputStateSelector(this.state);
     return (
       <InputStateContext.Provider value={inputState}>
         {this.props.children}
       </InputStateContext.Provider>
     );
   }
 }
 
 const mediaCreationLoadingStatusSelector = createLoadingStatusSelector(
   sendMultimediaMessageActionTypes,
 );
 const textCreationLoadingStatusSelector = createLoadingStatusSelector(
   sendTextMessageActionTypes,
 );
 
 const ConnectedInputStateContainer: React.ComponentType<BaseProps> =
   React.memo<BaseProps>(function ConnectedInputStateContainer(
     props: BaseProps,
   ) {
     const viewerID = useSelector(
       state => state.currentUserInfo && state.currentUserInfo.id,
     );
     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 callBlobServiceUpload = useBlobServiceUpload();
     const callSendMultimediaMessage =
       useInputStateContainerSendMultimediaMessage();
     const callSendTextMessage = useInputStateContainerSendTextMessage();
     const callNewThinThread = useNewThinThread();
     const callNewThickThread = useNewThickThread();
     const dispatchActionPromise = useDispatchActionPromise();
     const dispatch = useDispatch();
     const mediaReportsEnabled = useIsReportEnabled('mediaReports');
     const staffCanSee = useStaffCanSee();
     const textMessageCreationSideEffectsFunc =
       useMessageCreationSideEffectsFunc<RawTextMessageInfo>(messageTypes.TEXT);
     const callInvalidTokenLogOut = useInvalidCSATLogOut();
 
     return (
       <InputStateContainer
         {...props}
         viewerID={viewerID}
         messageStoreMessages={messageStoreMessages}
         ongoingMessageCreation={ongoingMessageCreation}
         hasWiFi={hasWiFi}
         mediaReportsEnabled={mediaReportsEnabled}
         calendarQuery={calendarQuery}
         blobServiceUpload={callBlobServiceUpload}
         sendMultimediaMessage={callSendMultimediaMessage}
         sendTextMessage={callSendTextMessage}
         newThinThread={callNewThinThread}
         newThickThread={callNewThickThread}
         dispatchActionPromise={dispatchActionPromise}
         dispatch={dispatch}
         staffCanSee={staffCanSee}
         textMessageCreationSideEffectsFunc={textMessageCreationSideEffectsFunc}
         invalidTokenLogOut={callInvalidTokenLogOut}
       />
     );
   });
 
 export default ConnectedInputStateContainer;
diff --git a/web/input/input-state-container.react.js b/web/input/input-state-container.react.js
index 542ada7ad..45252fd85 100644
--- a/web/input/input-state-container.react.js
+++ b/web/input/input-state-container.react.js
@@ -1,1729 +1,1735 @@
 // @flow
 
 import invariant from 'invariant';
 import _groupBy from 'lodash/fp/groupBy.js';
 import _keyBy from 'lodash/fp/keyBy.js';
 import _omit from 'lodash/fp/omit.js';
 import _partition from 'lodash/fp/partition.js';
 import _sortBy from 'lodash/fp/sortBy.js';
 import _memoize from 'lodash/memoize.js';
 import * as React from 'react';
 import { createSelector } from 'reselect';
 
 import {
   createLocalMessageActionType,
   sendMultimediaMessageActionTypes,
   sendTextMessageActionTypes,
 } from 'lib/actions/message-actions.js';
 import { queueReportsActionType } from 'lib/actions/report-actions.js';
 import { useNewThinThread } from 'lib/actions/thread-actions.js';
 import {
   type BlobServiceUploadAction,
   type DeleteUploadInput,
   updateMultimediaMessageMediaActionType,
   useBlobServiceUpload,
   useDeleteUpload,
 } from 'lib/actions/upload-actions.js';
 import { useInvalidCSATLogOut } from 'lib/actions/user-actions.js';
 import {
   type PushModal,
   useModalContext,
 } from 'lib/components/modal-provider.react.js';
 import {
   type SendMultimediaMessagePayload,
   useInputStateContainerSendMultimediaMessage,
   useInputStateContainerSendTextMessage,
 } from 'lib/hooks/input-state-container-hooks.js';
 import { useNewThickThread } from 'lib/hooks/thread-hooks.js';
 import { getNextLocalUploadID } from 'lib/media/media-utils.js';
 import { pendingToRealizedThreadIDsSelector } from 'lib/selectors/thread-selectors.js';
 import { IdentityClientContext } from 'lib/shared/identity-client-context.js';
 import type { IdentityClientContextType } from 'lib/shared/identity-client-context.js';
 import {
   createMediaMessageInfo,
   localIDPrefix,
   useMessageCreationSideEffectsFunc,
   getNextLocalID,
 } from 'lib/shared/message-utils.js';
 import type { CreationSideEffectsFunc } from 'lib/shared/messages/message-spec.js';
 import { createRealThreadFromPendingThread } from 'lib/shared/thread-actions-utils.js';
 import {
   draftKeyFromThreadID,
   patchThreadInfoToIncludeMentionedMembersOfParent,
   threadIsPending,
   threadIsPendingSidebar,
 } from 'lib/shared/thread-utils.js';
 import type { CalendarQuery } from 'lib/types/entry-types.js';
 import type {
   MediaMission,
   MediaMissionFailure,
   MediaMissionResult,
   MediaMissionStep,
 } from 'lib/types/media-types.js';
 import { messageTypes } from 'lib/types/message-types-enum.js';
 import {
   type RawMessageInfo,
   type RawMultimediaMessageInfo,
   type SendMessagePayload,
 } from 'lib/types/message-types.js';
 import type { RawImagesMessageInfo } from 'lib/types/messages/images.js';
 import type { RawMediaMessageInfo } from 'lib/types/messages/media.js';
 import type { RawTextMessageInfo } from 'lib/types/messages/text.js';
 import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js';
 import type { Dispatch } from 'lib/types/redux-types.js';
 import { reportTypes } from 'lib/types/report-types.js';
 import {
   threadTypeIsSidebar,
   threadTypeIsThick,
 } from 'lib/types/thread-types-enum.js';
 import type { ThreadType } from 'lib/types/thread-types-enum.js';
 import {
   type ClientNewThinThreadRequest,
   type NewThreadResult,
   type NewThickThreadRequest,
 } from 'lib/types/thread-types.js';
 import {
   blobHashFromBlobServiceURI,
   isBlobServiceURI,
   removeBlobHolder,
 } from 'lib/utils/blob-service.js';
 import { getConfig } from 'lib/utils/config.js';
 import { getMessageForException, SendMessageError } from 'lib/utils/errors.js';
 import {
   type DispatchActionPromise,
   useDispatchActionPromise,
 } from 'lib/utils/redux-promise-utils.js';
 import { useDispatch } from 'lib/utils/redux-utils.js';
 import { generateReportID } from 'lib/utils/report-utils.js';
 import { createDefaultHTTPRequestHeaders } from 'lib/utils/services-utils.js';
 
 import {
   type BaseInputState,
   type InputState,
   InputStateContext,
   type PendingMultimediaUpload,
   type TypeaheadInputState,
   type TypeaheadState,
 } from './input-state.js';
 import { encryptFile } from '../media/encryption-utils.js';
 import { generateThumbHash } from '../media/image-utils.js';
 import {
   preloadImage,
   preloadMediaResource,
   validateFile,
 } from '../media/media-utils.js';
 import InvalidUploadModal from '../modals/chat/invalid-upload.react.js';
 import { updateNavInfoActionType } from '../redux/action-types.js';
 import { useSelector } from '../redux/redux-utils.js';
 import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js';
 
 type CombinedInputState = {
   +inputBaseState: BaseInputState,
   +typeaheadState: TypeaheadInputState,
 };
 
 type BaseProps = {
   +children: React.Node,
 };
 type Props = {
   ...BaseProps,
   +activeChatThreadID: ?string,
   +drafts: { +[key: string]: string },
   +viewerID: ?string,
   +messageStoreMessages: { +[id: string]: RawMessageInfo },
   +pendingRealizedThreadIDs: $ReadOnlyMap<string, string>,
   +dispatch: Dispatch,
   +dispatchActionPromise: DispatchActionPromise,
   +calendarQuery: () => CalendarQuery,
   +blobServiceUpload: BlobServiceUploadAction,
   +deleteUpload: (input: DeleteUploadInput) => Promise<void>,
   +sendMultimediaMessage: (
     messageInfo: RawMultimediaMessageInfo,
     sidebarCreation: boolean,
     isLegacy: boolean,
   ) => Promise<SendMultimediaMessagePayload>,
   +sendTextMessage: (
     messageInfo: RawTextMessageInfo,
     threadInfo: ThreadInfo,
     parentThreadInfo: ?ThreadInfo,
     sidebarCreation: boolean,
   ) => Promise<SendMessagePayload>,
   +newThinThread: (
     request: ClientNewThinThreadRequest,
   ) => Promise<NewThreadResult>,
   +newThickThread: (request: NewThickThreadRequest) => Promise<string>,
   +pushModal: PushModal,
   +sendCallbacks: $ReadOnlyArray<() => mixed>,
   +registerSendCallback: (() => mixed) => void,
   +unregisterSendCallback: (() => mixed) => void,
   +textMessageCreationSideEffectsFunc: CreationSideEffectsFunc<RawTextMessageInfo>,
   +identityContext: ?IdentityClientContextType,
   +invalidTokenLogOut: () => Promise<void>,
 };
 type WritableState = {
   pendingUploads: {
     [threadID: string]: { [localUploadID: string]: PendingMultimediaUpload },
   },
   textCursorPositions: { [threadID: string]: number },
   typeaheadState: TypeaheadState,
 };
 type State = $ReadOnly<WritableState>;
 
 type PropsAndState = {
   ...Props,
   ...State,
 };
 
 class InputStateContainer extends React.PureComponent<Props, State> {
   state: State = {
     pendingUploads: {},
     textCursorPositions: {},
     typeaheadState: {
       canBeVisible: false,
       keepUpdatingThreadMembers: true,
       frozenUserMentionsCandidates: [],
       frozenChatMentionsCandidates: {},
       moveChoiceUp: null,
       moveChoiceDown: null,
       close: null,
       accept: null,
     },
   };
   replyCallbacks: Array<(message: string) => void> = [];
   pendingThreadCreations: Map<
     string,
     Promise<{
       +threadID: string,
       +threadType: ThreadType,
     }>,
   > = new Map<
     string,
     Promise<{
       +threadID: string,
       +threadType: ThreadType,
     }>,
   >();
 
   // When the user sends a multimedia message that triggers the creation of a
   // sidebar, the sidebar gets created right away, but the message needs to wait
   // for the uploads to complete before sending. We use this Set to track the
   // message localIDs that need sidebarCreation: true.
   pendingSidebarCreationMessageLocalIDs: Set<string> = new Set<string>();
 
   static reassignToRealizedThreads<T>(
     state: { +[threadID: string]: T },
     props: Props,
   ): ?{ [threadID: string]: T } {
     const newState: { [string]: T } = {};
     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): ?Partial<State> {
     const pendingUploads = InputStateContainer.reassignToRealizedThreads(
       state.pendingUploads,
       props,
     );
     const textCursorPositions = InputStateContainer.reassignToRealizedThreads(
       state.textCursorPositions,
       props,
     );
 
     if (!pendingUploads && !textCursorPositions) {
       return null;
     }
 
     const stateUpdate: Partial<WritableState> = {};
     if (pendingUploads) {
       stateUpdate.pendingUploads = pendingUploads;
     }
     if (textCursorPositions) {
       stateUpdate.textCursorPositions = textCursorPositions;
     }
     return stateUpdate;
   }
 
   static completedMessageIDs(state: State): Set<string> {
     const completed = new Map<string, boolean>();
     for (const threadID in state.pendingUploads) {
       const pendingUploads = state.pendingUploads[threadID];
       for (const localUploadID in pendingUploads) {
         const upload = pendingUploads[localUploadID];
         const { messageID, canBeSent, failed } = upload;
         if (!messageID || !messageID.startsWith(localIDPrefix)) {
           continue;
         }
         if (!canBeSent || failed) {
           completed.set(messageID, false);
           continue;
         }
         if (completed.get(messageID) === undefined) {
           completed.set(messageID, true);
         }
       }
     }
     const messageIDs = new Set<string>();
     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<string>();
     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<
       string,
       {
         +threadID: string,
         +uploads: PendingMultimediaUpload[],
       },
     >();
     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<string, RawMultimediaMessageInfo>();
     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,
           encryptionKey,
           thumbHash,
         }) => {
           // 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.
           const shimmedDimensions = dimensions ?? { height: 0, width: 0 };
           invariant(
             mediaType === 'photo' || mediaType === 'encrypted_photo',
             "web InputStateContainer can't handle video",
           );
           if (
             mediaType !== 'encrypted_photo' &&
             mediaType !== 'encrypted_video'
           ) {
             return {
               id: serverID ? serverID : localID,
               uri,
               type: 'photo',
               dimensions: shimmedDimensions,
               thumbHash,
             };
           }
           invariant(
             encryptionKey,
             'encrypted media must have an encryption key',
           );
           return {
             id: serverID ? serverID : localID,
             blobURI: uri,
             type: 'encrypted_photo',
             encryptionKey,
             dimensions: shimmedDimensions,
             thumbHash,
           };
         },
       );
       const messageInfo = createMediaMessageInfo(
         {
           localID: messageID,
           threadID,
           creatorID,
           media,
         },
         { forceMultimediaMessageType: true },
       );
       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);
       }
       void 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,
   ): Promise<void> {
     if (!threadIsPending(messageInfo.threadID)) {
       void 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');
       }
       const result = await threadCreationPromise;
       newThreadID = result.threadID;
     } catch (e) {
       const exceptionMessage = getMessageForException(e) ?? '';
       const payload = new SendMessageError(
         `Exception when creating thread: ${exceptionMessage}`,
         messageInfo.localID ?? '',
         messageInfo.threadID,
       );
       this.props.dispatch({
         type: sendMultimediaMessageActionTypes.failed,
         payload,
         error: true,
       });
       return;
     } finally {
       this.pendingThreadCreations.delete(messageInfo.threadID);
     }
 
     // While the thread was being created, the image preload may have completed,
     // and we might have a finalized URI now. So we fetch from Redux again
     const { localID } = messageInfo;
     invariant(
       localID !== null && localID !== undefined,
       'localID should exist for locally-created RawMessageInfo',
     );
     const latestMessageInfo = this.getRawMultimediaMessageInfo(localID);
 
     // Conditional is necessary for Flow
     let newMessageInfo;
     if (latestMessageInfo.type === messageTypes.MULTIMEDIA) {
       newMessageInfo = {
         ...latestMessageInfo,
         threadID: newThreadID,
         time: Date.now(),
       };
     } else {
       newMessageInfo = {
         ...latestMessageInfo,
         threadID: newThreadID,
         time: Date.now(),
       };
     }
 
     void this.props.dispatchActionPromise(
       sendMultimediaMessageActionTypes,
       this.sendMultimediaMessageAction(newMessageInfo),
       undefined,
       newMessageInfo,
     );
   }
 
   async sendMultimediaMessageAction(
     messageInfo: RawMultimediaMessageInfo,
   ): Promise<SendMessagePayload> {
     const { localID, threadID } = messageInfo;
     invariant(
       localID !== null && localID !== undefined,
       'localID should be set',
     );
     const sidebarCreation =
       this.pendingSidebarCreationMessageLocalIDs.has(localID);
     try {
       const { result, mediaIDUpdates } = await this.props.sendMultimediaMessage(
         messageInfo,
         sidebarCreation,
         true,
       );
       this.pendingSidebarCreationMessageLocalIDs.delete(localID);
       this.setState(prevState => {
         const newThreadID = this.getRealizedOrPendingThreadID(threadID);
         const prevUploads = prevState.pendingUploads[newThreadID];
         const newUploads: { [string]: PendingMultimediaUpload } = {};
         for (const localUploadID in prevUploads) {
           const upload = prevUploads[localUploadID];
           if (upload.messageID !== localID) {
             newUploads[localUploadID] = upload;
           } else if (!upload.uriIsReal) {
             const { serverID } = upload;
             let newServerID = serverID;
             if (serverID && mediaIDUpdates?.[serverID]) {
               newServerID = mediaIDUpdates[serverID].id;
             }
             newUploads[localUploadID] = {
               ...upload,
               messageID: result.serverID,
               serverID: newServerID,
             };
           }
         }
         return {
           pendingUploads: {
             ...prevState.pendingUploads,
             [newThreadID]: newUploads,
           },
         };
       });
       return result;
     } catch (e) {
+      if (e instanceof SendMessageError) {
+        throw e;
+      }
       const exceptionMessage = getMessageForException(e) ?? '';
       if (exceptionMessage === 'invalid_csat') {
         void this.props.invalidTokenLogOut();
       }
       throw new SendMessageError(
         `Exception while sending multimedia message: ${exceptionMessage}`,
         localID,
         threadID,
       );
     }
   }
 
   startThreadCreation(threadInfo: ThreadInfo): Promise<{
     +threadID: string,
     +threadType: ThreadType,
   }> {
     if (!threadIsPending(threadInfo.id)) {
       return Promise.resolve({
         threadID: threadInfo.id,
         threadType: threadInfo.type,
       });
     }
     let threadCreationPromise = this.pendingThreadCreations.get(threadInfo.id);
     if (!threadCreationPromise) {
       const calendarQuery = this.props.calendarQuery();
       threadCreationPromise = createRealThreadFromPendingThread({
         threadInfo,
         dispatchActionPromise: this.props.dispatchActionPromise,
         createNewThinThread: this.props.newThinThread,
         createNewThickThread: this.props.newThickThread,
         sourceMessageID: threadInfo.sourceMessageID,
         viewerID: this.props.viewerID,
         calendarQuery,
       });
       this.pendingThreadCreations.set(threadInfo.id, threadCreationPromise);
     }
     return threadCreationPromise;
   }
 
   inputBaseStateSelector: (?string) => PropsAndState => BaseInputState =
     _memoize(threadID =>
       createSelector(
         (propsAndState: PropsAndState) =>
           threadID ? propsAndState.pendingUploads[threadID] : null,
         (propsAndState: PropsAndState) =>
           threadID
             ? propsAndState.drafts[draftKeyFromThreadID(threadID)]
             : null,
         (propsAndState: PropsAndState) =>
           threadID ? propsAndState.textCursorPositions[threadID] : null,
         (
           pendingUploads: ?{ [localUploadID: string]: PendingMultimediaUpload },
           draft: ?string,
           textCursorPosition: ?number,
         ) => {
           let threadPendingUploads: $ReadOnlyArray<PendingMultimediaUpload> =
             [];
           const assignedUploads: {
             [string]: $ReadOnlyArray<PendingMultimediaUpload>,
           } = {};
           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 ?? '',
             textCursorPosition: textCursorPosition ?? 0,
             appendFiles: (
               threadInfo: ThreadInfo,
               files: $ReadOnlyArray<File>,
             ) => this.appendFiles(threadInfo, files),
             cancelPendingUpload: this.cancelPendingUpload,
             sendTextMessage: (
               messageInfo: RawTextMessageInfo,
               threadInfo: ThreadInfo,
               parentThreadInfo: ?ThreadInfo,
             ) =>
               this.sendTextMessage(messageInfo, threadInfo, parentThreadInfo),
             createMultimediaMessage: (threadInfo: ThreadInfo) =>
               this.createMultimediaMessage(threadInfo),
             setDraft: (newDraft: string) => this.setDraft(threadID, newDraft),
             setTextCursorPosition: (newPosition: number) =>
               this.setTextCursorPosition(threadID, newPosition),
             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,
           }: BaseInputState);
         },
       ),
     );
 
   typeaheadStateSelector: PropsAndState => TypeaheadInputState = createSelector(
     (propsAndState: PropsAndState) => propsAndState.typeaheadState,
     (typeaheadState: TypeaheadState) => ({
       typeaheadState,
       setTypeaheadState: this.setTypeaheadState,
     }),
   );
 
   inputStateSelector: CombinedInputState => InputState = createSelector(
     (state: CombinedInputState) => state.inputBaseState,
     (state: CombinedInputState) => state.typeaheadState,
     (inputBaseState: BaseInputState, typeaheadState: TypeaheadInputState) => ({
       ...inputBaseState,
       ...typeaheadState,
     }),
   );
 
   getRealizedOrPendingThreadID(threadID: string): string {
     return this.props.pendingRealizedThreadIDs.get(threadID) ?? threadID;
   }
 
   async appendFiles(
     threadInfo: ThreadInfo,
     files: $ReadOnlyArray<File>,
   ): Promise<boolean> {
     const selectionTime = Date.now();
     const { pushModal } = this.props;
 
     const appendResults = await Promise.all(
       files.map(file => this.appendFile(threadInfo, file, selectionTime)),
     );
 
     if (appendResults.some(({ result }) => !result.success)) {
       pushModal(<InvalidUploadModal />);
 
       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(threadInfo.id);
         const prevUploads = prevState.pendingUploads[newThreadID];
         const mergedUploads = prevUploads
           ? { ...prevUploads, ...newUploadsObject }
           : newUploadsObject;
         return {
           pendingUploads: {
             ...prevState.pendingUploads,
             [newThreadID]: mergedUploads,
           },
         };
       },
       () => this.uploadFiles(threadInfo, newUploads),
     );
     return true;
   }
 
   async appendFile(
     threadInfo: ThreadInfo,
     file: File,
     selectTime: number,
   ): Promise<{
     steps: $ReadOnlyArray<MediaMissionStep>,
     result:
       | MediaMissionFailure
       | { success: true, pendingUpload: PendingMultimediaUpload },
   }> {
     const steps: MediaMissionStep[] = [
       {
         step: 'web_selection',
         filename: file.name,
         size: file.size,
         mime: file.type,
         selectTime,
       },
     ];
 
     let response;
     const validationStart = Date.now();
     try {
       response = await validateFile(file);
     } 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;
 
     let encryptionResponse;
     const encryptionStart = Date.now();
     try {
       encryptionResponse = await encryptFile(fixedFile);
     } catch (e) {
       return {
         steps,
         result: {
           success: false,
           reason: 'encryption_exception',
           time: Date.now() - encryptionStart,
           exceptionMessage: getMessageForException(e),
         },
       };
     }
     const { result: encryptionResult, steps: encryptionSteps } =
       encryptionResponse;
     steps.push(...encryptionSteps);
 
     if (!encryptionResult.success) {
       return { steps, result: encryptionResult };
     }
 
     const { steps: thumbHashSteps, result: thumbHashResult } =
       await generateThumbHash(fixedFile, encryptionResult?.encryptionKey);
     const thumbHash = thumbHashResult.success
       ? thumbHashResult.thumbHash
       : null;
     steps.push(...thumbHashSteps);
 
     return {
       steps,
       result: {
         success: true,
         pendingUpload: {
           localID: getNextLocalUploadID(),
           serverID: null,
           messageID: null,
           failed: false,
           file: encryptionResult?.file ?? fixedFile,
           mediaType: encryptionResult ? 'encrypted_photo' : mediaType,
           dimensions,
           uri: encryptionResult?.uri ?? uri,
           loop: false,
           uriIsReal: false,
           canBeSent: false,
           blobHolder: null,
           blobHash: encryptionResult?.sha256Hash,
           encryptionKey: encryptionResult?.encryptionKey,
           thumbHash,
           progressPercent: 0,
           abort: null,
           steps,
           selectTime,
         },
       },
     };
   }
 
   uploadFiles(
     threadInfo: ThreadInfo,
     uploads: $ReadOnlyArray<PendingMultimediaUpload>,
   ): Promise<mixed> {
     return Promise.all(
       uploads.map(upload => this.uploadFile(threadInfo, upload)),
     );
   }
 
   async uploadFile(threadInfo: ThreadInfo, upload: PendingMultimediaUpload) {
     const { selectTime, localID, encryptionKey } = upload;
     const threadID = threadInfo.id;
     const isThickThread = threadTypeIsThick(threadInfo.type);
     const isEncrypted =
       !!encryptionKey &&
       (upload.mediaType === 'encrypted_photo' ||
         upload.mediaType === 'encrypted_video');
 
     const steps = [...upload.steps];
     let userTime;
 
     const { identityContext } = this.props;
     invariant(identityContext, 'Identity context should be set');
 
     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 {
       const callbacks = {
         onProgress: (percent: number) =>
           this.setProgress(threadID, localID, percent),
         abortHandler: (abort: () => void) =>
           this.handleAbortCallback(threadID, localID, abort),
       };
 
       const { mediaType, blobHash, dimensions, thumbHash } = upload;
       invariant(
         mediaType === 'encrypted_photo' || mediaType === 'encrypted_video',
         'uploaded media should be encrypted',
       );
       invariant(
         encryptionKey && blobHash && dimensions,
         'incomplete encrypted upload',
       );
 
       uploadResult = await this.props.blobServiceUpload({
         uploadInput: {
           blobInput: {
             type: 'file',
             file: upload.file,
           },
           blobHash,
           encryptionKey,
           dimensions,
           loop: false,
           thumbHash,
         },
         keyserverOrThreadID: isThickThread ? null : threadID,
         callbacks,
       });
     } catch (e) {
       uploadExceptionMessage = getMessageForException(e);
       if (uploadExceptionMessage === 'invalid_csat') {
         void this.props.invalidTokenLogOut();
         return;
       }
       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 outputMediaType = isEncrypted ? 'encrypted_photo' : result.mediaType;
 
     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,
               blobHolder: result.blobHolder,
               abort: null,
               // For thin threads we can send message right after serverID
               // is present, but for thick threads we need to wait until
               // a "real" Blob URI is assigned to the message.
               canBeSent: !isThickThread,
             },
           },
         },
       };
     });
 
     if (encryptionKey) {
       const authMetadata = await identityContext.getAuthMetadata();
       const { steps: preloadSteps } = await preloadMediaResource(
         result.uri,
         authMetadata,
       );
       steps.push(...preloadSteps);
     } else {
       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 } = result;
       const { thumbHash } = upload;
       let mediaUpdate = {
         dimensions,
         ...(thumbHash ? { thumbHash } : undefined),
       };
       if (!isEncrypted) {
         mediaUpdate = {
           ...mediaUpdate,
           type: mediaType,
           uri,
         };
       } else {
         mediaUpdate = {
           ...mediaUpdate,
           type: outputMediaType,
           blobURI: uri,
           encryptionKey,
         };
       }
       this.props.dispatch({
         type: updateMultimediaMessageMediaActionType,
         payload: {
           messageID: uploadAfterPreload.messageID,
           currentMediaID: result.id ?? uploadAfterPreload.localID,
           mediaUpdate,
         },
       });
     }
 
     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: outputMediaType,
               dimensions: result.dimensions,
               loop: result.loop,
               uriIsReal: true,
               canBeSent: true,
             },
           },
         },
       };
     });
   }
 
   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,
         id: generateReportID(),
       }),
     );
     this.props.dispatch({ type: queueReportsActionType, payload: { reports } });
   }
 
   cancelPendingUpload = (threadInfo: ThreadInfo, localUploadID: string) => {
     let revokeURL: ?string, abortRequest: ?() => void;
     this.setState(
       prevState => {
         const newThreadID = this.getRealizedOrPendingThreadID(threadInfo.id);
         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) {
           const { serverID } = pendingUpload;
           if (!threadTypeIsThick(threadInfo.type)) {
             void this.props.deleteUpload({
               id: serverID,
               keyserverOrThreadID: threadInfo.id,
             });
           }
           if (isBlobServiceURI(pendingUpload.uri)) {
             const { invalidTokenLogOut, identityContext } = this.props;
             invariant(identityContext, 'Identity context should be set');
             invariant(
               pendingUpload.blobHolder,
               'blob service upload has no holder',
             );
             const holder = pendingUpload.blobHolder;
             const blobHash = blobHashFromBlobServiceURI(pendingUpload.uri);
             void (async () => {
               const authMetadata = await identityContext.getAuthMetadata();
               const defaultHeaders =
                 createDefaultHTTPRequestHeaders(authMetadata);
               const result = await removeBlobHolder(
                 { blobHash, holder },
                 defaultHeaders,
               );
               if (!result.success && result.reason === 'INVALID_CSAT') {
                 void invalidTokenLogOut();
               }
             })();
           }
         }
         const newPendingUploads = _omit([localUploadID])(currentPendingUploads);
         return {
           pendingUploads: {
             ...prevState.pendingUploads,
             [newThreadID]: newPendingUploads,
           },
         };
       },
       () => {
         if (revokeURL) {
           URL.revokeObjectURL(revokeURL);
         }
         if (abortRequest) {
           abortRequest();
         }
       },
     );
   };
 
   async sendTextMessage(
     messageInfo: RawTextMessageInfo,
     inputThreadInfo: ThreadInfo,
     parentThreadInfo: ?ThreadInfo,
   ) {
     this.props.sendCallbacks.forEach(callback => callback());
 
     const { localID } = messageInfo;
     invariant(
       localID !== null && localID !== undefined,
       'localID should be set',
     );
     if (threadIsPendingSidebar(inputThreadInfo.id)) {
       this.pendingSidebarCreationMessageLocalIDs.add(localID);
     }
 
     if (!threadIsPending(inputThreadInfo.id)) {
       void this.props.dispatchActionPromise(
         sendTextMessageActionTypes,
         this.sendTextMessageAction(
           messageInfo,
           inputThreadInfo,
           parentThreadInfo,
         ),
         undefined,
         messageInfo,
       );
       return;
     }
 
     this.props.dispatch({
       type: sendTextMessageActionTypes.started,
       payload: messageInfo,
     });
 
     let threadInfo = inputThreadInfo;
     const { viewerID } = this.props;
     if (viewerID && threadTypeIsSidebar(inputThreadInfo.type)) {
       invariant(parentThreadInfo, 'sidebar should have parent');
       threadInfo = patchThreadInfoToIncludeMentionedMembersOfParent(
         inputThreadInfo,
         parentThreadInfo,
         messageInfo.text,
         viewerID,
       );
       if (threadInfo !== inputThreadInfo) {
         this.props.dispatch({
           type: updateNavInfoActionType,
           payload: { pendingThread: threadInfo },
         });
       }
     }
 
     let threadCreationResult = null;
     try {
       threadCreationResult = await this.startThreadCreation(threadInfo);
     } catch (e) {
       const exceptionMessage = getMessageForException(e) ?? '';
       const payload = new SendMessageError(
         `Exception while creating thread: ${exceptionMessage}`,
         messageInfo.localID ?? '',
         messageInfo.threadID,
       );
       this.props.dispatch({
         type: sendTextMessageActionTypes.failed,
         payload,
         error: true,
       });
       return;
     } finally {
       this.pendingThreadCreations.delete(threadInfo.id);
     }
 
     const newMessageInfo = {
       ...messageInfo,
       threadID: threadCreationResult?.threadID,
       time: Date.now(),
     };
 
     const newThreadInfo = {
       ...threadInfo,
       id: threadCreationResult?.threadID,
       type: threadCreationResult?.threadType ?? threadInfo.type,
     };
     void this.props.dispatchActionPromise(
       sendTextMessageActionTypes,
       this.sendTextMessageAction(
         newMessageInfo,
         newThreadInfo,
         parentThreadInfo,
       ),
       undefined,
       newMessageInfo,
     );
   }
 
   async sendTextMessageAction(
     messageInfo: RawTextMessageInfo,
     threadInfo: ThreadInfo,
     parentThreadInfo: ?ThreadInfo,
   ): Promise<SendMessagePayload> {
     try {
       if (!threadTypeIsThick(threadInfo.type)) {
         await this.props.textMessageCreationSideEffectsFunc(
           messageInfo,
           threadInfo,
           parentThreadInfo,
         );
       }
       const { localID } = messageInfo;
       invariant(
         localID !== null && localID !== undefined,
         'localID should be set',
       );
       const sidebarCreation =
         this.pendingSidebarCreationMessageLocalIDs.has(localID);
       const result = await this.props.sendTextMessage(
         messageInfo,
         threadInfo,
         parentThreadInfo,
         sidebarCreation,
       );
       if (threadTypeIsThick(threadInfo.type)) {
         await this.props.textMessageCreationSideEffectsFunc(
           messageInfo,
           threadInfo,
           parentThreadInfo,
         );
       }
       this.pendingSidebarCreationMessageLocalIDs.delete(localID);
       return result;
     } catch (e) {
+      if (e instanceof SendMessageError) {
+        throw e;
+      }
       const exceptionMessage = getMessageForException(e) ?? '';
       throw new SendMessageError(
         `Exception while sending text message: ${exceptionMessage}`,
         messageInfo.localID ?? '',
         messageInfo.threadID,
       );
     }
   }
 
   // Creates a MultimediaMessage from the unassigned pending uploads,
   // if there are any
   createMultimediaMessage(threadInfo: ThreadInfo) {
     this.props.sendCallbacks.forEach(callback => callback());
 
     const localMessageID = getNextLocalID();
     void this.startThreadCreation(threadInfo);
 
     if (threadIsPendingSidebar(threadInfo.id)) {
       this.pendingSidebarCreationMessageLocalIDs.add(localMessageID);
     }
 
     this.setState(prevState => {
       const newThreadID = this.getRealizedOrPendingThreadID(threadInfo.id);
       const currentPendingUploads = prevState.pendingUploads[newThreadID];
       if (!currentPendingUploads) {
         return {};
       }
       const newPendingUploads: { [string]: PendingMultimediaUpload } = {};
       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) {
     invariant(threadID, 'threadID should be set in setDraft');
 
     const newThreadID = this.getRealizedOrPendingThreadID(threadID);
     this.props.dispatch({
       type: 'UPDATE_DRAFT',
       payload: {
         key: draftKeyFromThreadID(newThreadID),
         text: draft,
       },
     });
   }
 
   setTextCursorPosition(threadID: ?string, newPosition: number) {
     invariant(threadID, 'threadID should be set in setTextCursorPosition');
 
     this.setState(prevState => {
       const newThreadID = this.getRealizedOrPendingThreadID(threadID);
       return {
         textCursorPositions: {
           ...prevState.textCursorPositions,
           [newThreadID]: newPosition,
         },
       };
     });
   }
 
   setTypeaheadState = (newState: Partial<TypeaheadState>) => {
     this.setState(prevState => ({
       typeaheadState: {
         ...prevState.typeaheadState,
         ...newState,
       },
     }));
   };
 
   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<PendingMultimediaUpload>,
   ): boolean {
     if (!pendingUploads) {
       return false;
     }
     return pendingUploads.some(upload => upload.failed);
   }
 
   retryMultimediaMessage(
     localMessageID: string,
     threadInfo: ThreadInfo,
     pendingUploads: ?$ReadOnlyArray<PendingMultimediaUpload>,
   ) {
     this.props.sendCallbacks.forEach(callback => callback());
 
     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);
     }
 
     void this.startThreadCreation(threadInfo);
 
     if (threadIsPendingSidebar(threadInfo.id)) {
       this.pendingSidebarCreationMessageLocalIDs.add(localMessageID);
     }
 
     const completed = InputStateContainer.completedMessageIDs(this.state);
     if (completed.has(localMessageID)) {
       void 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<string>();
     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: { [string]: PendingMultimediaUpload } = {};
       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,
         },
       };
     });
 
     void this.uploadFiles(threadInfo, 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(): React.Node {
     const { activeChatThreadID } = this.props;
 
     // we're going with two selectors as we want to avoid
     // recreation of chat state setter functions on typeahead state updates
     const inputBaseState = this.inputBaseStateSelector(activeChatThreadID)({
       ...this.state,
       ...this.props,
     });
 
     const typeaheadState = this.typeaheadStateSelector({
       ...this.state,
       ...this.props,
     });
 
     const inputState = this.inputStateSelector({
       inputBaseState,
       typeaheadState,
     });
 
     return (
       <InputStateContext.Provider value={inputState}>
         {this.props.children}
       </InputStateContext.Provider>
     );
   }
 }
 
 const ConnectedInputStateContainer: React.ComponentType<BaseProps> =
   React.memo<BaseProps>(function ConnectedInputStateContainer(props) {
     const activeChatThreadID = useSelector(
       state => state.navInfo.activeChatThreadID,
     );
     const drafts = useSelector(state => state.draftStore.drafts);
     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 callBlobServiceUpload = useBlobServiceUpload();
     const callDeleteUpload = useDeleteUpload();
     const callSendMultimediaMessage =
       useInputStateContainerSendMultimediaMessage();
     const callSendTextMessage = useInputStateContainerSendTextMessage();
     const callNewThinThread = useNewThinThread();
     const callNewThickThread = useNewThickThread();
     const dispatch = useDispatch();
     const dispatchActionPromise = useDispatchActionPromise();
     const modalContext = useModalContext();
     const identityContext = React.useContext(IdentityClientContext);
     const callInvalidTokenLogOut = useInvalidCSATLogOut();
 
     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),
         );
       },
       [],
     );
     const textMessageCreationSideEffectsFunc =
       useMessageCreationSideEffectsFunc<RawTextMessageInfo>(messageTypes.TEXT);
 
     return (
       <InputStateContainer
         {...props}
         activeChatThreadID={activeChatThreadID}
         drafts={drafts}
         viewerID={viewerID}
         messageStoreMessages={messageStoreMessages}
         pendingRealizedThreadIDs={pendingToRealizedThreadIDs}
         calendarQuery={calendarQuery}
         blobServiceUpload={callBlobServiceUpload}
         deleteUpload={callDeleteUpload}
         sendMultimediaMessage={callSendMultimediaMessage}
         sendTextMessage={callSendTextMessage}
         newThinThread={callNewThinThread}
         newThickThread={callNewThickThread}
         dispatch={dispatch}
         dispatchActionPromise={dispatchActionPromise}
         pushModal={modalContext.pushModal}
         sendCallbacks={sendCallbacks}
         registerSendCallback={registerSendCallback}
         unregisterSendCallback={unregisterSendCallback}
         textMessageCreationSideEffectsFunc={textMessageCreationSideEffectsFunc}
         identityContext={identityContext}
         invalidTokenLogOut={callInvalidTokenLogOut}
       />
     );
   });
 
 export default ConnectedInputStateContainer;