diff --git a/lib/media/media-utils.js b/lib/media/media-utils.js index b1961c1b2..70fb894a7 100644 --- a/lib/media/media-utils.js +++ b/lib/media/media-utils.js @@ -1,74 +1,88 @@ // @flow import invariant from 'invariant'; import type { Media } from '../types/media-types.js'; import type { MultimediaMessageInfo, RawMultimediaMessageInfo, } from '../types/message-types.js'; -import { isBlobServiceURI } from '../utils/blob-service.js'; +import { + isBlobServiceURI, + getBlobFetchableURL, + holderFromBlobServiceURI, +} from '../utils/blob-service.js'; const maxDimensions = Object.freeze({ width: 1920, height: 1920 }); function contentStringForMediaArray(media: $ReadOnlyArray): string { if (media.length === 0) { return 'corrupted media'; } else if (media.length === 1) { const type = media[0].type.replace('encrypted_', ''); return `a ${type}`; } let firstType; for (const single of media) { if (!firstType) { firstType = single.type; } if (firstType === single.type) { continue; } else { return 'some media'; } } invariant(firstType, 'there should be some media'); firstType = firstType.replace('encrypted_', ''); if (firstType === 'photo') { firstType = 'image'; } return `some ${firstType}s`; } function isMediaBlobServiceHosted(media: Media): boolean { return ( (!!media.uri && isBlobServiceURI(media.uri)) || (!!media.holder && isBlobServiceURI(media.holder)) || (!!media.thumbnailURI && isBlobServiceURI(media.thumbnailURI)) || (!!media.thumbnailHolder && isBlobServiceURI(media.thumbnailHolder)) ); } +function fetchableMediaURI(uri: string): string { + if (isBlobServiceURI(uri)) { + const holder = holderFromBlobServiceURI(uri); + return getBlobFetchableURL(holder); + } + + return uri; +} + function multimediaMessagePreview( messageInfo: MultimediaMessageInfo | RawMultimediaMessageInfo, ): string { const mediaContentString = contentStringForMediaArray(messageInfo.media); return `sent ${mediaContentString}`; } const localUploadPrefix = 'localUpload'; function isLocalUploadID(id: string): boolean { return id.startsWith(localUploadPrefix); } let nextLocalUploadID = 0; function getNextLocalUploadID(): string { return `${localUploadPrefix}${nextLocalUploadID++}`; } export { maxDimensions, contentStringForMediaArray, multimediaMessagePreview, isLocalUploadID, isMediaBlobServiceHosted, getNextLocalUploadID, + fetchableMediaURI, }; diff --git a/native/media/encryption-utils.js b/native/media/encryption-utils.js index 12b6a43e2..e7b1db90f 100644 --- a/native/media/encryption-utils.js +++ b/native/media/encryption-utils.js @@ -1,386 +1,389 @@ // @flow import invariant from 'invariant'; import { uintArrayToHexString, hexToUintArray } from 'lib/media/data-utils.js'; import { replaceExtension, fileInfoFromData, readableFilename, pathFromURI, } from 'lib/media/file-utils.js'; import type { MediaMissionFailure, MediaMissionStep, EncryptFileMediaMissionStep, } from 'lib/types/media-types.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { pad, unpad, calculatePaddedLength } from 'lib/utils/pkcs7-padding.js'; import { temporaryDirectoryPath } from './file-utils.js'; import { getFetchableURI } from './identifier-utils.js'; import type { MediaResult } from './media-utils.js'; import { commUtilsModule } from '../native-modules.js'; import * as AES from '../utils/aes-crypto-module.js'; const PADDING_THRESHOLD = 5000000; // we don't pad files larger than this type EncryptedFileResult = { +success: true, +uri: string, +encryptionKey: string, }; /** * Encrypts a single file and returns the encrypted file URI * and the encryption key. The encryption key is returned as a hex string. * The encrypted file is written to the same directory as the original file, * with the same name, but with the extension ".dat". * * @param uri uri to the file to encrypt * @returns encryption result along with mission steps */ async function encryptFile(uri: string): Promise<{ steps: $ReadOnlyArray, result: MediaMissionFailure | EncryptedFileResult, }> { let success = true, exceptionMessage; const steps: EncryptFileMediaMissionStep[] = []; const destinationURI = replaceExtension(uri, 'dat'); const destination = pathFromURI(destinationURI); invariant(destination, `uri must be a local file:// path: ${destinationURI}`); // Step 1. Read the file const startOpenFile = Date.now(); let data; try { const path = pathFromURI(uri); // for local paths (file:// URI) we can use native module which is faster if (path) { const buffer = await commUtilsModule.readBufferFromFile(path); data = new Uint8Array(buffer); } else { const response = await fetch(getFetchableURI(uri)); const buffer = await response.arrayBuffer(); data = new Uint8Array(buffer); } } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'read_plaintext_file', file: uri, time: Date.now() - startOpenFile, success, exceptionMessage, }); if (!success || !data) { return { steps, result: { success: false, reason: 'fetch_failed' }, }; } // Step 2. Encrypt the file const startEncrypt = Date.now(); const paddedLength = calculatePaddedLength(data.byteLength); const shouldPad = paddedLength <= PADDING_THRESHOLD; let key, encryptedData; try { const plaintextData = shouldPad ? pad(data) : data; key = AES.generateKey(); encryptedData = AES.encrypt(key, plaintextData); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'encrypt_data', dataSize: encryptedData?.byteLength ?? -1, isPadded: shouldPad, time: Date.now() - startEncrypt, success, exceptionMessage, }); if (!success || !encryptedData || !key) { return { steps, result: { success: false, reason: 'encryption_failed' }, }; } // Step 3. Write the encrypted file const startWriteFile = Date.now(); try { await commUtilsModule.writeBufferToFile(destination, encryptedData.buffer); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'write_encrypted_file', file: destination, time: Date.now() - startWriteFile, success, exceptionMessage, }); if (!success) { return { steps, result: { success: false, reason: 'write_file_failed' }, }; } return { steps, result: { success: true, uri: destinationURI, encryptionKey: uintArrayToHexString(key), }, }; } /** * Encrypts a single photo or video. Replaces the uploadURI with the encrypted * file URI. Attaches `encryptionKey` to the result. Changes the mediaType to * `encrypted_photo` or `encrypted_video`. * * @param preprocessedMedia - Result of `processMedia()` call * @returns a `preprocessedMedia` param, but with encryption applied */ async function encryptMedia(preprocessedMedia: MediaResult): Promise<{ result: MediaResult | MediaMissionFailure, steps: $ReadOnlyArray, }> { invariant(preprocessedMedia.success, 'encryptMedia called on failure result'); invariant( preprocessedMedia.mediaType === 'photo' || preprocessedMedia.mediaType === 'video', 'encryptMedia should only be called on unencrypted photos and videos', ); const { uploadURI } = preprocessedMedia; const steps = []; // Encrypt the media file const { steps: encryptionSteps, result: encryptionResult } = await encryptFile(uploadURI); steps.push(...encryptionSteps); if (!encryptionResult.success) { return { steps, result: encryptionResult }; } if (preprocessedMedia.mediaType === 'photo') { return { steps, result: { ...preprocessedMedia, mediaType: 'encrypted_photo', uploadURI: encryptionResult.uri, encryptionKey: encryptionResult.encryptionKey, shouldDisposePath: pathFromURI(encryptionResult.uri), }, }; } // For videos, we also need to encrypt the thumbnail const { steps: thumbnailEncryptionSteps, result: thumbnailEncryptionResult } = await encryptFile(preprocessedMedia.uploadThumbnailURI); steps.push(...thumbnailEncryptionSteps); if (!thumbnailEncryptionResult.success) { return { steps, result: thumbnailEncryptionResult }; } return { steps, result: { ...preprocessedMedia, mediaType: 'encrypted_video', uploadURI: encryptionResult.uri, encryptionKey: encryptionResult.encryptionKey, uploadThumbnailURI: thumbnailEncryptionResult.uri, thumbnailEncryptionKey: thumbnailEncryptionResult.encryptionKey, shouldDisposePath: pathFromURI(encryptionResult.uri), }, }; } type DecryptFileStep = | { +step: 'fetch_file', +file: string, +time: number, +success: boolean, +exceptionMessage: ?string, } | { +step: 'decrypt_data', +dataSize: number, +time: number, +isPadded: boolean, +success: boolean, +exceptionMessage: ?string, } | { +step: 'write_file', +file: string, +mimeType: string, +time: number, +success: boolean, +exceptionMessage: ?string, } | { +step: 'create_data_uri', +mimeType: string, +time: number, +success: boolean, +exceptionMessage: ?string, }; type DecryptionFailure = | MediaMissionFailure | { +success: false, +reason: | 'fetch_file_failed' | 'decrypt_data_failed' | 'write_file_failed', +exceptionMessage: ?string, }; async function decryptMedia( holder: string, encryptionKey: string, options: { +destination: 'file' | 'data_uri' }, ): Promise<{ steps: $ReadOnlyArray, result: DecryptionFailure | { success: true, uri: string }, }> { let success = true, exceptionMessage; const steps: DecryptFileStep[] = []; // Step 1. Fetch the file and convert it to a Uint8Array const fetchStartTime = Date.now(); let data; try { const response = await fetch(getFetchableURI(holder)); + if (!response.ok) { + throw new Error(`HTTP error ${response.status}: ${response.statusText}`); + } const buf = await response.arrayBuffer(); data = new Uint8Array(buf); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'fetch_file', file: holder, time: Date.now() - fetchStartTime, success, exceptionMessage, }); if (!success || !data) { return { steps, result: { success: false, reason: 'fetch_file_failed', exceptionMessage }, }; } // Step 2. Decrypt the data const decryptionStartTime = Date.now(); let plaintextData, decryptedData, isPadded; try { const key = hexToUintArray(encryptionKey); plaintextData = AES.decrypt(key, data); isPadded = plaintextData.byteLength <= PADDING_THRESHOLD; decryptedData = isPadded ? unpad(plaintextData) : plaintextData; } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'decrypt_data', dataSize: decryptedData?.byteLength ?? -1, isPadded: !!isPadded, time: Date.now() - decryptionStartTime, success, exceptionMessage, }); if (!success || !decryptedData) { return { steps, result: { success: false, reason: 'decrypt_data_failed', exceptionMessage, }, }; } // Step 3. Write the file to disk or create a data URI let uri; const writeStartTime = Date.now(); // we need extension for react-native-video to work const { mime } = fileInfoFromData(decryptedData); if (!mime) { return { steps, result: { success: false, reason: 'mime_check_failed', mime, }, }; } if (options.destination === 'file') { // if holder is a URL, then we use the last part of the path as the filename const holderSuffix = holder.substring(holder.lastIndexOf('/') + 1); const filename = readableFilename(holderSuffix, mime) || holderSuffix; const targetPath = `${temporaryDirectoryPath}${Date.now()}-${filename}`; try { await commUtilsModule.writeBufferToFile(targetPath, decryptedData.buffer); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } uri = `file://${targetPath}`; steps.push({ step: 'write_file', file: uri, mimeType: mime, time: Date.now() - writeStartTime, success, exceptionMessage, }); if (!success) { return { steps, result: { success: false, reason: 'write_file_failed', exceptionMessage, }, }; } } else { const base64 = commUtilsModule.base64EncodeBuffer(decryptedData.buffer); uri = `data:${mime};base64,${base64}`; steps.push({ step: 'create_data_uri', mimeType: mime, time: Date.now() - writeStartTime, success, exceptionMessage, }); } return { steps, result: { success: true, uri }, }; } export { encryptMedia, decryptMedia }; diff --git a/native/media/identifier-utils.js b/native/media/identifier-utils.js index f032678d1..960b40dc0 100644 --- a/native/media/identifier-utils.js +++ b/native/media/identifier-utils.js @@ -1,62 +1,66 @@ // @flow +import { fetchableMediaURI } from 'lib/media/media-utils.js'; + function getCompatibleMediaURI(uri: string, ext: ?string): string { if (!ext) { return uri; } if (!uri.startsWith('ph://') && !uri.startsWith('ph-upload://')) { return uri; } const photoKitLocalIdentifier = uri.split('/')[2]; if (!photoKitLocalIdentifier) { return uri; } // While the ph:// scheme is a Facebook hack used by FBMediaKit, the // assets-library:// scheme is a legacy Apple identifier. We map to the former // because: // (1) Some libraries (namely react-native-video) don't know how to handle the // ph:// scheme yet // (2) In RN0.60, uploading ph:// JPEGs leads to recompression and often // increases file size! It has the nice side effect of rotating image data // based on EXIF orientation, but this isn't worth it for us // https://github.com/facebook/react-native/issues/27099#issuecomment-602016225 // https://github.com/expo/expo/issues/3177 // https://github.com/react-native-community/react-native-video/issues/1572 return ( `assets-library://asset/asset.${ext}` + `?id=${photoKitLocalIdentifier}&ext=${ext}` ); } const mediaLibraryIdentifierRegex = new RegExp( '^assets-library:\\/\\/asset\\/asset.[a-z0-9]+\\?id=([a-z0-9-]+)', 'i', ); function getMediaLibraryIdentifier(inputURI: string): ?string { const uri = getCompatibleMediaURI(inputURI); const matches = uri.match(mediaLibraryIdentifierRegex); if (!matches) { return null; } return matches[1]; } function getFetchableURI(inputURI: string): string { + // support for blob service URIs + let uri = fetchableMediaURI(inputURI); + // React Native always resolves Apple's assets-library:// and FBMediaKit's // ph:// scheme as an image so that the Image component can render thumbnails // of videos. In order to force fetch() to return a blob of the video, we need // to use the ph-upload:// scheme. https://git.io/Jerlh - let uri = inputURI; if (uri.startsWith('assets-library://')) { const mediaNativeID = getMediaLibraryIdentifier(uri); if (mediaNativeID) { uri = `ph-upload://${mediaNativeID}/L0/001`; } } if (uri.startsWith('ph://')) { uri = uri.replace(/^ph:/, 'ph-upload:'); } return uri; } export { getCompatibleMediaURI, getMediaLibraryIdentifier, getFetchableURI }; diff --git a/web/media/encryption-utils.js b/web/media/encryption-utils.js index 5416d293e..16fe5d3f2 100644 --- a/web/media/encryption-utils.js +++ b/web/media/encryption-utils.js @@ -1,225 +1,230 @@ // @flow import invariant from 'invariant'; import { hexToUintArray, uintArrayToHexString } from 'lib/media/data-utils.js'; import { fileInfoFromData } from 'lib/media/file-utils.js'; +import { fetchableMediaURI } from 'lib/media/media-utils.js'; import type { MediaMissionFailure, MediaMissionStep, } from 'lib/types/media-types.js'; import { getMessageForException } from 'lib/utils/errors.js'; import { calculatePaddedLength, pad, unpad } from 'lib/utils/pkcs7-padding.js'; import * as AES from './aes-crypto-utils.js'; const PADDING_THRESHOLD = 5000000; // 5MB type EncryptFileResult = { +success: true, +file: File, +uri: string, +encryptionKey: string, }; async function encryptFile(input: File): Promise<{ steps: $ReadOnlyArray, result: EncryptFileResult | MediaMissionFailure, }> { const steps = []; let success = true, exceptionMessage; // Step 1: Read the file into an ArrayBuffer let data; const arrayBufferStart = Date.now(); try { const inputBuffer = await input.arrayBuffer(); data = new Uint8Array(inputBuffer); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'array_buffer_from_blob', success, exceptionMessage, time: Date.now() - arrayBufferStart, }); if (!success || !data) { return { steps, result: { success: false, reason: 'array_buffer_failed' } }; } // Step 2: Encrypt the data const startEncrypt = Date.now(); const paddedLength = calculatePaddedLength(data.length); const shouldPad = paddedLength <= PADDING_THRESHOLD; let key, encryptedData; try { const plaintextData = shouldPad ? pad(data) : data; key = await AES.generateKey(); encryptedData = await AES.encrypt(key, plaintextData); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'encrypt_data', dataSize: encryptedData?.byteLength ?? -1, isPadded: shouldPad, time: Date.now() - startEncrypt, success, exceptionMessage, }); if (!success || !encryptedData || !key) { return { steps, result: { success: false, reason: 'encryption_failed' } }; } // Step 3: Create a File from the encrypted data const output = new File([encryptedData], input.name, { type: input.type }); return { steps, result: { success: true, file: output, uri: URL.createObjectURL(output), encryptionKey: uintArrayToHexString(key), }, }; } type DecryptFileStep = | { +step: 'fetch_buffer', +url: string, +time: number, +success: boolean, +exceptionMessage: ?string, } | { +step: 'decrypt_data', +dataSize: number, +time: number, +isPadded: boolean, +success: boolean, +exceptionMessage: ?string, } | { +step: 'save_blob', +objectURL: ?string, +mimeType: string, +time: number, +success: boolean, +exceptionMessage: ?string, }; type DecryptionFailure = | MediaMissionFailure | { +success: false, +reason: 'decrypt_data_failed' | 'save_blob_failed', }; /** * Fetches the encrypted media for given {@link holder}, decrypts it, * and stores it in a blob. Returns the object URL of the blob. * * The returned object URL should be revoked when the media is no longer needed. */ async function decryptMedia( holder: string, encryptionKey: string, ): Promise<{ steps: $ReadOnlyArray, result: { success: true, uri: string } | DecryptionFailure, }> { let success = true; let exceptionMessage; const steps: DecryptFileStep[] = []; // Step 1 - Fetch the encrypted media and convert it to a Uint8Array let data; const fetchStartTime = Date.now(); + const url = fetchableMediaURI(holder); try { - const response = await fetch(holder); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error ${response.status}: ${response.statusText}`); + } const buffer = await response.arrayBuffer(); data = new Uint8Array(buffer); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'fetch_buffer', - url: holder, + url, time: Date.now() - fetchStartTime, success, exceptionMessage, }); if (!success || !data) { return { steps, result: { success: false, reason: 'fetch_failed' }, }; } // Step 2 - Decrypt the data let decryptedData; const decryptStartTime = Date.now(); try { const keyBytes = hexToUintArray(encryptionKey); const plaintext = await AES.decrypt(keyBytes, data); decryptedData = plaintext.byteLength > PADDING_THRESHOLD ? plaintext : unpad(plaintext); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'decrypt_data', dataSize: decryptedData?.byteLength ?? -1, time: Date.now() - decryptStartTime, isPadded: data.byteLength > PADDING_THRESHOLD, success, exceptionMessage, }); if (!success || !decryptedData) { return { steps, result: { success: false, reason: 'decrypt_data_failed' } }; } // Step 3 - Create a blob from the decrypted data and return it const saveStartTime = Date.now(); const { mime } = fileInfoFromData(decryptedData); if (!mime) { return { steps, result: { success: false, reason: 'mime_check_failed', mime }, }; } let objectURL; try { invariant(mime, 'mime type should be defined'); const decryptedBlob = new Blob([decryptedData], { type: mime }); objectURL = URL.createObjectURL(decryptedBlob); } catch (e) { success = false; exceptionMessage = getMessageForException(e); } steps.push({ step: 'save_blob', objectURL, mimeType: mime, time: Date.now() - saveStartTime, success, exceptionMessage, }); if (!success || !objectURL) { return { steps, result: { success: false, reason: 'save_blob_failed' }, }; } return { steps, result: { success: true, uri: objectURL } }; } export { encryptFile, decryptMedia }; diff --git a/web/media/multimedia-modal.react.js b/web/media/multimedia-modal.react.js index 3ba14074a..0a01e3473 100644 --- a/web/media/multimedia-modal.react.js +++ b/web/media/multimedia-modal.react.js @@ -1,109 +1,112 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { XCircle as XCircleIcon } from 'react-feather'; import { useModalContext } from 'lib/components/modal-provider.react.js'; +import { fetchableMediaURI } from 'lib/media/media-utils.js'; import type { EncryptedMediaType, MediaType } from 'lib/types/media-types.js'; import EncryptedMultimedia from './encrypted-multimedia.react.js'; import css from './media.css'; type MediaInfo = | { +type: MediaType, +uri: string, } | { +type: EncryptedMediaType, +holder: string, +encryptionKey: string, }; type BaseProps = { +media: MediaInfo, }; type Props = { ...BaseProps, +popModal: (modal: ?React.Node) => void, }; class MultimediaModal extends React.PureComponent { overlay: ?HTMLDivElement; componentDidMount() { invariant(this.overlay, 'overlay ref unset'); this.overlay.focus(); } render(): React.Node { let mediaModalItem; const { media } = this.props; if (media.type === 'photo') { - mediaModalItem = ; + const uri = fetchableMediaURI(media.uri); + mediaModalItem = ; } else if (media.type === 'video') { + const uri = fetchableMediaURI(media.uri); mediaModalItem = ( ); } else { invariant( media.type === 'encrypted_photo' || media.type === 'encrypted_video', 'invalid media type', ); const { type, holder, encryptionKey } = media; mediaModalItem = ( ); } return (
{mediaModalItem}
); } overlayRef: (overlay: ?HTMLDivElement) => void = overlay => { this.overlay = overlay; }; onBackgroundClick: (event: SyntheticEvent) => void = event => { if (event.target === this.overlay) { this.props.popModal(); } }; onKeyDown: (event: SyntheticKeyboardEvent) => void = event => { if (event.key === 'Escape') { this.props.popModal(); } }; } function ConnectedMultiMediaModal(props: BaseProps): React.Node { const modalContext = useModalContext(); return ; } export default ConnectedMultiMediaModal; diff --git a/web/media/multimedia.react.js b/web/media/multimedia.react.js index 2e81b0c6a..0c0e099f6 100644 --- a/web/media/multimedia.react.js +++ b/web/media/multimedia.react.js @@ -1,190 +1,193 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import { CircularProgressbar } from 'react-circular-progressbar'; import 'react-circular-progressbar/dist/styles.css'; import { XCircle as XCircleIcon, AlertCircle as AlertCircleIcon, } from 'react-feather'; import { useModalContext, type PushModal, } from 'lib/components/modal-provider.react.js'; +import { fetchableMediaURI } from 'lib/media/media-utils.js'; import type { MediaType, EncryptedMediaType } from 'lib/types/media-types.js'; import EncryptedMultimedia from './encrypted-multimedia.react.js'; import css from './media.css'; import MultimediaModal from './multimedia-modal.react.js'; import Button from '../components/button.react.js'; import { type PendingMultimediaUpload } from '../input/input-state.js'; type MediaSource = | { +type: MediaType, +uri: string, } | { +type: EncryptedMediaType, +holder: string, +encryptionKey: string, }; type BaseProps = { +mediaSource: MediaSource, +pendingUpload?: ?PendingMultimediaUpload, +remove?: (uploadID: string) => void, +multimediaCSSClass: string, +multimediaImageCSSClass: string, }; type Props = { ...BaseProps, +pushModal: PushModal, }; class Multimedia extends React.PureComponent { componentDidUpdate(prevProps: Props) { const { mediaSource, pendingUpload } = this.props; if ( prevProps.mediaSource.type === 'encrypted_photo' || prevProps.mediaSource.type === 'encrypted_video' ) { return; } const prevUri = prevProps.mediaSource?.uri; if (!prevUri || mediaSource.uri === prevUri) { return; } if ( (!pendingUpload || pendingUpload.uriIsReal) && (!prevProps.pendingUpload || !prevProps.pendingUpload.uriIsReal) ) { URL.revokeObjectURL(prevUri); } } render(): React.Node { let progressIndicator, errorIndicator, removeButton; const { pendingUpload, remove, mediaSource, multimediaImageCSSClass, multimediaCSSClass, } = this.props; if (pendingUpload) { const { progressPercent, failed } = pendingUpload; if (progressPercent !== 0 && progressPercent !== 1) { const outOfHundred = Math.floor(progressPercent * 100); const text = `${outOfHundred}%`; progressIndicator = ( ); } if (failed) { errorIndicator = ( ); } if (remove) { removeButton = ( ); } } const imageContainerClasses = [ css.multimediaImage, multimediaImageCSSClass, ]; imageContainerClasses.push(css.clickable); // Media element is the actual image or video element (or encrypted version) let mediaElement; if (mediaSource.type === 'photo') { - mediaElement = ; + const uri = fetchableMediaURI(mediaSource.uri); + mediaElement = ; } else if (mediaSource.type === 'video') { + const uri = fetchableMediaURI(mediaSource.uri); mediaElement = ( ); } else if ( mediaSource.type === 'encrypted_photo' || mediaSource.type === 'encrypted_video' ) { const { ...encryptedMediaProps } = mediaSource; mediaElement = ; } // Media node is the container for the media element (button if photo) let mediaNode; if ( mediaSource.type === 'photo' || mediaSource.type === 'encrypted_photo' ) { mediaNode = ( ); } else { mediaNode = (
{mediaElement}
); } const containerClasses = [css.multimedia, multimediaCSSClass]; return ( {mediaNode} {progressIndicator} {errorIndicator} ); } remove: (event: SyntheticEvent) => void = event => { event.stopPropagation(); const { remove, pendingUpload } = this.props; invariant( remove && pendingUpload, 'Multimedia cannot be removed as either remove or pendingUpload ' + 'are unspecified', ); remove(pendingUpload.localID); }; onClick: () => void = () => { const { pushModal, mediaSource } = this.props; pushModal(); }; } function ConnectedMultimediaContainer(props: BaseProps): React.Node { const modalContext = useModalContext(); return ; } export default ConnectedMultimediaContainer;