diff --git a/web/chat/chat-input-bar.react.js b/web/chat/chat-input-bar.react.js --- a/web/chat/chat-input-bar.react.js +++ b/web/chat/chat-input-bar.react.js @@ -232,24 +232,27 @@ const { pendingUploads, cancelPendingUpload } = this.props.inputState; const multimediaPreviews = pendingUploads.map(pendingUpload => { - let mediaSource; - if ( - pendingUpload.mediaType !== 'encrypted_photo' && - pendingUpload.mediaType !== 'encrypted_video' - ) { + const { uri, mediaType, thumbHash, dimensions } = pendingUpload; + let mediaSource = { thumbHash, dimensions }; + if (mediaType !== 'encrypted_photo' && mediaType !== 'encrypted_video') { mediaSource = { - type: pendingUpload.mediaType, - uri: pendingUpload.uri, + ...mediaSource, + type: mediaType, + uri, + thumbnailURI: null, }; } else { + const { encryptionKey } = pendingUpload; invariant( - pendingUpload.encryptionKey, + encryptionKey, 'encryptionKey should be set for encrypted media', ); mediaSource = { - type: pendingUpload.mediaType, - holder: pendingUpload.uri, - encryptionKey: pendingUpload.encryptionKey, + ...mediaSource, + type: mediaType, + holder: uri, + encryptionKey, + thumbHashEncryptionKey: encryptionKey, }; } return ( diff --git a/web/chat/multimedia-message.react.js b/web/chat/multimedia-message.react.js --- a/web/chat/multimedia-message.react.js +++ b/web/chat/multimedia-message.react.js @@ -39,17 +39,30 @@ const pendingUpload = pendingUploads ? pendingUploads.find(upload => upload.localID === singleMedia.id) : null; + const thumbHash = + singleMedia.thumbHash ?? + singleMedia.thumbnailThumbHash ?? + pendingUpload?.thumbHash; let mediaSource; if (singleMedia.type === 'photo' || singleMedia.type === 'video') { - mediaSource = { - type: singleMedia.type, - uri: singleMedia.uri, - }; + const { type, uri, thumbnailURI, dimensions } = singleMedia; + mediaSource = { type, uri, thumbHash, thumbnailURI, dimensions }; } else { + const { + type, + holder, + encryptionKey, + thumbnailEncryptionKey, + dimensions, + } = singleMedia; + const thumbHashEncryptionKey = thumbnailEncryptionKey ?? encryptionKey; mediaSource = { - type: singleMedia.type, - holder: singleMedia.holder, - encryptionKey: singleMedia.encryptionKey, + type, + holder, + encryptionKey, + dimensions, + thumbHash, + thumbHashEncryptionKey, }; } diff --git a/web/media/multimedia.react.js b/web/media/multimedia.react.js --- a/web/media/multimedia.react.js +++ b/web/media/multimedia.react.js @@ -12,23 +12,38 @@ import { useModalContext } 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 type { + Dimensions, + EncryptedMediaType, + MediaType, +} from 'lib/types/media-types.js'; import EncryptedMultimedia from './encrypted-multimedia.react.js'; +import { preloadImage, usePlaceholder } from './media-utils.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'; +// this should be in sync with the max-height value +// for span.multimedia > multimediaImage in media.css +const MAX_THUMBNAIL_HEIGHT = 200; + type MediaSource = | { +type: MediaType, +uri: string, + +dimensions: ?Dimensions, + +thumbHash: ?string, + +thumbnailURI: ?string, } | { +type: EncryptedMediaType, +holder: string, +encryptionKey: string, + +dimensions: ?Dimensions, + +thumbHash: ?string, + +thumbHashEncryptionKey: ?string, }; type Props = { @@ -134,15 +149,54 @@ const imageContainerClasses = [css.multimediaImage, multimediaImageCSSClass]; imageContainerClasses.push(css.clickable); + const thumbHash = mediaSource.thumbHash ?? pendingUpload?.thumbHash; + const { thumbHashEncryptionKey } = mediaSource; + const placeholderImage = usePlaceholder(thumbHash, thumbHashEncryptionKey); + + const { dimensions } = mediaSource; + const elementStyle = React.useMemo(() => { + if (!dimensions) { + return undefined; + } + const { width, height } = dimensions; + // Resize the image to fit in max width while preserving aspect ratio + const calculatedWidth = + Math.min(MAX_THUMBNAIL_HEIGHT, height) * (width / height); + return { + background: placeholderImage + ? `center / cover url(${placeholderImage})` + : undefined, + width: `${calculatedWidth}px`, + // height is limited by the max-height style in media.css + height: `${height}px`, + }; + }, [dimensions, placeholderImage]); + + const [isVideoLoaded, setVideoLoaded] = React.useState(false); + const handleVideoLoad = React.useCallback(() => setVideoLoaded(true), []); + React.useEffect(() => { + // video thumbnail is used as a poster image when the video is loaded + // preload it so the browser can immediately load it from cache + if (mediaSource.thumbnailURI) { + preloadImage(mediaSource.thumbnailURI); + } + }, [mediaSource.thumbnailURI]); + // Media element is the actual image or video element (or encrypted version) let mediaElement; if (mediaSource.type === 'photo') { const uri = fetchableMediaURI(mediaSource.uri); - mediaElement = ; + mediaElement = ; } else if (mediaSource.type === 'video') { const uri = fetchableMediaURI(mediaSource.uri); + const poster = isVideoLoaded ? mediaSource.thumbnailURI : placeholderImage; mediaElement = ( -