diff --git a/web/input/input-state-container.react.js b/web/input/input-state-container.react.js --- a/web/input/input-state-container.react.js +++ b/web/input/input-state-container.react.js @@ -8,9 +8,11 @@ import _partition from 'lodash/fp/partition.js'; import _sortBy from 'lodash/fp/sortBy.js'; import _memoize from 'lodash/memoize.js'; +import _throttle from 'lodash/throttle.js'; import * as React from 'react'; import { useDispatch } from 'react-redux'; import { createSelector } from 'reselect'; +import uuid from 'uuid'; import { createLocalMessageActionType, @@ -23,6 +25,7 @@ import { newThread } from 'lib/actions/thread-actions.js'; import { uploadMultimedia, + uploadMediaMetadata, updateMultimediaMessageMediaActionType, deleteUpload, type MultimediaUploadCallbacks, @@ -32,6 +35,7 @@ useModalContext, type PushModal, } from 'lib/components/modal-provider.react.js'; +import blobService from 'lib/facts/blob-service.js'; import commStaffCommunity from 'lib/facts/comm-staff-community.js'; import { getNextLocalUploadID } from 'lib/media/media-utils.js'; import { pendingToRealizedThreadIDsSelector } from 'lib/selectors/thread-selectors.js'; @@ -52,10 +56,12 @@ import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { UploadMultimediaResult, + UploadMediaMetadataRequest, MediaMissionStep, MediaMissionFailure, MediaMissionResult, MediaMission, + Dimensions, } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { @@ -81,6 +87,9 @@ useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; +import { toBase64URL } from 'lib/utils/base64.js'; +import { makeBlobServiceEndpointURL } from 'lib/utils/blob-service.js'; +import type { CallServerEndpointOptions } from 'lib/utils/call-server-endpoint.js'; import { getConfig } from 'lib/utils/config.js'; import { getMessageForException, cloneError } from 'lib/utils/errors.js'; @@ -119,6 +128,9 @@ extras: MultimediaUploadExtras, callbacks: MultimediaUploadCallbacks, ) => Promise, + +uploadMediaMetadata: ( + input: UploadMediaMetadataRequest, + ) => Promise, +deleteUpload: (id: string) => Promise, +sendMultimediaMessage: ( threadID: string, @@ -992,6 +1004,123 @@ }); } + async blobServiceUpload( + input: { + file: File, + blobHash: string, + encryptionKey: string, + dimensions: Dimensions, + loop?: boolean, + }, + options?: ?CallServerEndpointOptions, + ): Promise { + const newHolder = uuid.v4(); + const blobHash = toBase64URL(input.blobHash); + + // 1. Assign new holder for blob with given blobHash + let blobAlreadyExists: boolean; + try { + const assignHolderEndpoint = blobService.httpEndpoints.ASSIGN_HOLDER; + const assignHolderResponse = await fetch( + makeBlobServiceEndpointURL(assignHolderEndpoint), + { + method: assignHolderEndpoint.method, + body: JSON.stringify({ + holder: newHolder, + blob_hash: blobHash, + }), + headers: { + 'content-type': 'application/json', + }, + }, + ); + + if (!assignHolderResponse.ok) { + const { status, statusText } = assignHolderResponse; + throw new Error(`Server responded with HTTP ${status}: ${statusText}`); + } + const { data_exists: dataExistsResponse } = + await assignHolderResponse.json(); + blobAlreadyExists = dataExistsResponse; + } catch (e) { + throw new Error( + `Failed to assign holder: ${ + getMessageForException(e) ?? 'unknown error' + }`, + ); + } + + // 2. Upload blob contents if blob doesn't exist + if (!blobAlreadyExists) { + const formData = new FormData(); + formData.append('blob_hash', blobHash); + formData.append('blob_data', input.file); + + const xhr = new XMLHttpRequest(); + const uploadEndpoint = blobService.httpEndpoints.UPLOAD_BLOB; + xhr.open( + uploadEndpoint.method, + makeBlobServiceEndpointURL(uploadEndpoint), + ); + if (options?.timeout) { + xhr.timeout = options.timeout; + } + if (options && options.onProgress) { + const { onProgress } = options; + xhr.upload.onprogress = _throttle( + ({ loaded, total }) => onProgress(loaded / total), + 50, + ); + } + + let failed = false; + const responsePromise = new Promise((resolve, reject) => { + xhr.onload = () => { + if (failed) { + return; + } + resolve(); + }; + xhr.onabort = () => { + failed = true; + reject(new Error('request aborted')); + }; + xhr.onerror = event => { + failed = true; + reject(event); + }; + if (options && options.timeout) { + xhr.ontimeout = event => { + failed = true; + reject(event); + }; + } + if (options && options.abortHandler) { + options.abortHandler(() => { + failed = true; + reject(new Error('request aborted')); + xhr.abort(); + }); + } + }); + + if (!failed) { + xhr.send(formData); + } + await responsePromise; + } + + // 3. Send upload metadata to the keyserver, return response + return await this.props.uploadMediaMetadata({ + ...input.dimensions, + loop: input.loop ?? false, + blobHolder: newHolder, + encryptionKey: input.encryptionKey, + mimeType: input.file.type, + filename: input.file.name, + }); + } + handleAbortCallback( threadID: string, localUploadID: string, @@ -1521,6 +1650,7 @@ ); const calendarQuery = useSelector(nonThreadCalendarQuery); const callUploadMultimedia = useServerCall(uploadMultimedia); + const callUploadMediaMetadata = useServerCall(uploadMediaMetadata); const callDeleteUpload = useServerCall(deleteUpload); const callSendMultimediaMessage = useServerCall( legacySendMultimediaMessage, @@ -1559,6 +1689,7 @@ pendingRealizedThreadIDs={pendingToRealizedThreadIDs} calendarQuery={calendarQuery} uploadMultimedia={callUploadMultimedia} + uploadMediaMetadata={callUploadMediaMetadata} deleteUpload={callDeleteUpload} sendMultimediaMessage={callSendMultimediaMessage} sendTextMessage={callSendTextMessage}