diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index 3111e106..a6ecd3a2 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -35,12 +35,14 @@ "react-beautiful-dnd": "^13.1.1", "react-draggable": "^4.4.6", "react-dropzone": "^14.2.9", + "react-error-boundary": "^4.1.2", "react-hotkeys-hook": "^4.5.0", "react-joyride": "^2.9.2", "react-redux": "^9.1.2", "react-transition-group": "^4.4.5", "react-use": "^17.5.1", "rxjs": "^7.8.1", + "swr": "^2.2.5", "tinycolor2": "^1.6.0", "yjs": "^13.6.19" }, diff --git a/packages/react-sdk/src/components/Whiteboard/Element/ConnectedElement.test.tsx b/packages/react-sdk/src/components/Whiteboard/Element/ConnectedElement.test.tsx index 32dac3c5..54c5c3a7 100644 --- a/packages/react-sdk/src/components/Whiteboard/Element/ConnectedElement.test.tsx +++ b/packages/react-sdk/src/components/Whiteboard/Element/ConnectedElement.test.tsx @@ -15,7 +15,7 @@ */ import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { ComponentType, PropsWithChildren } from 'react'; import { MockInstance, @@ -31,6 +31,7 @@ import { mockImageElement, mockWhiteboardManager, } from '../../../lib/testUtils/documentTestUtils'; +import { ImageMimeType } from '../../../state'; import { LayoutStateProvider } from '../../Layout'; import { SlidesProvider } from '../../Layout/SlidesProvider'; import { SvgCanvas } from '../SvgCanvas'; @@ -112,4 +113,123 @@ describe('', () => { screen.queryByTestId('element-element-0-image'), ).not.toBeInTheDocument(); }); + + it('should render an error when an image is not available', async () => { + vi.mocked(URL.createObjectURL).mockReturnValue(''); + + // @ts-ignore ignore readonly prop for tests + widgetApi.widgetParameters.baseUrl = 'https://example.com'; + + render( + , + { wrapper: Wrapper }, + ); + + expect( + screen.getByTestId('element-element-0-skeleton'), + ).toBeInTheDocument(); + + const errorContainer = await screen.findByTestId( + 'element-element-0-error-container', + ); + expect(errorContainer).toBeInTheDocument(); + + const imageElement = screen.queryByTestId('element-element-0-image'); + expect(imageElement).not.toBeInTheDocument(); + + vi.mocked(URL.createObjectURL).mockReset(); + }); + + it.each([ + ['example.gif', 'image/gif'], + ['example.jpeg', 'image/jpeg'], + ['example.png', 'image/png'], + ['example.svg', 'image/svg+xml'], + ] as [string, ImageMimeType][])( + 'should render %s without exploding', + async (fileName, mimeType) => { + widgetApi = mockWidgetApi(); + consoleSpy = vi.spyOn(console, 'error'); + + vi.mocked(URL.createObjectURL).mockReturnValue('http://...'); + + const imageElementMock = mockImageElement(); + imageElementMock.fileName = fileName; + imageElementMock.mimeType = mimeType; + + const { whiteboardManager } = mockWhiteboardManager({ + slides: [['slide-0', [['element-0', imageElementMock]]]], + }); + + Wrapper = ({ children }) => ( + + + + + {children} + + + + + ); + + // @ts-ignore ignore readonly prop for tests + widgetApi.widgetParameters.baseUrl = 'https://example.com'; + + render( + , + { wrapper: Wrapper }, + ); + + expect( + screen.getByTestId('element-element-0-skeleton'), + ).toBeInTheDocument(); + const imageElement = await screen.findByTestId('element-element-0-image'); + expect(imageElement).toBeInTheDocument(); + }, + ); + + it('should render a skeleton until an image is loaded', async () => { + // @ts-ignore ignore readonly prop for tests + widgetApi.widgetParameters.baseUrl = 'https://example.com'; + render( + , + { wrapper: Wrapper }, + ); + + expect( + screen.getByTestId('element-element-0-skeleton'), + ).toBeInTheDocument(); + + const imageElement = await screen.findByTestId('element-element-0-image'); + expect(imageElement).toBeInTheDocument(); + fireEvent.load(imageElement); + + await waitFor(() => { + expect( + screen.queryByTestId('element-element-0-skeleton'), + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/react-sdk/src/components/Whiteboard/Element/ConnectedElement.tsx b/packages/react-sdk/src/components/Whiteboard/Element/ConnectedElement.tsx index d2142991..5a640d0e 100644 --- a/packages/react-sdk/src/components/Whiteboard/Element/ConnectedElement.tsx +++ b/packages/react-sdk/src/components/Whiteboard/Element/ConnectedElement.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import { useWidgetApi } from '@matrix-widget-toolkit/react'; import { Elements } from '../../../state/types'; import { useElementOverride } from '../../ElementOverridesProvider'; import EllipseDisplay from '../../elements/ellipse/Display'; @@ -24,6 +23,14 @@ import PolylineDisplay from '../../elements/polyline/Display'; import RectangleDisplay from '../../elements/rectangle/Display'; import TriangleDisplay from '../../elements/triangle/Display'; +export type OtherProps = { + active: boolean; + readOnly: boolean; + elementId: string; + activeElementIds: string[]; + overrides: Elements; +}; + export const ConnectedElement = ({ id, readOnly = false, @@ -35,13 +42,12 @@ export const ConnectedElement = ({ activeElementIds?: string[]; overrides?: Elements; }) => { - const widgetApi = useWidgetApi(); const element = useElementOverride(id); const isActive = !readOnly && id ? activeElementIds.length === 1 && activeElementIds[0] === id : false; - const otherProps = { + const otherProps: OtherProps = { // TODO: Align names active: isActive, readOnly, @@ -66,18 +72,7 @@ export const ConnectedElement = ({ return ; } } else if (element.type === 'image') { - if (widgetApi.widgetParameters.baseUrl === undefined) { - console.error('Image cannot be rendered due to missing base URL'); - return null; - } - - return ( - - ); + return ; } } diff --git a/packages/react-sdk/src/components/elements/image/ImageDisplay.test.tsx b/packages/react-sdk/src/components/elements/image/ImageDisplay.test.tsx index 19f2037f..b40a0907 100644 --- a/packages/react-sdk/src/components/elements/image/ImageDisplay.test.tsx +++ b/packages/react-sdk/src/components/elements/image/ImageDisplay.test.tsx @@ -15,7 +15,7 @@ */ import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { ComponentType, PropsWithChildren } from 'react'; import { afterEach, @@ -31,7 +31,6 @@ import { mockWhiteboardManager, WhiteboardTestingContextProvider, } from '../../../lib/testUtils/documentTestUtils'; -import { ImageMimeType } from '../../../state'; import { LayoutStateProvider } from '../../Layout'; import { SlidesProvider } from '../../Layout/SlidesProvider'; import { whiteboardHeight, whiteboardWidth } from '../../Whiteboard'; @@ -86,121 +85,25 @@ describe('', () => { fetch.resetMocks(); }); - it.each([ - ['example.gif', 'image/gif'], - ['example.jpeg', 'image/jpeg'], - ['example.png', 'image/png'], - ['example.svg', 'image/svg+xml'], - ] as [string, ImageMimeType][])( - 'should render %s without exploding', - async (fileName, mimeType) => { - render( - , - { wrapper: Wrapper }, - ); - - expect( - screen.getByTestId('element-element-0-skeleton'), - ).toBeInTheDocument(); - const imageElement = await screen.findByTestId('element-element-0-image'); - expect(imageElement).toBeInTheDocument(); - }, - ); - - it('should render a skeleton until an image is loaded', async () => { - render( - , - { wrapper: Wrapper }, - ); - - expect( - screen.getByTestId('element-element-0-skeleton'), - ).toBeInTheDocument(); - - const imageElement = await screen.findByTestId('element-element-0-image'); - expect(imageElement).toBeInTheDocument(); - fireEvent.load(imageElement); - - await waitFor(() => { - expect( - screen.queryByTestId('element-element-0-skeleton'), - ).not.toBeInTheDocument(); - }); - }); - - it('should render an error when an image is not available', async () => { - vi.mocked(URL.createObjectURL).mockReturnValue(''); - - render( - , - { wrapper: Wrapper }, - ); - - expect( - screen.getByTestId('element-element-0-skeleton'), - ).toBeInTheDocument(); - - const errorContainer = await screen.findByTestId( - 'element-element-0-error-container', - ); - expect(errorContainer).toBeInTheDocument(); - - const imageElement = screen.queryByTestId('element-element-0-image'); - expect(imageElement).not.toBeInTheDocument(); - - vi.mocked(URL.createObjectURL).mockReset(); - }); - it('should not have a context menu in read-only mode', () => { render( , { wrapper: Wrapper }, ); diff --git a/packages/react-sdk/src/components/elements/image/ImageDisplay.tsx b/packages/react-sdk/src/components/elements/image/ImageDisplay.tsx index 82c520a7..3f64f762 100644 --- a/packages/react-sdk/src/components/elements/image/ImageDisplay.tsx +++ b/packages/react-sdk/src/components/elements/image/ImageDisplay.tsx @@ -14,10 +14,13 @@ * limitations under the License. */ +import { WidgetApi } from '@matrix-widget-toolkit/api'; import { useWidgetApi } from '@matrix-widget-toolkit/react'; import { styled } from '@mui/material'; -import { IDownloadFileActionFromWidgetResponseData } from 'matrix-widget-api'; -import React, { useCallback, useEffect, useState } from 'react'; +import { getLogger } from 'loglevel'; +import { Suspense, useCallback, useEffect, useState } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import useSWR from 'swr'; import { convertMxcToHttpUrl, WidgetApiActionError } from '../../../lib'; import { ImageElement } from '../../../state'; import { @@ -26,9 +29,106 @@ import { SelectableElement, WithExtendedSelectionProps, } from '../../Whiteboard'; +import { OtherProps } from '../../Whiteboard/Element/ConnectedElement'; import { ImagePlaceholder } from './ImagePlaceholder'; import { Skeleton } from './Skeleton'; +/** + * Download a file from the widget API or fallback to HTTP download. + * + * @returns The data URL of the downloaded file. This can be a blob url or a http url. + */ +const downloadFile = async ({ + widgetApi, + baseUrl, + mxc, + mimeType, +}: { + widgetApi: WidgetApi; + baseUrl: string; + mxc: string; + mimeType: string; +}): Promise => { + try { + const result = await tryDownloadFileWithWidgetApi(widgetApi, mxc); + + // Convert the widget API response to a Blob with given size and type + if (!(result.file instanceof Blob)) { + throw new Error('Got non Blob file response'); + } + const blob = result.file.slice(0, result.file.size, mimeType); + + return blob; + } catch (error) { + const logger = getLogger('ImageDisplay.downloadFile'); + if (error instanceof WidgetApiActionError) { + logger.warn( + 'Widget API downloadFile not available, falling back to HTTP download', + ); + return tryFallbackDownload(mxc, baseUrl, mimeType); + } else { + logger.error('Failed to download image:', error); + throw error; + } + } +}; + +/** + * Try to download a file using the widget API. + * + * @param widgetApi The widget API to use for downloading the file + * @param mxc The mxc URL of the file to download + * @returns The file data as `Promise + * @throws {WidgetApiActionError} If the widget API does not support the downloadFile action + */ +const tryDownloadFileWithWidgetApi = async ( + widgetApi: WidgetApi, + mxc: string, +) => { + try { + const result = await widgetApi.downloadFile(mxc); + return result; + } catch (error) { + const logger = getLogger('ImageDisplay.downloadFile'); + logger.error('Failed to download image from widget api:', error); + throw new WidgetApiActionError('downloadFile not available'); + } +}; + +/** + * Try getting an http url for the given mxc url. + * + * @param mxc The mxc URL of the file to download + * @param baseUrl The base URL of the matrix homeserver + * @param mimeType The MIME type of the file + * @returns The URL of the file. Note this is an blob URL for SVG files. + */ +const tryFallbackDownload = async ( + mxc: string, + baseUrl: string, + mimeType: string, +) => { + const httpUrl = convertMxcToHttpUrl(mxc, baseUrl); + + if (httpUrl === null) { + return ''; + } + + if (mimeType === 'image/svg+xml') { + try { + const response = await fetch(httpUrl); + const rawBlob = await response.blob(); + const svgBlob = rawBlob.slice(0, rawBlob.size, mimeType); + return svgBlob; + } catch (fetchError) { + const logger = getLogger('ImageDisplay.tryFallbackDownload'); + logger.error('Failed to fetch SVG image:', fetchError); + throw new Error('Failed to fetch SVG image'); + } + } + return httpUrl; +}; + type ImageDisplayProps = Omit & WithExtendedSelectionProps & { /** @@ -37,13 +137,12 @@ type ImageDisplayProps = Omit & baseUrl: string; }; -const Image = styled('image', { - shouldForwardProp: (p) => p !== 'loading', -})<{ loading: boolean }>(({ loading }) => ({ +const Image = styled( + 'image', + {}, +)<{}>(() => ({ userSelect: 'none', WebkitUserSelect: 'none', - // prevention of partial rendering if the image has not yet been fully loaded - visibility: loading ? 'hidden' : 'visible', })); /** @@ -64,132 +163,34 @@ function ImageDisplay({ overrides = {}, }: ImageDisplayProps) { const widgetApi = useWidgetApi(); - const [loadError, setLoadError] = useState(false); - const [loading, setLoading] = useState(true); - const [imageUri, setImageUri] = useState(); - - const handleLoad = useCallback(() => { - setLoading(false); - setLoadError(false); - - // This can happen directly when the image is loaded and saves some memory. - if (imageUri) { - URL.revokeObjectURL(imageUri); - } - }, [setLoading, setLoadError, imageUri]); - - const handleLoadError = useCallback(() => { - setLoading(false); - setLoadError(true); - }, [setLoading, setLoadError]); + const [imageUri, setImageUri] = useState(); + const { data: image } = useSWR( + { widgetApi, baseUrl, mxc, mimeType }, + downloadFile, + { suspense: true }, + ); useEffect(() => { - const downloadFile = async () => { - try { - const result = await tryDownloadFileWithWidgetApi(mxc); - const blob = getBlobFromResult(result, mimeType); - const downloadedFileDataUrl = createObjectUrlFromBlob(blob); - setImageUri(downloadedFileDataUrl); - } catch (error) { - handleDownloadError(error as Error); - } - }; - - const tryDownloadFileWithWidgetApi = async (mxc: string) => { - try { - const result = await widgetApi.downloadFile(mxc); - return result; - } catch { - throw new WidgetApiActionError('downloadFile not available'); - } - }; - - const getBlobFromResult = ( - result: IDownloadFileActionFromWidgetResponseData, - mimeType: string, - ): Blob => { - if (!(result.file instanceof Blob)) { - throw new Error('Got non Blob file response'); - } - return result.file.slice(0, result.file.size, mimeType); - }; - - const createObjectUrlFromBlob = (blob: Blob): string => { - const url = URL.createObjectURL(blob); - if (url === '') { + if (image instanceof Blob) { + // Convert blob to blob URL + const downloadedFileDataUrl = URL.createObjectURL(image); + if (downloadedFileDataUrl === '') { throw new Error('Failed to create object URL'); } - return url; - }; - - const handleDownloadError = (error: Error) => { - if (error instanceof WidgetApiActionError) { - tryFallbackDownload(); - } else { - setLoadError(true); - } - }; - - const tryFallbackDownload = async () => { - let abortController: AbortController | undefined; - - const httpUrl = convertMxcToHttpUrl(mxc, baseUrl); - - if (httpUrl === null) { - setImageUri(''); - return; - } - - if (mimeType === 'image/svg+xml') { - abortController = new AbortController(); - try { - const response = await fetch(httpUrl, { - signal: abortController.signal, - }); - const rawBlob = await response.blob(); - const svgBlob = rawBlob.slice(0, rawBlob.size, mimeType); - setImageUri(URL.createObjectURL(svgBlob)); - } catch (fetchError) { - console.error('Failed to fetch SVG image:', fetchError); - setLoadError(true); - } - return; - } - - setImageUri(httpUrl); - - return () => { - if (abortController) { - abortController.abort(); - } - }; - }; - - downloadFile(); - }, [baseUrl, mimeType, mxc, widgetApi]); - - const renderedSkeleton = - loading && !loadError ? ( - - ) : null; + setImageUri(downloadedFileDataUrl); + } else { + setImageUri(image); + } + }, [image, setImageUri]); - const renderedPlaceholder = loadError ? ( - - ) : null; + const onLoaded = useCallback(() => { + if (image instanceof Blob && imageUri) { + URL.revokeObjectURL(imageUri); + } + }, [image, imageUri]); const renderedChild = - imageUri !== undefined && !loadError ? ( + imageUri !== undefined ? ( ) : null; if (readOnly) { - return ( - <> - {renderedSkeleton} {renderedChild} {renderedPlaceholder} - - ); + return <>{renderedChild}; } return ( @@ -221,9 +216,7 @@ function ImageDisplay({ > - {renderedSkeleton} {renderedChild} - {renderedPlaceholder} @@ -231,4 +224,50 @@ function ImageDisplay({ ); } -export default React.memo(ImageDisplay); +function ImageDisplayWrapper({ + element, + otherProps, +}: { + element: ImageElement; + otherProps: OtherProps; +}) { + const widgetApi = useWidgetApi(); + + if (widgetApi.widgetParameters.baseUrl === undefined) { + console.error('Image cannot be rendered due to missing base URL'); + return null; + } + + return ( + + } + > + + } + > + + + + ); +} + +export default ImageDisplayWrapper; diff --git a/yarn.lock b/yarn.lock index 8a5e8c63..7df91eb8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2333,6 +2333,11 @@ cli-truncate@^4.0.0: slice-ansi "^5.0.0" string-width "^7.0.0" +client-only@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" + integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== + cliui@^7.0.2: version "7.0.4" resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" @@ -5211,6 +5216,11 @@ picocolors@^1.0.0, picocolors@^1.1.0: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.0.tgz#5358b76a78cde483ba5cef6a9dc9671440b27d59" integrity sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw== +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" @@ -5248,7 +5258,16 @@ possible-typed-array-names@^1.0.0: resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f" integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q== -postcss@^8.2.14, postcss@^8.4.43, postcss@^8.4.44: +postcss@^8.2.14: + version "8.4.49" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19" + integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA== + dependencies: + nanoid "^3.3.7" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +postcss@^8.4.43, postcss@^8.4.44: version "8.4.47" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.47.tgz#5bf6c9a010f3e724c503bf03ef7947dcb0fea365" integrity sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ== @@ -5391,6 +5410,13 @@ react-error-boundary@^3.1.4: dependencies: "@babel/runtime" "^7.12.5" +react-error-boundary@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-4.1.2.tgz#bc750ad962edb8b135d6ae922c046051eb58f289" + integrity sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag== + dependencies: + "@babel/runtime" "^7.12.5" + react-floater@^0.7.9: version "0.7.9" resolved "https://registry.yarnpkg.com/react-floater/-/react-floater-0.7.9.tgz#b15a652e817f200bfa42a2023ee8d3105803b968" @@ -6270,6 +6296,14 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +swr@^2.2.5: + version "2.2.5" + resolved "https://registry.yarnpkg.com/swr/-/swr-2.2.5.tgz#063eea0e9939f947227d5ca760cc53696f46446b" + integrity sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg== + dependencies: + client-only "^0.0.1" + use-sync-external-store "^1.2.0" + symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" @@ -6624,7 +6658,7 @@ use-memo-one@^1.1.1: resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99" integrity sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ== -use-sync-external-store@^1.0.0: +use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0: version "1.2.2" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9" integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==