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 = (
-