diff --git a/package.json b/package.json index ab85c03ea8..acd1b63c79 100644 --- a/package.json +++ b/package.json @@ -166,6 +166,7 @@ "@wojtekmaj/enzyme-adapter-react-17": "^0.6.1", "allchange": "^1.0.6", "babel-jest": "^26.6.3", + "blob-polyfill": "^6.0.20211015", "chokidar": "^3.5.1", "concurrently": "^5.3.0", "enzyme": "^3.11.0", diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 9a6d4da100..babe4563f7 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -106,15 +106,17 @@ interface IThumbnail { * @param {HTMLElement} element The element to thumbnail. * @param {number} inputWidth The width of the image in the input element. * @param {number} inputHeight the width of the image in the input element. - * @param {String} mimeType The mimeType to save the blob as. + * @param {string} mimeType The mimeType to save the blob as. + * @param {boolean} calculateBlurhash Whether to calculate a blurhash of the given image too. * @return {Promise} A promise that resolves with an object with an info key * and a thumbnail key. */ -async function createThumbnail( +export async function createThumbnail( element: ThumbnailableElement, inputWidth: number, inputHeight: number, mimeType: string, + calculateBlurhash = true, ): Promise { let targetWidth = inputWidth; let targetHeight = inputHeight; @@ -152,7 +154,7 @@ async function createThumbnail( const imageData = context.getImageData(0, 0, targetWidth, targetHeight); // thumbnailPromise and blurhash promise are being awaited concurrently - const blurhash = await BlurhashEncoder.instance.getBlurhash(imageData); + const blurhash = calculateBlurhash ? await BlurhashEncoder.instance.getBlurhash(imageData) : undefined; const thumbnail = await thumbnailPromise; return { diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx index cf379e79da..19e71b72bd 100644 --- a/src/components/views/messages/MImageBody.tsx +++ b/src/components/views/messages/MImageBody.tsx @@ -30,19 +30,25 @@ import SettingsStore from "../../../settings/SettingsStore"; import InlineSpinner from '../elements/InlineSpinner'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { Media, mediaFromContent } from "../../../customisations/Media"; -import { BLURHASH_FIELD } from "../../../ContentMessages"; +import { BLURHASH_FIELD, createThumbnail } from "../../../ContentMessages"; import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent'; import ImageView from '../elements/ImageView'; import { IBodyProps } from "./IBodyProps"; import { ImageSize, suggestedSize as suggestedImageSize } from "../../../settings/enums/ImageSize"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; +import { blobIsAnimated, mayBeAnimated } from '../../../utils/Image'; + +enum Placeholder { + NoImage, + Blurhash, +} interface IState { - decryptedUrl?: string; - decryptedThumbnailUrl?: string; - decryptedBlob?: Blob; - error; + contentUrl?: string; + thumbUrl?: string; + isAnimated?: boolean; + error?: Error; imgError: boolean; imgLoaded: boolean; loadedImageDimensions?: { @@ -51,7 +57,7 @@ interface IState { }; hover: boolean; showImage: boolean; - placeholder: 'no-image' | 'blurhash'; + placeholder: Placeholder; } @replaceableComponent("views.messages.MImageBody") @@ -68,16 +74,11 @@ export default class MImageBody extends React.Component { super(props); this.state = { - decryptedUrl: null, - decryptedThumbnailUrl: null, - decryptedBlob: null, - error: null, imgError: false, imgLoaded: false, - loadedImageDimensions: null, hover: false, showImage: SettingsStore.getValue("showImages"), - placeholder: 'no-image', + placeholder: Placeholder.NoImage, }; } @@ -86,7 +87,7 @@ export default class MImageBody extends React.Component { if (this.unmounted) return; // Consider the client reconnected if there is no error with syncing. // This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP. - const reconnected = syncState !== "ERROR" && prevState !== syncState; + const reconnected = syncState !== SyncState.Error && prevState !== syncState; if (reconnected && this.state.imgError) { // Load the image again this.setState({ @@ -110,7 +111,7 @@ export default class MImageBody extends React.Component { } const content = this.props.mxEvent.getContent(); - const httpUrl = this.getContentUrl(); + const httpUrl = this.state.contentUrl; const params: Omit, "onFinished"> = { src: httpUrl, name: content.body?.length > 0 ? content.body : _t('Attachment'), @@ -139,29 +140,24 @@ export default class MImageBody extends React.Component { } }; - private isGif = (): boolean => { - const content = this.props.mxEvent.getContent(); - return content.info?.mimetype === "image/gif"; - }; - private onImageEnter = (e: React.MouseEvent): void => { this.setState({ hover: true }); - if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifs")) { + if (!this.state.showImage || !this.state.isAnimated || SettingsStore.getValue("autoplayGifs")) { return; } const imgElement = e.currentTarget; - imgElement.src = this.getContentUrl(); + imgElement.src = this.state.contentUrl; }; private onImageLeave = (e: React.MouseEvent): void => { this.setState({ hover: false }); - if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifs")) { + if (!this.state.showImage || !this.state.isAnimated || SettingsStore.getValue("autoplayGifs")) { return; } const imgElement = e.currentTarget; - imgElement.src = this.getThumbUrl(); + imgElement.src = this.state.thumbUrl ?? this.state.contentUrl; }; private onImageError = (): void => { @@ -175,7 +171,7 @@ export default class MImageBody extends React.Component { this.clearBlurhashTimeout(); this.props.onHeightChanged(); - let loadedImageDimensions; + let loadedImageDimensions: IState["loadedImageDimensions"]; if (this.image.current) { const { naturalWidth, naturalHeight } = this.image.current; @@ -185,22 +181,17 @@ export default class MImageBody extends React.Component { this.setState({ imgLoaded: true, loadedImageDimensions }); }; - protected getContentUrl(): string { - const content: IMediaEventContent = this.props.mxEvent.getContent(); + private getContentUrl(): string { // During export, the content url will point to the MSC, which will later point to a local url - if (this.props.forExport) return content.url || content.file?.url; - if (this.media.isEncrypted) { - return this.state.decryptedUrl; - } else { - return this.media.srcHttp; - } + if (this.props.forExport) return this.media.srcMxc; + return this.media.srcHttp; } private get media(): Media { return mediaFromContent(this.props.mxEvent.getContent()); } - protected getThumbUrl(): string { + private getThumbUrl(): string { // FIXME: we let images grow as wide as you like, rather than capped to 800x600. // So either we need to support custom timeline widths here, or reimpose the cap, otherwise the // thumbnail resolution will be unnecessarily reduced. @@ -210,85 +201,112 @@ export default class MImageBody extends React.Component { const content = this.props.mxEvent.getContent(); const media = mediaFromContent(content); + const info = content.info; - if (media.isEncrypted) { - // Don't use the thumbnail for clients wishing to autoplay gifs. - if (this.state.decryptedThumbnailUrl) { - return this.state.decryptedThumbnailUrl; - } - return this.state.decryptedUrl; - } else if (content.info && content.info.mimetype === "image/svg+xml" && media.hasThumbnail) { - // special case to return clientside sender-generated thumbnails for SVGs, if any, - // given we deliberately don't thumbnail them serverside to prevent - // billion lol attacks and similar + if (info?.mimetype === "image/svg+xml" && media.hasThumbnail) { + // Special-case to return clientside sender-generated thumbnails for SVGs, if any, + // given we deliberately don't thumbnail them serverside to prevent billion lol attacks and similar. return media.getThumbnailHttp(thumbWidth, thumbHeight, 'scale'); - } else { - // we try to download the correct resolution - // for hi-res images (like retina screenshots). - // synapse only supports 800x600 thumbnails for now though, - // so we'll need to download the original image for this to work - // well for now. First, let's try a few cases that let us avoid - // downloading the original, including: - // - When displaying a GIF, we always want to thumbnail so that we can - // properly respect the user's GIF autoplay setting (which relies on - // thumbnailing to produce the static preview image) - // - On a low DPI device, always thumbnail to save bandwidth - // - If there's no sizing info in the event, default to thumbnail - const info = content.info; - if ( - this.isGif() || - window.devicePixelRatio === 1.0 || - (!info || !info.w || !info.h || !info.size) - ) { - return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); - } else { - // we should only request thumbnails if the image is bigger than 800x600 - // (or 1600x1200 on retina) otherwise the image in the timeline will just - // end up resampled and de-retina'd for no good reason. - // Ideally the server would pregen 1600x1200 thumbnails in order to provide retina - // thumbnails, but we don't do this currently in synapse for fear of disk space. - // As a compromise, let's switch to non-retina thumbnails only if the original - // image is both physically too large and going to be massive to load in the - // timeline (e.g. >1MB). - - const isLargerThanThumbnail = ( - info.w > thumbWidth || - info.h > thumbHeight - ); - const isLargeFileSize = info.size > 1 * 1024 * 1024; // 1mb - - if (isLargeFileSize && isLargerThanThumbnail) { - // image is too large physically and bytewise to clutter our timeline so - // we ask for a thumbnail, despite knowing that it will be max 800x600 - // despite us being retina (as synapse doesn't do 1600x1200 thumbs yet). - return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); - } else { - // download the original image otherwise, so we can scale it client side - // to take pixelRatio into account. - return media.srcHttp; - } - } } + + // we try to download the correct resolution for hi-res images (like retina screenshots). + // Synapse only supports 800x600 thumbnails for now though, + // so we'll need to download the original image for this to work well for now. + // First, let's try a few cases that let us avoid downloading the original, including: + // - When displaying a GIF, we always want to thumbnail so that we can + // properly respect the user's GIF autoplay setting (which relies on + // thumbnailing to produce the static preview image) + // - On a low DPI device, always thumbnail to save bandwidth + // - If there's no sizing info in the event, default to thumbnail + if ( + this.state.isAnimated || + window.devicePixelRatio === 1.0 || + (!info || !info.w || !info.h || !info.size) + ) { + return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); + } + + // We should only request thumbnails if the image is bigger than 800x600 (or 1600x1200 on retina) otherwise + // the image in the timeline will just end up resampled and de-retina'd for no good reason. + // Ideally the server would pre-gen 1600x1200 thumbnails in order to provide retina thumbnails, + // but we don't do this currently in synapse for fear of disk space. + // As a compromise, let's switch to non-retina thumbnails only if the original image is both + // physically too large and going to be massive to load in the timeline (e.g. >1MB). + + const isLargerThanThumbnail = ( + info.w > thumbWidth || + info.h > thumbHeight + ); + const isLargeFileSize = info.size > 1 * 1024 * 1024; // 1mb + + if (isLargeFileSize && isLargerThanThumbnail) { + // image is too large physically and byte-wise to clutter our timeline so, + // we ask for a thumbnail, despite knowing that it will be max 800x600 + // despite us being retina (as synapse doesn't do 1600x1200 thumbs yet). + return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); + } + + // download the original image otherwise, so we can scale it client side to take pixelRatio into account. + return media.srcHttp; } private async downloadImage() { - if (this.props.mediaEventHelper.media.isEncrypted && this.state.decryptedUrl === null) { + if (this.state.contentUrl) return; // already downloaded + + let thumbUrl: string; + let contentUrl: string; + if (this.props.mediaEventHelper.media.isEncrypted) { try { - const thumbnailUrl = await this.props.mediaEventHelper.thumbnailUrl.value; - this.setState({ - decryptedUrl: await this.props.mediaEventHelper.sourceUrl.value, - decryptedThumbnailUrl: thumbnailUrl, - decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value, - }); - } catch (err) { + ([contentUrl, thumbUrl] = await Promise.all([ + this.props.mediaEventHelper.sourceUrl.value, + this.props.mediaEventHelper.thumbnailUrl.value, + ])); + } catch (error) { if (this.unmounted) return; - logger.warn("Unable to decrypt attachment: ", err); + logger.warn("Unable to decrypt attachment: ", error); // Set a placeholder image when we can't decrypt the image. - this.setState({ - error: err, + this.setState({ error }); + } + } else { + thumbUrl = this.getThumbUrl(); + contentUrl = this.getContentUrl(); + } + + const content = this.props.mxEvent.getContent(); + let isAnimated = mayBeAnimated(content.info?.mimetype); + + // If there is no included non-animated thumbnail then we will generate our own, we can't depend on the server + // because 1. encryption and 2. we can't ask the server specifically for a non-animated thumbnail. + if (isAnimated && !SettingsStore.getValue("autoplayGifs")) { + if (!thumbUrl || !content?.info.thumbnail_info || mayBeAnimated(content.info.thumbnail_info.mimetype)) { + const img = document.createElement("img"); + const loadPromise = new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; }); + img.crossOrigin = "Anonymous"; // CORS allow canvas access + img.src = contentUrl; + + await loadPromise; + + const blob = await this.props.mediaEventHelper.sourceBlob.value; + if (!await blobIsAnimated(content.info.mimetype, blob)) { + isAnimated = false; + } + + if (isAnimated) { + const thumb = await createThumbnail(img, img.width, img.height, content.info.mimetype, false); + thumbUrl = URL.createObjectURL(thumb.thumbnail); + } } } + + if (this.unmounted) return; + this.setState({ + contentUrl, + thumbUrl, + isAnimated, + }); } private clearBlurhashTimeout() { @@ -317,7 +335,7 @@ export default class MImageBody extends React.Component { this.timeout = setTimeout(() => { if (!this.state.imgLoaded || !this.state.imgError) { this.setState({ - placeholder: 'blurhash', + placeholder: Placeholder.Blurhash, }); } }, 150); @@ -333,6 +351,9 @@ export default class MImageBody extends React.Component { MatrixClientPeg.get().removeListener(ClientEvent.Sync, this.onClientSync); this.clearBlurhashTimeout(); SettingsStore.unwatchSetting(this.sizeWatcher); + if (this.state.isAnimated && this.state.thumbUrl) { + URL.revokeObjectURL(this.state.thumbUrl); + } } protected messageContent( @@ -341,10 +362,12 @@ export default class MImageBody extends React.Component { content: IMediaEventContent, forcedHeight?: number, ): JSX.Element { + if (!thumbUrl) thumbUrl = contentUrl; // fallback + let infoWidth: number; let infoHeight: number; - if (content && content.info && content.info.w && content.info.h) { + if (content.info?.w && content.info?.h) { infoWidth = content.info.w; infoHeight = content.info.h; } else { @@ -385,9 +408,9 @@ export default class MImageBody extends React.Component { forcedHeight ?? this.props.maxImageHeight, ); - let img = null; - let placeholder = null; - let gifLabel = null; + let img: JSX.Element; + let placeholder: JSX.Element; + let gifLabel: JSX.Element; if (!this.props.forExport && !this.state.imgLoaded) { placeholder = this.getPlaceholder(maxWidth, maxHeight); @@ -427,7 +450,8 @@ export default class MImageBody extends React.Component { showPlaceholder = false; // because we're hiding the image, so don't show the placeholder. } - if (this.isGif() && !SettingsStore.getValue("autoplayGifs") && !this.state.hover) { + if (this.state.isAnimated && !SettingsStore.getValue("autoplayGifs") && !this.state.hover) { + // XXX: Arguably we may want a different label when the animated image is WEBP and not GIF gifLabel =

GIF

; } @@ -489,9 +513,9 @@ export default class MImageBody extends React.Component { const blurhash = this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]; if (blurhash) { - if (this.state.placeholder === 'no-image') { + if (this.state.placeholder === Placeholder.NoImage) { return
; - } else if (this.state.placeholder === 'blurhash') { + } else if (this.state.placeholder === Placeholder.Blurhash) { return ; } } @@ -510,13 +534,15 @@ export default class MImageBody extends React.Component { if (this.props.forExport) return null; /* * In the room timeline or the thread context we don't need the download - * link as the message action bar will fullfil that + * link as the message action bar will fulfill that */ - const hasMessageActionBar = this.context.timelineRenderingType === TimelineRenderingType.Room - || this.context.timelineRenderingType === TimelineRenderingType.Pinned - || this.context.timelineRenderingType === TimelineRenderingType.Search - || this.context.timelineRenderingType === TimelineRenderingType.Thread - || this.context.timelineRenderingType === TimelineRenderingType.ThreadsList; + const hasMessageActionBar = ( + this.context.timelineRenderingType === TimelineRenderingType.Room || + this.context.timelineRenderingType === TimelineRenderingType.Pinned || + this.context.timelineRenderingType === TimelineRenderingType.Search || + this.context.timelineRenderingType === TimelineRenderingType.Thread || + this.context.timelineRenderingType === TimelineRenderingType.ThreadsList + ); if (!hasMessageActionBar) { return ; } @@ -525,7 +551,7 @@ export default class MImageBody extends React.Component { render() { const content = this.props.mxEvent.getContent(); - if (this.state.error !== null) { + if (this.state.error) { return (
@@ -534,12 +560,12 @@ export default class MImageBody extends React.Component { ); } - const contentUrl = this.getContentUrl(); - let thumbUrl; - if (this.props.forExport || (this.isGif() && SettingsStore.getValue("autoplayGifs"))) { + const contentUrl = this.state.contentUrl; + let thumbUrl: string; + if (this.props.forExport || (this.state.isAnimated && SettingsStore.getValue("autoplayGifs"))) { thumbUrl = contentUrl; } else { - thumbUrl = this.getThumbUrl(); + thumbUrl = this.state.thumbUrl ?? this.state.contentUrl; } const thumbnail = this.messageContent(contentUrl, thumbUrl, content); diff --git a/src/components/views/messages/MImageReplyBody.tsx b/src/components/views/messages/MImageReplyBody.tsx index bb179ba6c0..9edbcec304 100644 --- a/src/components/views/messages/MImageReplyBody.tsx +++ b/src/components/views/messages/MImageReplyBody.tsx @@ -41,14 +41,12 @@ export default class MImageReplyBody extends MImageBody { } render() { - if (this.state.error !== null) { + if (this.state.error) { return super.render(); } const content = this.props.mxEvent.getContent(); - - const contentUrl = this.getContentUrl(); - const thumbnail = this.messageContent(contentUrl, this.getThumbUrl(), content, FORCED_IMAGE_HEIGHT); + const thumbnail = this.messageContent(this.state.contentUrl, this.state.thumbUrl, content, FORCED_IMAGE_HEIGHT); const fileBody = this.getFileBody(); const sender = { + if (mimeType === "image/webp") { + // Only extended file format WEBP images support animation, so grab the expected data range and verify header. + // Based on https://developers.google.com/speed/webp/docs/riff_container#extended_file_format + const arr = await blob.slice(0, 17).arrayBuffer(); + if ( + arrayBufferReadStr(arr, 0, 4) === "RIFF" && + arrayBufferReadStr(arr, 8, 4) === "WEBP" && + arrayBufferReadStr(arr, 12, 4) === "VP8X" + ) { + const [flags] = arrayBufferRead(arr, 16, 1); + // Flags: R R I L E X _A_ R (reversed) + const animationFlagMask = 1 << 1; + return (flags & animationFlagMask) != 0; + } + } else if (mimeType === "image/gif") { + // Based on https://gist.github.com/zakirt/faa4a58cec5a7505b10e3686a226f285 + // More info at http://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp + const dv = new DataView(await blob.arrayBuffer(), 10); + + const globalColorTable = dv.getUint8(0); + let globalColorTableSize = 0; + // check first bit, if 0, then we don't have a Global Color Table + if (globalColorTable & 0x80) { + // grab the last 3 bits, to calculate the global color table size -> RGB * 2^(N+1) + // N is the value in the last 3 bits. + globalColorTableSize = 3 * Math.pow(2, (globalColorTable & 0x7) + 1); + } + + // move on to the Graphics Control Extension + const offset = 3 + globalColorTableSize; + + const extensionIntroducer = dv.getUint8(offset); + const graphicsControlLabel = dv.getUint8(offset + 1); + let delayTime = 0; + + // Graphics Control Extension section is where GIF animation data is stored + // First 2 bytes must be 0x21 and 0xF9 + if ((extensionIntroducer & 0x21) && (graphicsControlLabel & 0xF9)) { + // skip to the 2 bytes with the delay time + delayTime = dv.getUint16(offset + 4); + } + + return !!delayTime; + } + + return false; +} diff --git a/src/utils/blobs.ts b/src/utils/blobs.ts index 209a91f1ae..bf7251b61f 100644 --- a/src/utils/blobs.ts +++ b/src/utils/blobs.ts @@ -52,6 +52,7 @@ const ALLOWED_BLOB_MIMETYPES = [ 'image/jpeg', 'image/gif', 'image/png', + 'image/webp', 'video/mp4', 'video/webm', diff --git a/test/Image-test.ts b/test/Image-test.ts new file mode 100644 index 0000000000..9bd933a3a1 --- /dev/null +++ b/test/Image-test.ts @@ -0,0 +1,61 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import fs from "fs"; +import path from "path"; + +import './skinned-sdk'; +import { blobIsAnimated, mayBeAnimated } from "../src/utils/Image"; + +describe("Image", () => { + describe("mayBeAnimated", () => { + it("image/gif", async () => { + expect(mayBeAnimated("image/gif")).toBeTruthy(); + }); + it("image/webp", async () => { + expect(mayBeAnimated("image/webp")).toBeTruthy(); + }); + it("image/png", async () => { + expect(mayBeAnimated("image/png")).toBeFalsy(); + }); + it("image/jpeg", async () => { + expect(mayBeAnimated("image/jpeg")).toBeFalsy(); + }); + }); + + describe("blobIsAnimated", () => { + const animatedGif = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.gif"))]); + const animatedWebp = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "animated-logo.webp"))]); + const staticGif = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.gif"))]); + const staticWebp = new Blob([fs.readFileSync(path.resolve(__dirname, "images", "static-logo.webp"))]); + + it("Animated GIF", async () => { + expect(await blobIsAnimated("image/gif", animatedGif)).toBeTruthy(); + }); + + it("Static GIF", async () => { + expect(await blobIsAnimated("image/gif", staticGif)).toBeFalsy(); + }); + + it("Animated WEBP", async () => { + expect(await blobIsAnimated("image/webp", animatedWebp)).toBeTruthy(); + }); + + it("Static WEBP", async () => { + expect(await blobIsAnimated("image/webp", staticWebp)).toBeFalsy(); + }); + }); +}); diff --git a/test/images/animated-logo.gif b/test/images/animated-logo.gif new file mode 100644 index 0000000000..e3ee4f6206 Binary files /dev/null and b/test/images/animated-logo.gif differ diff --git a/test/images/animated-logo.webp b/test/images/animated-logo.webp new file mode 100644 index 0000000000..a49c936381 Binary files /dev/null and b/test/images/animated-logo.webp differ diff --git a/test/images/static-logo.gif b/test/images/static-logo.gif new file mode 100644 index 0000000000..8108b87f6f Binary files /dev/null and b/test/images/static-logo.gif differ diff --git a/test/images/static-logo.webp b/test/images/static-logo.webp new file mode 100644 index 0000000000..4101418342 Binary files /dev/null and b/test/images/static-logo.webp differ diff --git a/test/setupTests.js b/test/setupTests.js index 6ce8ad9491..0ff021eaa2 100644 --- a/test/setupTests.js +++ b/test/setupTests.js @@ -1,6 +1,7 @@ import { TextEncoder, TextDecoder } from 'util'; import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { configure } from "enzyme"; +import "blob-polyfill"; // https://github.com/jsdom/jsdom/issues/2555 import * as languageHandler from "../src/languageHandler"; import SdkConfig, { DEFAULTS } from '../src/SdkConfig'; diff --git a/yarn.lock b/yarn.lock index 948b04b7f5..7696ed8f43 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2627,6 +2627,11 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +blob-polyfill@^6.0.20211015: + version "6.0.20211015" + resolved "https://registry.yarnpkg.com/blob-polyfill/-/blob-polyfill-6.0.20211015.tgz#7c47e62347e302e8d1d1ee5e140b881f74bdb23e" + integrity sha512-OGL4bm6ZNpdFAvQugRlQy5MNly8gk15aWi/ZhQHimQsrx9WKD05r+v+xNgHCChLER3MH+9KLAhzuFlwFKrH1Yw== + bluebird@^3.5.0: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"