diff --git a/lib/actions/holder-actions.js b/lib/actions/holder-actions.js --- a/lib/actions/holder-actions.js +++ b/lib/actions/holder-actions.js @@ -163,9 +163,10 @@ }, [dispatchActionPromise, getAuthMetadata, holdersToRemove]); } -function useProcessBlobHolders(): ( +export type ProcessHolders = ( blobOperations: $ReadOnlyArray, -) => Promise { +) => Promise; +function useProcessBlobHolders(): ProcessHolders { const identityContext = React.useContext(IdentityClientContext); const getAuthMetadata = identityContext?.getAuthMetadata; diff --git a/lib/hooks/input-state-container-hooks.js b/lib/hooks/input-state-container-hooks.js --- a/lib/hooks/input-state-container-hooks.js +++ b/lib/hooks/input-state-container-hooks.js @@ -4,13 +4,32 @@ import * as React from 'react'; import uuid from 'uuid'; +import { + type ProcessHolders, + useProcessBlobHolders, +} from '../actions/holder-actions.js'; import { useLegacySendMultimediaMessage, useSendMultimediaMessage, useSendTextMessage, } from '../actions/message-actions.js'; +import type { MediaMetadataReassignmentAction } from '../actions/upload-actions.js'; +import { + useMediaMetadataReassignment, + updateMultimediaMessageMediaActionType, +} from '../actions/upload-actions.js'; +import { + encryptedMediaBlobURI, + encryptedVideoThumbnailBlobURI, +} from '../media/media-utils.js'; import { dmOperationSpecificationTypes } from '../shared/dm-ops/dm-op-utils.js'; import { useSendComposableDMOperation } from '../shared/dm-ops/process-dm-ops.js'; +import type { BlobOperation } from '../types/holder-types.js'; +import type { + EncryptedImage, + EncryptedVideo, + Media, +} from '../types/media-types.js'; import type { RawMultimediaMessageInfo, SendMessagePayload, @@ -18,11 +37,16 @@ import { getMediaMessageServerDBContentsFromMedia } from '../types/messages/media.js'; import type { RawTextMessageInfo } from '../types/messages/text.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; +import type { Dispatch } from '../types/redux-types.js'; import { thickThreadTypes, threadTypeIsThick, } from '../types/thread-types-enum.js'; -import { useSelector } from '../utils/redux-utils.js'; +import { + blobHashFromBlobServiceURI, + isBlobServiceURI, +} from '../utils/blob-service.js'; +import { useSelector, useDispatch } from '../utils/redux-utils.js'; function useInputStateContainerSendTextMessage(): ( messageInfo: RawTextMessageInfo, @@ -116,6 +140,10 @@ const sendComposableDMOperation = useSendComposableDMOperation(); const threadInfos = useSelector(state => state.threadStore.threadInfos); + const reassignThickThreadMedia = useMediaMetadataReassignment(); + const processHolders = useProcessBlobHolders(); + const dispatch = useDispatch(); + return React.useCallback( async ( messageInfo: RawMultimediaMessageInfo, @@ -132,8 +160,14 @@ const isThickThread = threadInfo && threadTypeIsThick(threadInfo.type); if (!isThickThread && isLegacy) { + const { messageMedia } = await migrateMessageMediaToKeyserver( + messageInfo, + reassignThickThreadMedia, + dispatch, + processHolders, + ); const mediaIDs = []; - for (const { id } of messageInfo.media) { + for (const { id } of messageMedia) { mediaIDs.push(id); } const result = await legacySendMultimediaMessage({ @@ -151,9 +185,14 @@ } if (!isThickThread && !isLegacy) { - const mediaMessageContents = getMediaMessageServerDBContentsFromMedia( - messageInfo.media, + const { messageMedia } = await migrateMessageMediaToKeyserver( + messageInfo, + reassignThickThreadMedia, + dispatch, + processHolders, ); + const mediaMessageContents = + getMediaMessageServerDBContentsFromMedia(messageMedia); const result = await sendMultimediaMessage({ threadID: messageInfo.threadID, localID, @@ -206,7 +245,10 @@ }; }, [ + dispatch, + processHolders, legacySendMultimediaMessage, + reassignThickThreadMedia, sendComposableDMOperation, sendMultimediaMessage, threadInfos, @@ -214,6 +256,139 @@ ); } +function mediaIDIsKeyserverID(mediaID: string): boolean { + return mediaID.indexOf('|') !== -1; +} + +type MediaIDUpdatePayload = { +id: string, +thumbnailID?: string }; +type MediaIDUpdates = { +[string]: MediaIDUpdatePayload }; + +async function migrateMessageMediaToKeyserver( + messageInfo: RawMultimediaMessageInfo, + reassignMediaMetadata: MediaMetadataReassignmentAction, + dispatch: Dispatch, + processHolders: ProcessHolders, +): Promise<{ + +messageMedia: $ReadOnlyArray, + +mediaIDUpdates: MediaIDUpdates, +}> { + const messageMedia = [], + holderActions: Array = []; + let mediaIDUpdates: MediaIDUpdates = {}; + + const processMediaChanges = ( + prevMediaID: string, + changes: { + ...MediaIDUpdatePayload, + +blobsToRemoveHolder: $ReadOnlyArray, + }, + ) => { + const { blobsToRemoveHolder, ...mediaUpdate } = changes; + const newHolderActions = blobsToRemoveHolder.map(blobHash => ({ + type: 'remove_holder', + blobHash, + })); + holderActions.push(...newHolderActions); + + mediaIDUpdates = { ...mediaIDUpdates, [prevMediaID]: mediaUpdate }; + dispatch({ + type: updateMultimediaMessageMediaActionType, + payload: { + messageID: messageInfo.localID, + currentMediaID: prevMediaID, + mediaUpdate, + }, + }); + }; + + const reassignmentPromises = messageInfo.media.map(async media => { + if ( + mediaIDIsKeyserverID(media.id) || + (media.type !== 'encrypted_photo' && media.type !== 'encrypted_video') + ) { + messageMedia.push(media); + return; + } + + const mediaURI = encryptedMediaBlobURI(media); + invariant( + isBlobServiceURI(mediaURI), + 'thick thread media should be blob-hosted', + ); + + // This is only to determine server-side if media is photo or video. + // We can mock mime type to represent one of them. + const mimeType = + media.type === 'encrypted_photo' ? 'image/jpeg' : 'video/mp4'; + const blobHash = blobHashFromBlobServiceURI(mediaURI); + const mediaReassignmentPromise = reassignMediaMetadata({ + keyserverOrThreadID: messageInfo.threadID, + mediaMetadataInput: { + blobHash, + mimeType, + dimensions: media.dimensions, + thumbHash: media.thumbHash, + encryptionKey: media.encryptionKey, + loop: media.loop, + }, + }); + + if (media.type !== 'encrypted_video') { + const { id } = await mediaReassignmentPromise; + + const updatedMedia: EncryptedImage = { ...media, id }; + messageMedia.push(updatedMedia); + + const mediaChanges = { id, blobsToRemoveHolder: [blobHash] }; + processMediaChanges(media.id, mediaChanges); + + return; + } + + const thumbnailMediaURI = encryptedVideoThumbnailBlobURI(media); + invariant( + isBlobServiceURI(thumbnailMediaURI), + 'thick thread media thumbnail should be blob-hosted', + ); + + const thumbnailBlobHash = blobHashFromBlobServiceURI(thumbnailMediaURI); + const thumbnailReassignmentPromise = reassignMediaMetadata({ + keyserverOrThreadID: messageInfo.threadID, + mediaMetadataInput: { + blobHash: thumbnailBlobHash, + mimeType: 'image/jpeg', + dimensions: media.dimensions, + thumbHash: media.thumbnailThumbHash, + encryptionKey: media.thumbnailEncryptionKey, + loop: false, + }, + }); + + const [{ id }, { id: thumbnailID }] = await Promise.all([ + mediaReassignmentPromise, + thumbnailReassignmentPromise, + ]); + + const updatedMedia: EncryptedVideo = { ...media, id, thumbnailID }; + messageMedia.push(updatedMedia); + + const mediaChanges = { + id, + thumbnailID, + blobsToRemoveHolder: [blobHash, thumbnailBlobHash], + }; + processMediaChanges(media.id, mediaChanges); + }); + + await Promise.all(reassignmentPromises); + void processHolders(holderActions); + + return { + messageMedia, + mediaIDUpdates, + }; +} + export { useInputStateContainerSendTextMessage, useInputStateContainerSendMultimediaMessage,