From d4072d8aa9c1a058a913baf85098d91ca011b03f Mon Sep 17 00:00:00 2001 From: Mohammad-Mohsen Date: Fri, 24 Jul 2026 10:09:57 +0330 Subject: [PATCH 1/6] Add support for displaying gzipped lottie (e.g. tgs) files --- .changeset/gzipped-lottie.md | 5 + package.json | 1 + pnpm-lock.yaml | 18 +++ src/app/components/editor/Elements.tsx | 3 +- src/app/components/emoji-board/EmojiBoard.tsx | 3 +- .../emoji-board/components/Item.tsx | 5 +- .../emoji-board/components/Preview.tsx | 3 +- .../emoji-board/components/Sidebar.tsx | 7 +- .../components/image-editor/ImageEditor.tsx | 3 +- .../components/image-pack-view/ImageTile.tsx | 7 +- .../image-viewer/ImageViewer.test.tsx | 14 +- .../components/image-viewer/ImageViewer.tsx | 3 +- src/app/components/media/Image.test.tsx | 29 ++++ src/app/components/media/Image.tsx | 141 +++++++++++++++++- .../message-preview/MessagePreview.tsx | 6 +- src/app/components/message/Reaction.tsx | 3 +- .../components/message/ReactionKeyInline.tsx | 3 +- src/app/components/power/PowerIcon.tsx | 3 +- src/app/components/room-card/RoomCard.tsx | 3 +- .../upload-card/UploadCardRenderer.tsx | 3 +- .../components/url-preview/ClientPreview.tsx | 4 +- src/app/components/user-profile/UserHero.tsx | 3 +- .../common-settings/general/RoomProfile.tsx | 6 +- src/app/features/settings/about/About.tsx | 3 +- src/app/features/settings/account/Profile.tsx | 3 +- .../timeline/useTimelineEventRenderer.tsx | 10 +- src/app/pages/auth/AuthLayout.tsx | 3 +- src/app/pages/client/WelcomePage.tsx | 3 +- src/app/pages/client/space/Space.tsx | 5 +- src/app/plugins/react-custom-html-parser.tsx | 5 +- 30 files changed, 266 insertions(+), 42 deletions(-) create mode 100644 .changeset/gzipped-lottie.md create mode 100644 src/app/components/media/Image.test.tsx diff --git a/.changeset/gzipped-lottie.md b/.changeset/gzipped-lottie.md new file mode 100644 index 0000000000..9732f554a3 --- /dev/null +++ b/.changeset/gzipped-lottie.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Add support for displaying gzipped lottie (e.g. tgs) files. diff --git a/package.json b/package.json index ab63531984..5b7ed49784 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@choochmeque/tauri-plugin-sharekit-api": "0.4.0-rc.5", "@fontsource-variable/nunito": "5.2.7", "@fontsource/space-mono": "5.2.9", + "@lottiefiles/dotlottie-react": "^0.12.0", "@noble/hashes": "^2.2.0", "@phosphor-icons/react": "^2.1.10", "@sableclient/twemoji-font": "^1.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f742b21cf..aa52fec46f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ importers: '@fontsource/space-mono': specifier: 5.2.9 version: 5.2.9 + '@lottiefiles/dotlottie-react': + specifier: ^0.12.0 + version: 0.12.3(react@18.3.1) '@noble/hashes': specifier: ^2.2.0 version: 2.2.0 @@ -1613,6 +1616,14 @@ packages: '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} + '@lottiefiles/dotlottie-react@0.12.3': + resolution: {integrity: sha512-b0k0Lakj2hmhyIkwJSpKySh6xoelpgWRaijyrCb6fraOCnzuitKglVTtYs0VWf72rk+2aSmC/0IK8j8wruVfOw==} + peerDependencies: + react: ^17 || ^18 || ^19 + + '@lottiefiles/dotlottie-web@0.40.1': + resolution: {integrity: sha512-iNz1rr8zRTSJ0xCZEPeoCX6YnaNhBdkF7gJWhSa8bwB4NTrYZm0vAuC0jF/aLLhU7q9nf6ykxffrz0Sf4JPSPg==} + '@matrix-org/matrix-sdk-crypto-wasm@18.3.1': resolution: {integrity: sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==} engines: {node: '>= 18'} @@ -7087,6 +7098,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@lottiefiles/dotlottie-react@0.12.3(react@18.3.1)': + dependencies: + '@lottiefiles/dotlottie-web': 0.40.1 + react: 18.3.1 + + '@lottiefiles/dotlottie-web@0.40.1': {} + '@matrix-org/matrix-sdk-crypto-wasm@18.3.1': {} '@napi-rs/canvas-android-arm64@1.0.2': diff --git a/src/app/components/editor/Elements.tsx b/src/app/components/editor/Elements.tsx index d2475b123d..2dccc14702 100644 --- a/src/app/components/editor/Elements.tsx +++ b/src/app/components/editor/Elements.tsx @@ -4,6 +4,7 @@ import { useFocused, useSelected, useSlate } from 'slate-react'; import { useAtomValue } from 'jotai'; import * as css from '$styles/CustomHtml.css'; +import { Image as MediaImage } from '$components/media'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { mxcUrlToHttp } from '$utils/matrix'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; @@ -91,7 +92,7 @@ function RenderEmoticonElement({ contentEditable={false} > {element.key.startsWith('mxc://') ? ( - {element.shortcode} - - {image.body - {image.body {key.startsWith('mxc://') ? ( - {shortcode}({ return ( {url ? ( - {label} + ) : ( - sizedIcon(Image, '200', { filled: active }) + sizedIcon(ImageIcon, '200', { filled: active }) )} ); diff --git a/src/app/components/image-editor/ImageEditor.tsx b/src/app/components/image-editor/ImageEditor.tsx index f55ff66027..48e0c9dc09 100644 --- a/src/app/components/image-editor/ImageEditor.tsx +++ b/src/app/components/image-editor/ImageEditor.tsx @@ -1,6 +1,7 @@ import classNames from 'classnames'; import { Box, Chip, Header, IconButton, Text, as } from 'folds'; import { ArrowLeft, sizedIcon } from '$components/icons/phosphor'; +import { Image as MediaImage } from '$components/media'; import * as css from './ImageEditor.css'; type ImageEditorProps = { @@ -43,7 +44,7 @@ export const ImageEditor = as<'div', ImageEditorProps>( justifyContent="Center" alignItems="Center" > - {name} + ); diff --git a/src/app/components/image-pack-view/ImageTile.tsx b/src/app/components/image-pack-view/ImageTile.tsx index 0f84a3907b..a14dd871d6 100644 --- a/src/app/components/image-pack-view/ImageTile.tsx +++ b/src/app/components/image-pack-view/ImageTile.tsx @@ -11,6 +11,7 @@ import type { TUploadAtom } from '$state/upload'; import { createUploadAtom } from '$state/upload'; import { replaceSpaceWithDash } from '$utils/common'; import { SettingTile } from '$components/setting-tile'; +import { Image as MediaImage } from '$components/media'; import * as css from './style.css'; import { UsageSwitcher, useUsageStr } from './UsageSwitcher'; @@ -40,7 +41,7 @@ export function ImageTile({ return ( createUploadAtom(file), [file]); return ( - }> + }> {children(uploadAtom)} ); @@ -164,7 +165,7 @@ export function ImageTileEdit({ return ( { expect(FileSaver.saveAs).toHaveBeenCalledWith(expect.any(Blob), 'kitten.png'); }); }); + +vi.mock('$components/media', () => ({ + Image: ({ alt, ...props }: ImgHTMLAttributes) => {alt}, +})); + +describe('ImageViewer', () => { + it('renders the fullscreen image without crashing', () => { + render( {}} />); + + expect(screen.getByAltText('demo')).toBeInTheDocument(); + }); +}); diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index df2c519a1d..c450d36c62 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -13,6 +13,7 @@ import { phosphorSizeRem, sizedIcon, } from '$components/icons/phosphor'; +import { Image as MediaImage } from '$components/media'; import { useImageGestures } from '$hooks/useImageGestures'; import { useMenuAnchor } from '$hooks/useMenuAnchor'; import { useDismissOnBack } from '$utils/androidBack'; @@ -319,7 +320,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( onTouchMove={menu.triggerProps.onTouchMove} onTouchCancel={menu.triggerProps.onTouchCancel} > - { + it('renders a regular img for non-lottie sources', () => { + render(demo); + + const image = screen.getByAltText('demo'); + expect(image.tagName).toBe('IMG'); + expect(image).toHaveAttribute('src', 'data:image/png;base64,ZmFrZQ=='); + }); + + it('uses the lottie renderer for gzipped lottie data', async () => { + const gzipped = Buffer.from(gzipSync(lottieJson)).toString('base64'); + render( + + ); + + const rendered = screen.getByLabelText('demo'); + expect(rendered).toBeInTheDocument(); + expect(rendered).toHaveAttribute('height', '123'); + }); +}); diff --git a/src/app/components/media/Image.tsx b/src/app/components/media/Image.tsx index 389bbf810d..abbf0960d2 100644 --- a/src/app/components/media/Image.tsx +++ b/src/app/components/media/Image.tsx @@ -1,6 +1,7 @@ -import type { ImgHTMLAttributes } from 'react'; -import { forwardRef } from 'react'; +import type { ComponentProps, ImgHTMLAttributes } from 'react'; +import { forwardRef, useEffect, useState } from 'react'; import classNames from 'classnames'; +import { DotLottieReact } from '@lottiefiles/dotlottie-react'; import { useSetting } from '$state/hooks/settings'; import { isPixelatedRendering, settingsAtom } from '$state/settings'; import * as css from './media.css'; @@ -8,9 +9,134 @@ import type { IImageInfo } from '$types/matrix/common'; type ImageProps = ImgHTMLAttributes & { info?: IImageInfo }; +type LottieDotProps = Omit, 'src' | 'alt' | 'loading'>; + +/** + * Gzipped files begin with a fixed byte sequence (1f 8b), which distinguis + * them from standard image formats (PNG, WebP, JPG). The implementation detects + * this signature in the file header and automatically decompresses gzipped + * content before parsing it as Lottie JSON. If parsing succeeds, + * the decompressed animation is passed to the Lottie player. + */ +async function resolveLottieDataUrl(src: string): Promise { + const dataUrlMatch = src.match(/^data:([^;,]+)?;base64,(.+)$/i); + if (dataUrlMatch) { + const [, , encoded] = dataUrlMatch; + if (typeof encoded !== 'string' || encoded.length === 0) { + return null; + } + + const bytes = Uint8Array.from(atob(encoded), (char) => char.charCodeAt(0)); + + if (bytes.length < 2 || bytes[0] !== 0x1f || bytes[1] !== 0x8b) { + return null; + } + + try { + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')); + const decompressed = await new Response(stream).arrayBuffer(); + const jsonText = new TextDecoder().decode(decompressed); + const json = JSON.parse(jsonText); + + if (json && typeof json === 'object' && 'v' in json) { + return `data:application/json;charset=utf-8,${encodeURIComponent(jsonText)}`; + } + } catch { + return null; + } + + return null; + } + + try { + const response = await fetch(src, { credentials: 'include' }); + if (!response.ok) { + return null; + } + + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.length < 2 || bytes[0] !== 0x1f || bytes[1] !== 0x8b) { + return null; + } + + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')); + const decompressed = await new Response(stream).arrayBuffer(); + const jsonText = new TextDecoder().decode(decompressed); + const json = JSON.parse(jsonText); + + if (json && typeof json === 'object' && 'v' in json) { + return `data:application/json;charset=utf-8,${encodeURIComponent(jsonText)}`; + } + } catch { + return null; + } + + return null; +} + export const Image = forwardRef( - ({ className, alt, info, ...props }, ref) => { + ( + { + className, + alt, + info, + loading = 'lazy', + onLoad, + onPointerDown, + src, + style, + onError, + ...props + }, + ref + ) => { const [pixelatedImageRendering] = useSetting(settingsAtom, 'pixelatedImageRendering'); + const [resolvedLottieSrc, setResolvedLottieSrc] = useState( + undefined + ); + + const lottieProps = props as LottieDotProps; + + useEffect(() => { + let cancelled = false; + + if (typeof src === 'string' && src.length > 0) { + void resolveLottieDataUrl(src).then((result) => { + if (!cancelled) { + setResolvedLottieSrc(result); + if (onLoad) onLoad({} as React.SyntheticEvent); + } + }); + } else { + setResolvedLottieSrc(null); + } + + return () => { + cancelled = true; + }; + }, [src, onLoad]); + + const shouldRenderLottie = typeof resolvedLottieSrc === 'string'; + + if (shouldRenderLottie) { + return ( + + ); + } return ( ( className )} alt={alt} + loading={loading} + src={src} + style={style} + onLoad={onLoad} + onPointerDown={onPointerDown} + onError={(...e) => { + // Don't send an error until we know whether the image is a Lottie or an ordinary image. + if (resolvedLottieSrc !== undefined && onError) onError(...e); + }} {...props} ref={ref} /> diff --git a/src/app/components/message-preview/MessagePreview.tsx b/src/app/components/message-preview/MessagePreview.tsx index fa97eb4bcd..c65436966e 100644 --- a/src/app/components/message-preview/MessagePreview.tsx +++ b/src/app/components/message-preview/MessagePreview.tsx @@ -23,7 +23,7 @@ import { type RenderImageContentProps, } from '$components/message'; import { UserAvatar } from '$components/user-avatar'; -import { Image } from '$components/media'; +import { Image as MediaImage } from '$components/media'; import { ImageViewer } from '$components/image-viewer'; import { RenderMessageContent } from '$components/RenderMessageContent'; import { PowerIcon } from '$components/power'; @@ -72,8 +72,8 @@ type MessagePreviewRendererContext = MessagePreviewRendererOptions & { mx: ReturnType; }; -function LazyImage(props: ComponentProps) { - return ; +function LazyImage(props: ComponentProps) { + return ; } function resolvePreviewContent( diff --git a/src/app/components/message/Reaction.tsx b/src/app/components/message/Reaction.tsx index 393cba9bbd..48fc8fa2cf 100644 --- a/src/app/components/message/Reaction.tsx +++ b/src/app/components/message/Reaction.tsx @@ -7,6 +7,7 @@ import { getHexcodeForEmoji, getShortcodeFor } from '$plugins/emoji'; import { getMemberDisplayName } from '$utils/room/display'; import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix'; import { useAtomValue } from 'jotai'; +import { Image as MediaImage } from '$components/media'; import { nicknamesAtom } from '$state/nicknames'; import * as css from './Reaction.css'; @@ -44,7 +45,7 @@ export const Reaction = as< ); return ( - {reaction}; + return ; } diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index f900bdcd47..fa2eea5bc1 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -26,6 +26,7 @@ import { CustomStateEvent } from '$types/matrix/room'; import colorMXID from '$utils/colorMXID'; import { reportMediaLoadFailure } from '$utils/mediaLoadDiagnostics'; import { ModalOverlay } from '$components/modal-overlay/ModalOverlay'; +import { Image as MediaImage } from '$components/media'; type GridColumnCount = '1' | '2' | '3'; const getGridColumnCount = (gridWidth: number): GridColumnCount => { @@ -209,7 +210,7 @@ export const RoomCard = as<'div', RoomCardProps>( }} /> ) : ( - {`${name}) { const fileUrl = useObjectURL(originalFile); return ( - (({ videoInfo, embedData }) thumbnail_info: { [MATRIX_UNSTABLE_BLUR_HASH_PROPERTY_NAME]: blurHash }, }} renderThumbnail={() => ( - ( - {`${userId}) { }} > {previewUrl ? ( - ) { - Sable logo>) { }} > {previewUrl ? ( - - + ); } - return ; + return ; }} renderViewer={(p) => } /> @@ -1006,11 +1006,11 @@ export function useTimelineEventRenderer({ if (!autoplayStickers && p.src) { return ( - + ); } - return ; + return ; }} renderViewer={(p) => } /> diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx index ef0b47ac68..642e53316b 100644 --- a/src/app/pages/auth/AuthLayout.tsx +++ b/src/app/pages/auth/AuthLayout.tsx @@ -34,6 +34,7 @@ import { ServerPicker } from './ServerPicker'; import * as css from './styles.css'; import { AuthFooter } from './AuthFooter'; import { usePathWithOrigin } from '$hooks/usePathWithOrigin'; +import { Image as MediaImage } from '$components/media'; const currentAuthPath = (pathname: string): string => { if (matchPath(LOGIN_PATH, pathname)) { @@ -221,7 +222,7 @@ export function AuthLayout() {
- Sable Logo + Sable {isAddingAccount && ( diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 4e62021ea8..4d0b470bc7 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -1,6 +1,7 @@ import { Box, Button, Text, config, toRem } from 'folds'; import { Code, Heart, menuIcon } from '$components/icons/phosphor'; import { Page, PageHero, PageHeroSection } from '$components/page'; +import { Image as MediaImage } from '$components/media'; import LogoSVG from '$public/res/svg/logo.svg'; export function WelcomePage() { @@ -14,7 +15,7 @@ export function WelcomePage() { > } + icon={} title="Welcome to Sable" subTitle={ diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 0d7c167a1b..74a632104a 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -98,6 +98,9 @@ import { ModalWide } from '$styles/Modal.css'; import { ImageViewer } from '$components/image-viewer'; import { reportMediaLoadFailure } from '$utils/mediaLoadDiagnostics'; import * as css from './styles.css'; +import { isResizingSidebarAtom } from '$state/isResizingSidebar'; +import { UserQuickTools } from '../sidebar/UserQuickTools'; +import { Image as MediaImage } from '$components/media'; import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; import { ModalOverlay } from '$components/modal-overlay/ModalOverlay'; import { useOpenRoomSettings } from '$state/hooks/roomSettings'; @@ -377,7 +380,7 @@ function SpaceHeader({ hideText, mx }: { hideText?: boolean; mx: MatrixClient }) } }} > - & { fallback: ReactNode }) { +}: ComponentPropsWithoutRef & { fallback: ReactNode }) { const [failed, setFailed] = useState(false); const renderableSrc = useRenderableMediaUrl(typeof src === 'string' ? src : undefined); if (failed) return <>{fallback}; - return setFailed(true)} />; + return setFailed(true)} />; } export const getReactCustomHtmlParser = ( From 1c85c1b41723bccca65577c88f0830c6df96ce2f Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sat, 25 Jul 2026 23:50:02 -0500 Subject: [PATCH 2/6] cleanup, lazy loading, image viewer --- src/app/components/editor/Elements.tsx | 1 + .../emoji-board/components/Item.tsx | 2 + .../components/image-viewer/ImageViewer.tsx | 10 + src/app/components/media/Image.test.tsx | 72 ++++- src/app/components/media/Image.tsx | 257 ++++++++++++------ .../message-preview/MessagePreview.tsx | 2 +- src/app/components/message/Reaction.css.ts | 1 + .../message/content/ImageContent.tsx | 6 + src/app/components/power/style.css.ts | 1 + .../upload-card/CompactUploadCardRenderer.tsx | 3 +- .../upload-card/UploadCardRenderer.tsx | 5 +- .../common-settings/general/RoomProfile.tsx | 3 +- src/app/features/room/RoomInput.tsx | 10 +- src/app/features/room/msgContent.test.ts | 48 ++++ src/app/features/room/msgContent.ts | 9 +- src/app/features/settings/about/About.tsx | 3 +- .../share-target/ShareTargetFeature.tsx | 4 +- .../timeline/useTimelineEventRenderer.tsx | 8 +- src/app/hooks/useImageGestures.ts | 16 ++ src/app/pages/auth/AuthLayout.tsx | 3 +- src/app/pages/client/WelcomePage.tsx | 3 +- .../plugins/react-custom-html-parser.test.tsx | 1 + src/app/plugins/react-custom-html-parser.tsx | 13 +- src/app/utils/mimeTypes.test.ts | 27 ++ src/app/utils/mimeTypes.ts | 45 ++- vite.config.ts | 6 +- 26 files changed, 442 insertions(+), 117 deletions(-) create mode 100644 src/app/features/room/msgContent.test.ts diff --git a/src/app/components/editor/Elements.tsx b/src/app/components/editor/Elements.tsx index 2dccc14702..2b14ced9d3 100644 --- a/src/app/components/editor/Elements.tsx +++ b/src/app/components/editor/Elements.tsx @@ -94,6 +94,7 @@ function RenderEmoticonElement({ {element.key.startsWith('mxc://') ? ( diff --git a/src/app/components/emoji-board/components/Item.tsx b/src/app/components/emoji-board/components/Item.tsx index 2f24ceeb9f..ab69db393f 100644 --- a/src/app/components/emoji-board/components/Item.tsx +++ b/src/app/components/emoji-board/components/Item.tsx @@ -108,6 +108,7 @@ export function CustomEmojiItem({ loading="lazy" className={css.CustomEmojiImg} alt={image.body || image.shortcode} + mimeType={image.info?.mimetype} src={getPackImageSrc(mx, image, useAuthentication, saveStickerEmojiBandwidth, 32, 32)} /> @@ -144,6 +145,7 @@ export function StickerItem({ loading="lazy" className={css.StickerImg} alt={image.body || image.shortcode} + mimeType={image.info?.mimetype} src={getPackImageSrc(mx, image, useAuthentication, saveStickerEmojiBandwidth, 125, 125)} /> diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index c450d36c62..33e29afe37 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -63,6 +63,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( imageRef, containerRef, handleImageLoad, + handleImageDimensions, enableResizeWithWindow, } = useImageGestures(true, 0.2, 0.1); useEffect(() => { @@ -331,11 +332,20 @@ export const ImageViewer = as<'div', ImageViewerProps>( }} src={src} alt={alt} + info={info} + pixelated={isPixelated} onPointerDown={onPointerDown} onLoad={(event: React.SyntheticEvent) => { handleImageLoad(event); setIsImageReady(true); }} + onLottieLoad={(canvas) => { + handleImageDimensions( + info?.w ?? canvas?.width ?? 0, + info?.h ?? canvas?.height ?? 0 + ); + setIsImageReady(true); + }} /> diff --git a/src/app/components/media/Image.test.tsx b/src/app/components/media/Image.test.tsx index 34ceccd9ba..16023ba3f1 100644 --- a/src/app/components/media/Image.test.tsx +++ b/src/app/components/media/Image.test.tsx @@ -1,29 +1,87 @@ -import { describe, expect, it } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { Image } from './Image'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { Image, sanitizeLottieJson } from './Image'; +import { Blob as NodeBlob } from 'node:buffer'; import { gzipSync } from 'node:zlib'; +import { DecompressionStream as NodeDecompressionStream } from 'node:stream/web'; + +vi.stubGlobal('Blob', NodeBlob); +vi.stubGlobal('DecompressionStream', NodeDecompressionStream); // file from https://github.com/LottieFiles/test-files/blob/main/data/shapes/rectangle.json const lottieJson = '{"v":"5.11.0","fr":60,"ip":0,"op":120,"w":100,"h":50,"nm":"Comp 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[80,30],"ix":2},"p":{"a":0,"k":[50,25],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.196077997544,0.313725998822,0.690195958755,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":120,"st":0,"ct":1,"bm":0}],"markers":[],"props":{}}'; describe('Image', () => { - it('renders a regular img for non-lottie sources', () => { - render(demo); + it('removes executable-looking fields from Lottie JSON', () => { + expect( + sanitizeLottieJson({ + v: '5.11.0', + x: 'this should not run', + expression: 'alert(1)', + layers: [{ script: 'alert(2)', x: 'wiggle()' }], + }) + ).toEqual({ v: '5.11.0', layers: [{}] }); + }); + + it('renders a regular img without probing its source', () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + let loadTarget: EventTarget | null = null; + const onLoad = vi.fn<(event: React.SyntheticEvent) => void>((event) => { + loadTarget = event.currentTarget; + }); + render(demo); const image = screen.getByAltText('demo'); expect(image.tagName).toBe('IMG'); - expect(image).toHaveAttribute('src', 'data:image/png;base64,ZmFrZQ=='); + expect(image).toHaveAttribute('src', 'https://example.com/demo.png'); + expect(fetchSpy).not.toHaveBeenCalled(); + + fireEvent.load(image); + expect(onLoad).toHaveBeenCalledOnce(); + expect(loadTarget).toBe(image); + + fetchSpy.mockRestore(); }); it('uses the lottie renderer for gzipped lottie data', async () => { const gzipped = Buffer.from(gzipSync(lottieJson)).toString('base64'); render( - + ); + await waitFor(() => { + expect(screen.getByLabelText('demo').tagName).toBe('CANVAS'); + }); const rendered = screen.getByLabelText('demo'); expect(rendered).toBeInTheDocument(); + expect(rendered).toHaveAttribute('width', '321'); expect(rendered).toHaveAttribute('height', '123'); }); + + it('forwards a candidate fallback error after detection fails', async () => { + let errorTarget: EventTarget | null = null; + const onError = vi.fn<(event: React.SyntheticEvent) => void>((event) => { + errorTarget = event.currentTarget; + }); + render( + broken + ); + + const image = screen.getByAltText('broken'); + expect(image).not.toHaveAttribute('src'); + + await waitFor(() => { + expect(image).toHaveAttribute('src', 'data:application/gzip;base64,bm90LWd6aXBwZWQ='); + }); + fireEvent.error(image); + + expect(onError).toHaveBeenCalledOnce(); + expect(errorTarget).toBe(image); + }); }); diff --git a/src/app/components/media/Image.tsx b/src/app/components/media/Image.tsx index abbf0960d2..b51f07285a 100644 --- a/src/app/components/media/Image.tsx +++ b/src/app/components/media/Image.tsx @@ -1,77 +1,160 @@ import type { ComponentProps, ImgHTMLAttributes } from 'react'; -import { forwardRef, useEffect, useState } from 'react'; +import { forwardRef, lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; -import { DotLottieReact } from '@lottiefiles/dotlottie-react'; +import type { DotLottieReact as DotLottieReactComponent } from '@lottiefiles/dotlottie-react'; import { useSetting } from '$state/hooks/settings'; import { isPixelatedRendering, settingsAtom } from '$state/settings'; import * as css from './media.css'; import type { IImageInfo } from '$types/matrix/common'; -type ImageProps = ImgHTMLAttributes & { info?: IImageInfo }; +type ImageProps = ImgHTMLAttributes & { + info?: IImageInfo; + mimeType?: string; + disablePixelation?: boolean; + pixelated?: boolean; + onLottieLoad?: (canvas?: HTMLCanvasElement) => void; + onLottieError?: () => void; +}; -type LottieDotProps = Omit, 'src' | 'alt' | 'loading'>; +type LottieDotProps = Omit< + ComponentProps, + 'src' | 'alt' | 'loading' +>; +type DotLottieInstance = Parameters< + NonNullable['dotLottieRefCallback']> +>[0]; -/** - * Gzipped files begin with a fixed byte sequence (1f 8b), which distinguis - * them from standard image formats (PNG, WebP, JPG). The implementation detects - * this signature in the file header and automatically decompresses gzipped - * content before parsing it as Lottie JSON. If parsing succeeds, - * the decompressed animation is passed to the Lottie player. - */ -async function resolveLottieDataUrl(src: string): Promise { - const dataUrlMatch = src.match(/^data:([^;,]+)?;base64,(.+)$/i); - if (dataUrlMatch) { - const [, , encoded] = dataUrlMatch; - if (typeof encoded !== 'string' || encoded.length === 0) { - return null; - } +const DotLottieReact = lazy(() => + import('@lottiefiles/dotlottie-react').then((module) => ({ + default: module.DotLottieReact, + })) +) as typeof DotLottieReactComponent; - const bytes = Uint8Array.from(atob(encoded), (char) => char.charCodeAt(0)); +const GZIPPED_LOTTIE_MIME = /^application\/(?:(?:x-)?gzip|x-tgsticker)(?:;|$)/i; +const UNSAFE_LOTTIE_KEYS = new Set([ + 'expression', + 'expressions', + 'script', + 'scripts', + 'javascript', + 'onload', + 'onclick', +]); - if (bytes.length < 2 || bytes[0] !== 0x1f || bytes[1] !== 0x8b) { - return null; - } +export function sanitizeLottieJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sanitizeLottieJson); + if (!value || typeof value !== 'object') return value; - try { - const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')); - const decompressed = await new Response(stream).arrayBuffer(); - const jsonText = new TextDecoder().decode(decompressed); - const json = JSON.parse(jsonText); + return Object.fromEntries( + Object.entries(value).flatMap(([key, child]) => { + if (UNSAFE_LOTTIE_KEYS.has(key) || (key === 'x' && typeof child === 'string')) return []; + return [[key, sanitizeLottieJson(child)]]; + }) + ); +} - if (json && typeof json === 'object' && 'v' in json) { - return `data:application/json;charset=utf-8,${encodeURIComponent(jsonText)}`; - } - } catch { - return null; - } +function isGzippedLottieCandidate( + src: string | undefined, + mimeType: string | undefined, + name: string | undefined +): src is string { + if (!src) return false; + return ( + GZIPPED_LOTTIE_MIME.test(mimeType ?? '') || + /^data:application\/(?:(?:x-)?gzip|x-tgsticker);base64,/i.test(src) || + [name, src].some((value) => value?.toLowerCase().split(/[?#]/, 1)[0]?.endsWith('.tgs')) + ); +} - return null; +async function loadBytes(src: string): Promise { + const encoded = src.match(/^data:[^;,]*;base64,(.+)$/i)?.[1]; + if (encoded) { + return Uint8Array.from(atob(encoded), (char) => char.charCodeAt(0)); } - try { - const response = await fetch(src, { credentials: 'include' }); - if (!response.ok) { - return null; - } - - const bytes = new Uint8Array(await response.arrayBuffer()); - if (bytes.length < 2 || bytes[0] !== 0x1f || bytes[1] !== 0x8b) { - return null; - } + const response = await fetch(src, { credentials: 'include' }); + return response.ok ? new Uint8Array(await response.arrayBuffer()) : null; +} - const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')); - const decompressed = await new Response(stream).arrayBuffer(); - const jsonText = new TextDecoder().decode(decompressed); - const json = JSON.parse(jsonText); +async function resolveLottieDataUrl(src: string): Promise { + try { + const bytes = await loadBytes(src); + if (!bytes || bytes[0] !== 0x1f || bytes[1] !== 0x8b) return null; - if (json && typeof json === 'object' && 'v' in json) { - return `data:application/json;charset=utf-8,${encodeURIComponent(jsonText)}`; - } + const stream = new Blob([bytes.buffer as ArrayBuffer]) + .stream() + .pipeThrough(new DecompressionStream('gzip')); + const jsonText = await new Response(stream).text(); + const json = sanitizeLottieJson(JSON.parse(jsonText)); + const safeJsonText = JSON.stringify(json); + return json && typeof json === 'object' && 'v' in json && safeJsonText + ? `data:application/json;charset=utf-8,${encodeURIComponent(safeJsonText)}` + : null; } catch { return null; } +} + +type LottieImageProps = LottieDotProps & { + src: string; + alt?: string; + onLottieLoad?: (canvas?: HTMLCanvasElement) => void; + onLottieError?: () => void; + pixelated?: boolean; +}; - return null; +function LottieImage({ + alt, + className, + style, + onLottieLoad, + onLottieError, + pixelated, + ...props +}: Readonly) { + const wrapperRef = useRef(null); + const callbacks = useRef({ onLottieLoad, onLottieError }); + const pixelation = useRef(pixelated); + callbacks.current = { onLottieLoad, onLottieError }; + pixelation.current = pixelated; + useEffect(() => { + const canvas = wrapperRef.current?.querySelector('canvas'); + if (canvas) canvas.style.imageRendering = pixelated ? 'pixelated' : 'auto'; + }, [pixelated]); + const handlePlayer = useCallback((player: DotLottieInstance) => { + if (!player) return; + let didLoad = false; + const handleLoad = () => { + if (didLoad) return; + didLoad = true; + (player.canvas as HTMLCanvasElement).style.imageRendering = pixelation.current + ? 'pixelated' + : 'auto'; + callbacks.current.onLottieLoad?.(player.canvas as HTMLCanvasElement); + }; + const handleError = () => callbacks.current.onLottieError?.(); + + player.addEventListener('load', handleLoad); + player.addEventListener('loadError', handleError); + if (player.isLoaded) handleLoad(); + }, []); + + return ( + +
+ +
+
+ ); } export const Image = forwardRef( @@ -80,58 +163,78 @@ export const Image = forwardRef( className, alt, info, + mimeType, + disablePixelation, loading = 'lazy', onLoad, onPointerDown, src, style, onError, + onLottieLoad, + onLottieError, + pixelated, ...props }, ref ) => { const [pixelatedImageRendering] = useSetting(settingsAtom, 'pixelatedImageRendering'); - const [resolvedLottieSrc, setResolvedLottieSrc] = useState( - undefined - ); + const [lottieResolution, setLottieResolution] = useState<{ + source: string; + resolved: string | null; + }>(); const lottieProps = props as LottieDotProps; + const isLottieCandidate = isGzippedLottieCandidate( + src, + mimeType ?? info?.mimetype, + typeof props.title === 'string' ? props.title : alt + ); + const resolvedLottieSrc = + isLottieCandidate && lottieResolution?.source === src + ? lottieResolution.resolved + : isLottieCandidate + ? undefined + : null; + const imageClass = classNames( + css.Image, + !disablePixelation && + (pixelated ?? isPixelatedRendering(pixelatedImageRendering, info)) && + css.ImagePixelated, + className + ); useEffect(() => { let cancelled = false; - if (typeof src === 'string' && src.length > 0) { + if (isLottieCandidate) { void resolveLottieDataUrl(src).then((result) => { if (!cancelled) { - setResolvedLottieSrc(result); - if (onLoad) onLoad({} as React.SyntheticEvent); + setLottieResolution({ source: src, resolved: result }); } }); - } else { - setResolvedLottieSrc(null); } return () => { cancelled = true; }; - }, [src, onLoad]); + }, [isLottieCandidate, src]); const shouldRenderLottie = typeof resolvedLottieSrc === 'string'; if (shouldRenderLottie) { return ( - @@ -140,21 +243,15 @@ export const Image = forwardRef( return ( {alt} { - // Don't send an error until we know whether the image is a Lottie or an ordinary image. - if (resolvedLottieSrc !== undefined && onError) onError(...e); - }} + onError={onError} {...props} ref={ref} /> diff --git a/src/app/components/message-preview/MessagePreview.tsx b/src/app/components/message-preview/MessagePreview.tsx index c65436966e..34017482a1 100644 --- a/src/app/components/message-preview/MessagePreview.tsx +++ b/src/app/components/message-preview/MessagePreview.tsx @@ -73,7 +73,7 @@ type MessagePreviewRendererContext = MessagePreviewRendererOptions & { }; function LazyImage(props: ComponentProps) { - return ; + return ; } function resolvePreviewContent( diff --git a/src/app/components/message/Reaction.css.ts b/src/app/components/message/Reaction.css.ts index f020080e02..482a0ae614 100644 --- a/src/app/components/message/Reaction.css.ts +++ b/src/app/components/message/Reaction.css.ts @@ -68,6 +68,7 @@ export const ReactionImg = style([ DefaultReset, { height: '1em', + width: 'auto', minWidth: 0, maxWidth: toRem(150), objectFit: 'contain', diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 7d1f2e3ea5..f8d6c4b854 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -84,8 +84,11 @@ type RenderImageProps = { alt: string; title: string; src: string; + info?: IImageInfo; onLoad: () => void; onError: () => void; + onLottieLoad: () => void; + onLottieError: () => void; onClick: () => void; tabIndex: number; }; @@ -314,8 +317,11 @@ export const ImageContent = as<'div', ImageContentProps>( alt: body ?? '', title: body ?? '', src: srcState.data, + info, onLoad: handleLoad, onError: handleError, + onLottieLoad: handleLoad, + onLottieError: handleError, onClick: () => { setIsHovered(false); setViewer(true); diff --git a/src/app/components/power/style.css.ts b/src/app/components/power/style.css.ts index d0ccc88fc5..4da2864bf5 100644 --- a/src/app/components/power/style.css.ts +++ b/src/app/components/power/style.css.ts @@ -37,6 +37,7 @@ export const PowerIcon = recipe({ { display: 'inline-flex', height: PowerIconSize, + width: PowerIconSize, minWidth: PowerIconSize, fontSize: PowerIconSize, lineHeight: PowerIconSize, diff --git a/src/app/components/upload-card/CompactUploadCardRenderer.tsx b/src/app/components/upload-card/CompactUploadCardRenderer.tsx index ecb602ec4f..d32a4da821 100644 --- a/src/app/components/upload-card/CompactUploadCardRenderer.tsx +++ b/src/app/components/upload-card/CompactUploadCardRenderer.tsx @@ -14,6 +14,7 @@ import type { TUploadAtom, UploadSuccess } from '$state/upload'; import { UploadStatus, useBindUploadAtom } from '$state/upload'; import { useMatrixClient } from '$hooks/useMatrixClient'; import type { TUploadContent } from '$utils/matrix'; +import { isImageMimeType } from '$utils/mimeTypes'; import { bytesToSize } from '$utils/common'; import { useMediaConfig } from '$hooks/useMediaConfig'; import { UploadCard, UploadCardError, CompactUploadCardProgress } from './UploadCard'; @@ -22,7 +23,7 @@ function getFileTypeIconComponent(fileType: string): PhosphorIcon { const type = fileType.toLowerCase(); if (type.startsWith('audio')) return Play; if (type.startsWith('video')) return VideoCamera; - if (type.startsWith('image')) return Image; + if (isImageMimeType(type)) return Image; return File; } diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx index 4bb2a964c1..057cf1bfdb 100644 --- a/src/app/components/upload-card/UploadCardRenderer.tsx +++ b/src/app/components/upload-card/UploadCardRenderer.tsx @@ -26,6 +26,7 @@ import type { UploadSuccess } from '$state/upload'; import { UploadStatus, useBindUploadAtom } from '$state/upload'; import { useMatrixClient } from '$hooks/useMatrixClient'; import type { TUploadContent } from '$utils/matrix'; +import { isImageMimeType } from '$utils/mimeTypes'; import { bytesToSize } from '$utils/common'; import type { TUploadItem, TUploadMetadata } from '$state/room/roomInputDrafts'; import { roomUploadAtomFamily } from '$state/room/roomInputDrafts'; @@ -42,7 +43,7 @@ function getFileTypeIconComponent(fileType: string): PhosphorIcon { const type = fileType.toLowerCase(); if (type.startsWith('audio')) return Play; if (type.startsWith('video')) return VideoCamera; - if (type.startsWith('image')) return Image; + if (isImageMimeType(type)) return Image; return File; } @@ -504,7 +505,7 @@ export function UploadCardRenderer({ } bottom={ <> - {fileItem.originalFile.type.startsWith('image') && ( + {isImageMimeType(fileItem.originalFile.type) && ( diff --git a/src/app/features/common-settings/general/RoomProfile.tsx b/src/app/features/common-settings/general/RoomProfile.tsx index 0a18adb7b7..d74a65e70a 100644 --- a/src/app/features/common-settings/general/RoomProfile.tsx +++ b/src/app/features/common-settings/general/RoomProfile.tsx @@ -7,9 +7,8 @@ import Linkify from 'linkify-react'; import classNames from 'classnames'; import type { StateEvents } from '$types/matrix-sdk'; import { JoinRule, EventType } from '$types/matrix-sdk'; -import { SequenceCard } from '$components/sequence-card'; +import { SequenceCard, SequenceCardStyle } from '$components/sequence-card'; import { Image as MediaImage } from '$components/media'; -import { SequenceCardStyle } from '$features/room-settings/styles.css'; import { useRoom } from '$hooks/useRoom'; import { useRoomAvatar, useRoomJoinRule, useRoomName, useRoomTopic } from '$hooks/useRoomMeta'; import { mDirectAtom } from '$state/mDirectList'; diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 5c94528563..6af4955d47 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -106,7 +106,7 @@ import { UploadBoard, UploadBoardContent, UploadBoardHeader } from '$components/ import type { Upload, UploadSuccess } from '$state/upload'; import { UploadStatus, createUploadFamilyObserverAtom } from '$state/upload'; import { loadImageElementFromMediaUrl } from '$utils/dom'; -import { safeFile } from '$utils/mimeTypes'; +import { isImageMimeType, safeUploadFile } from '$utils/mimeTypes'; import { fulfilledPromiseSettledResult } from '$utils/common'; import { useSetting } from '$state/hooks/settings'; import type { EditorButtonId } from '$state/settings'; @@ -427,7 +427,7 @@ export const RoomInput = forwardRef( const handleFiles = useCallback( async (files: File[], audioMeta?: { waveform: number[]; audioDuration: number }) => { setUploadBoard(true); - const safeFiles = files.map(safeFile); + const safeFiles = await Promise.all(files.map(safeUploadFile)); // Eager-read to avoid Android content URI expiry after SAF picker const blobbedFiles = mobileOrTablet() ? await Promise.all( @@ -915,7 +915,7 @@ export const RoomInput = forwardRef( const fileItem = selectedFiles.find((f) => f.file === upload.file); if (!fileItem) throw new Error('Broken upload'); - if (fileItem.file.type.startsWith('image')) { + if (isImageMimeType(fileItem.file.type)) { return getImageMsgContent(mx, fileItem, upload.mxc); } if (fileItem.file.type.startsWith('video')) { @@ -2052,7 +2052,7 @@ export const RoomInput = forwardRef( open={showAttachmentSheet} onClose={() => setShowAttachmentSheet(false)} onPickPhotos={() => { - pickFile('image/*'); + pickFile('image/*,.tgs'); setShowAttachmentSheet(false); }} onPickFile={() => { @@ -2114,7 +2114,7 @@ export const RoomInput = forwardRef( size="300" radii="300" onClick={() => { - pickFile('image/*'); + pickFile('image/*,.tgs'); setAddMenuAnchor(undefined); }} before={menuIcon(ImageIcon)} diff --git a/src/app/features/room/msgContent.test.ts b/src/app/features/room/msgContent.test.ts new file mode 100644 index 0000000000..56da8c69cd --- /dev/null +++ b/src/app/features/room/msgContent.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest'; +import { MsgType, type MatrixClient } from '$types/matrix-sdk'; +import type { TUploadItem } from '$state/room/roomInputDrafts'; +import { TGS_MIMETYPE } from '$utils/mimeTypes'; +import { getGalleryItemContent, getImageMsgContent } from './msgContent'; + +vi.mock('$utils/dom', () => ({ + getImageFileUrl: vi.fn<(file: File | Blob) => string>(() => 'blob:test'), + loadImageElement: vi + .fn<(url: string) => Promise>() + .mockRejectedValue(new Error('TGS is not a native image')), +})); + +const createTgsItem = (): TUploadItem => { + const file = new File(['tgs'], 'sticker.tgs', { type: TGS_MIMETYPE }); + return { + file, + originalFile: file, + encInfo: undefined, + metadata: { markedAsSpoiler: false }, + }; +}; + +describe('TGS message content', () => { + it('sends TGS uploads as images with their MIME metadata', async () => { + const content = await getImageMsgContent({} as MatrixClient, createTgsItem(), 'mxc://sticker'); + + expect(content).toMatchObject({ + msgtype: MsgType.Image, + body: 'sticker.tgs', + url: 'mxc://sticker', + info: { + mimetype: TGS_MIMETYPE, + size: 3, + }, + }); + }); + + it('classifies TGS gallery uploads as images', async () => { + const content = await getGalleryItemContent( + {} as MatrixClient, + createTgsItem(), + 'mxc://sticker' + ); + + expect(content.itemtype).toBe(MsgType.Image); + }); +}); diff --git a/src/app/features/room/msgContent.ts b/src/app/features/room/msgContent.ts index bfdf25aa97..68cde8324e 100644 --- a/src/app/features/room/msgContent.ts +++ b/src/app/features/room/msgContent.ts @@ -19,7 +19,7 @@ import { mxcUrlToHttp, uploadContentToServer, } from '$utils/matrix'; -import { mimeTypeToExt } from '$utils/mimeTypes'; +import { isImageMimeType, mimeTypeToExt } from '$utils/mimeTypes'; import type { TUploadItem } from '$state/room/roomInputDrafts'; import type { GifData } from '$components/emoji-board/types'; import { encodeBlurHashAsync } from '$utils/blurHash'; @@ -84,6 +84,11 @@ export const getImageMsgContent = async ( ...getImageInfo(imgEl, file), [MATRIX_UNSTABLE_BLUR_HASH_PROPERTY_NAME]: blurHash, }; + } else { + content.info = { + mimetype: originalFile.type, + size: originalFile.size, + }; } if (encInfo) { content.file = { @@ -327,7 +332,7 @@ export const getGalleryItemContent = async ( item: TUploadItem, mxc: string ): Promise => { - if (item.file.type.startsWith('image')) { + if (isImageMimeType(item.file.type)) { return swapMsgTypeToItemType(await getImageMsgContent(mx, item, mxc), MsgType.Image); } if (item.file.type.startsWith('video')) { diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index eca683a791..168da69085 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -19,7 +19,6 @@ import { } from '$state/desktopUpdate'; import dayjs from 'dayjs'; import { useAtomValue, useSetAtom } from 'jotai'; -import { Image as MediaImage } from '$components/media'; type VersionResult = | { error: { message: string } } @@ -253,7 +252,7 @@ export function About({ requestBack, requestClose }: Readonly) { - encryptFile(f))); diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index 171db6bc97..31a0498c13 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -859,11 +859,11 @@ export function useTimelineEventRenderer({ if (!autoplayStickers && p.src) { return ( - + ); } - return ; + return ; }} renderViewer={(p) => } /> @@ -1006,11 +1006,11 @@ export function useTimelineEventRenderer({ if (!autoplayStickers && p.src) { return ( - + ); } - return ; + return ; }} renderViewer={(p) => } /> diff --git a/src/app/hooks/useImageGestures.ts b/src/app/hooks/useImageGestures.ts index e9e6e46877..abf745574a 100644 --- a/src/app/hooks/useImageGestures.ts +++ b/src/app/hooks/useImageGestures.ts @@ -277,6 +277,21 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 [updateZoom] ); + const handleImageDimensions = useCallback( + (width: number, height: number) => { + const container = containerRef.current; + if (!container || width <= 0 || height <= 0) return; + + const heightRatio = container.clientHeight / height; + const widthRatio = container.clientWidth / width; + const fitZoom = Math.min(heightRatio, widthRatio, 1); + + setFitRatio(fitZoom); + updateZoom(fitZoom); + }, + [updateZoom] + ); + const zoomIn = useCallback(() => { disableResizeWithWindow(); prepareForTransform(); @@ -360,6 +375,7 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 onPointerDown, handleWheel, handleImageLoad, + handleImageDimensions, setZoom, setZoomSilently, setPan, diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx index 642e53316b..ef0b47ac68 100644 --- a/src/app/pages/auth/AuthLayout.tsx +++ b/src/app/pages/auth/AuthLayout.tsx @@ -34,7 +34,6 @@ import { ServerPicker } from './ServerPicker'; import * as css from './styles.css'; import { AuthFooter } from './AuthFooter'; import { usePathWithOrigin } from '$hooks/usePathWithOrigin'; -import { Image as MediaImage } from '$components/media'; const currentAuthPath = (pathname: string): string => { if (matchPath(LOGIN_PATH, pathname)) { @@ -222,7 +221,7 @@ export function AuthLayout() {
- + Sable Logo Sable {isAddingAccount && ( diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 4d0b470bc7..4e62021ea8 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -1,7 +1,6 @@ import { Box, Button, Text, config, toRem } from 'folds'; import { Code, Heart, menuIcon } from '$components/icons/phosphor'; import { Page, PageHero, PageHeroSection } from '$components/page'; -import { Image as MediaImage } from '$components/media'; import LogoSVG from '$public/res/svg/logo.svg'; export function WelcomePage() { @@ -15,7 +14,7 @@ export function WelcomePage() { > } + icon={Sable Logo} title="Welcome to Sable" subTitle={ diff --git a/src/app/plugins/react-custom-html-parser.test.tsx b/src/app/plugins/react-custom-html-parser.test.tsx index f343522baa..01d1ef3b14 100644 --- a/src/app/plugins/react-custom-html-parser.test.tsx +++ b/src/app/plugins/react-custom-html-parser.test.tsx @@ -149,6 +149,7 @@ describe('react custom html parser', () => { const img = container.querySelector('img'); expect(img).toBeInTheDocument(); expect(img).toHaveAttribute('height', '32'); + expect(img).toHaveStyle({ width: 'auto', height: '1em' }); }); it('clamps incoming inline image height to the configured max', () => { diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index d7b1aaae4b..f63c2858ac 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -560,12 +560,23 @@ export function CodeBlock({ function FallbackImg({ fallback, src, + className, + style, ...props }: ComponentPropsWithoutRef & { fallback: ReactNode }) { const [failed, setFailed] = useState(false); const renderableSrc = useRenderableMediaUrl(typeof src === 'string' ? src : undefined); if (failed) return <>{fallback}; - return setFailed(true)} />; + return ( + setFailed(true)} + onLottieError={() => setFailed(true)} + /> + ); } export const getReactCustomHtmlParser = ( diff --git a/src/app/utils/mimeTypes.test.ts b/src/app/utils/mimeTypes.test.ts index 3b60e711a1..30ca46548b 100644 --- a/src/app/utils/mimeTypes.test.ts +++ b/src/app/utils/mimeTypes.test.ts @@ -1,10 +1,15 @@ import { describe, it, expect } from 'vitest'; +import { gzipSync } from 'node:zlib'; import { getBlobSafeMimeType, + isImageMimeType, + isTgsFile, mimeTypeToExt, getFileNameExt, getFileNameWithoutExt, FALLBACK_MIMETYPE, + safeUploadFile, + TGS_MIMETYPE, } from './mimeTypes'; describe('getBlobSafeMimeType', () => { @@ -41,6 +46,28 @@ describe('getBlobSafeMimeType', () => { }); }); +describe('TGS uploads', () => { + const tgsBytes = Uint8Array.from(gzipSync('{"v":"5.11.0","w":100,"h":100}')); + + it('detects gzipped .tgs files and assigns the sticker MIME type', async () => { + const upload = await safeUploadFile(new File([tgsBytes], 'sticker.tgs')); + + expect(await isTgsFile(upload)).toBe(true); + expect(upload.type).toBe(TGS_MIMETYPE); + expect(isImageMimeType(upload.type)).toBe(true); + }); + + it('does not treat a mislabeled non-gzip file as a TGS image', async () => { + const upload = await safeUploadFile( + new File(['not gzipped'], 'sticker.tgs', { type: TGS_MIMETYPE }) + ); + + expect(await isTgsFile(upload)).toBe(false); + expect(upload.type).toBe(FALLBACK_MIMETYPE); + expect(isImageMimeType(upload.type)).toBe(false); + }); +}); + describe('mimeTypeToExt', () => { it.each([ ['image/jpeg', 'jpeg'], diff --git a/src/app/utils/mimeTypes.ts b/src/app/utils/mimeTypes.ts index 3d97f87d47..9e85d84539 100644 --- a/src/app/utils/mimeTypes.ts +++ b/src/app/utils/mimeTypes.ts @@ -28,6 +28,7 @@ const AUDIO_MIME_TYPES = [ const APPLICATION_MIME_TYPES = [ 'application/pdf', 'application/json', + 'application/x-tgsticker', 'application/x-sh', 'application/ecmascript', 'application/javascript', @@ -97,20 +98,43 @@ export const READABLE_EXT_TO_MIME_TYPE: Record = { sql: 'text/sql', }; -const ALLOWED_BLOB_MIME_TYPES = [ +const ALLOWED_BLOB_MIME_TYPES = new Set([ ...IMAGE_MIME_TYPES, ...VIDEO_MIME_TYPES, ...AUDIO_MIME_TYPES, ...APPLICATION_MIME_TYPES, ...TEXT_MIME_TYPE, -]; +]); export const FALLBACK_MIMETYPE = 'application/octet-stream'; +export const TGS_MIMETYPE = 'application/x-tgsticker'; + +const GZIP_MIME_TYPES = new Set(['application/gzip', 'application/x-gzip', TGS_MIMETYPE]); + +export const isTgsMimeType = (mimeType: string): boolean => + mimeType.split(';', 1)[0]?.toLowerCase() === TGS_MIMETYPE; + +export const isImageMimeType = (mimeType: string): boolean => + mimeType.toLowerCase().startsWith('image/') || isTgsMimeType(mimeType); + +const isTgsCandidate = (file: File): boolean => + file.name.toLowerCase().endsWith('.tgs') || + GZIP_MIME_TYPES.has(file.type.split(';', 1)[0]?.toLowerCase() ?? ''); + +export const isTgsFile = async (file: File): Promise => { + if (!isTgsCandidate(file)) return false; + try { + const signature = new Uint8Array(await file.slice(0, 2).arrayBuffer()); + return signature[0] === 0x1f && signature[1] === 0x8b; + } catch { + return false; + } +}; export const getBlobSafeMimeType = (mimeType: string): string => { if (typeof mimeType !== 'string') return FALLBACK_MIMETYPE; const [type] = mimeType.split(';'); - if (!type || !ALLOWED_BLOB_MIME_TYPES.includes(type)) { + if (!type || !ALLOWED_BLOB_MIME_TYPES.has(type)) { return FALLBACK_MIMETYPE; } // Required for Chromium browsers @@ -128,6 +152,21 @@ export const safeFile = (f: File) => { return f; }; +export const safeUploadFile = async (file: File): Promise => { + if (await isTgsFile(file)) { + return file.type === TGS_MIMETYPE + ? file + : new File([file], file.name, { type: TGS_MIMETYPE, lastModified: file.lastModified }); + } + if (isTgsMimeType(file.type)) { + return new File([file], file.name, { + type: FALLBACK_MIMETYPE, + lastModified: file.lastModified, + }); + } + return safeFile(file); +}; + export const mimeTypeToExt = (mimeType: string): string => { const extStart = mimeType.lastIndexOf('/') + 1; return mimeType.slice(extStart); diff --git a/vite.config.ts b/vite.config.ts index 2e68718d0d..0a87713185 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -200,7 +200,7 @@ export default defineConfig(({ command }) => ({ injectManifest: { // element-call is a self-contained embedded app; exclude its large assets // from the SW precache manifest (they are not part of the Sable shell). - globIgnores: ['public/element-call/**'], + globIgnores: ['public/element-call/**', 'assets/lottie-*.js'], // The app's own crypto WASM and main bundle exceed the 2 MiB default. maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // 10 MiB }, @@ -297,6 +297,10 @@ export default defineConfig(({ command }) => ({ rollupOptions: { plugins: [inject({ Buffer: ['buffer', 'Buffer'] }) as PluginOption], output: { + chunkFileNames: (chunk) => + chunk.moduleIds.some((id) => id.includes('@lottiefiles/dotlottie')) + ? 'assets/lottie-[hash].js' + : 'assets/[name]-[hash].js', manualChunks: (id) => { if (id.includes('@matrix-org') || id.includes('matrix-js-sdk')) return 'matrix'; return undefined; From 0a2072437a62fb83108decc8174c443ad2ffe7a3 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sun, 26 Jul 2026 11:21:26 -0500 Subject: [PATCH 3/6] better detection, fallback, decompression limits --- .../image-viewer/ImageViewer.test.tsx | 46 +++- .../components/image-viewer/ImageViewer.tsx | 1 + src/app/components/media/Image.test.tsx | 79 +++++++ src/app/components/media/Image.tsx | 207 ++++++++++++++---- src/app/hooks/useImageGestures.ts | 12 +- src/app/utils/mimeTypes.test.ts | 9 + src/app/utils/mimeTypes.ts | 5 +- 7 files changed, 306 insertions(+), 53 deletions(-) diff --git a/src/app/components/image-viewer/ImageViewer.test.tsx b/src/app/components/image-viewer/ImageViewer.test.tsx index 494ad229c3..16fe88546d 100644 --- a/src/app/components/image-viewer/ImageViewer.test.tsx +++ b/src/app/components/image-viewer/ImageViewer.test.tsx @@ -1,10 +1,13 @@ -import type { ImgHTMLAttributes, PointerEvent, SyntheticEvent, WheelEvent } from 'react'; +import type { SyntheticEvent, WheelEvent } from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import FileSaver from 'file-saver'; import { ImageViewer } from './ImageViewer'; const downloadMedia = vi.fn<(src: string) => Promise>(); +const gestureMocks = vi.hoisted(() => ({ + onPointerDown: vi.fn<(event: React.PointerEvent) => void>(), +})); vi.mock('$hooks/useImageGestures', () => ({ useImageGestures: () => ({ @@ -14,7 +17,7 @@ vi.mock('$hooks/useImageGestures', () => ({ imageRef: { current: null }, containerRef: { current: null }, handleWheel: vi.fn<(event: WheelEvent) => void>(), - onPointerDown: vi.fn<(event: PointerEvent) => void>(), + onPointerDown: gestureMocks.onPointerDown, handleImageLoad: vi.fn<(event: SyntheticEvent) => void>(), setZoom: vi.fn<(next: number) => void>(), resetTransforms: vi.fn<() => void>(), @@ -61,9 +64,25 @@ describe('ImageViewer', () => { }); }); -vi.mock('$components/media', () => ({ - Image: ({ alt, ...props }: ImgHTMLAttributes) => {alt}, -})); +vi.mock('$components/media', async () => { + const { forwardRef } = await import('react'); + return { + Image: forwardRef< + HTMLImageElement | HTMLCanvasElement, + React.ImgHTMLAttributes & { info?: { mimetype?: string } } + >(({ alt, info, ...props }, ref) => + info?.mimetype === 'application/x-tgsticker' ? ( + )} + ref={ref as React.ForwardedRef} + /> + ) : ( + {alt}} /> + ) + ), + }; +}); describe('ImageViewer', () => { it('renders the fullscreen image without crashing', () => { @@ -71,4 +90,21 @@ describe('ImageViewer', () => { expect(screen.getByAltText('demo')).toBeInTheDocument(); }); + + it('starts viewer gestures from the rendered lottie canvas', () => { + gestureMocks.onPointerDown.mockClear(); + render( + {}} + /> + ); + + const canvas = screen.getByLabelText('animated sticker'); + expect(canvas.tagName).toBe('CANVAS'); + fireEvent.pointerDown(canvas); + expect(gestureMocks.onPointerDown).toHaveBeenCalled(); + }); }); diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 33e29afe37..ac284b5429 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -322,6 +322,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( onTouchCancel={menu.triggerProps.onTouchCancel} > { expect(rendered).toHaveAttribute('height', '123'); }); + it('forwards the canvas ref and pointer events for lottie images', async () => { + const gzipped = Buffer.from(gzipSync(lottieJson)).toString('base64'); + const ref = createRef(); + const onPointerDown = vi.fn<(event: React.PointerEvent) => void>(); + render( + + ); + + await waitFor(() => { + expect(screen.getByLabelText('interactive lottie').tagName).toBe('CANVAS'); + }); + const canvas = screen.getByLabelText('interactive lottie'); + fireEvent.pointerDown(canvas); + + expect(canvas.tagName).toBe('CANVAS'); + await waitFor(() => expect(ref.current).toBe(canvas)); + expect(onPointerDown).toHaveBeenCalledOnce(); + }); + + it('probes an undeclared lottie only after native image decoding fails', async () => { + const bytes = Uint8Array.from(gzipSync(lottieJson)); + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(bytes, { headers: { 'content-length': `${bytes.length}` } })); + render(undeclared lottie); + + const image = screen.getByAltText('undeclared lottie'); + expect(fetchSpy).not.toHaveBeenCalled(); + fireEvent.error(image); + + await waitFor(() => { + expect(screen.getByLabelText('undeclared lottie').tagName).toBe('CANVAS'); + }); + expect(fetchSpy).toHaveBeenCalledOnce(); + fetchSpy.mockRestore(); + }); + + it('aborts an in-flight lottie download when unmounted', async () => { + let requestSignal: AbortSignal | undefined; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { + requestSignal = init?.signal ?? undefined; + return new Promise(() => {}); + }); + const { unmount } = render( + loading lottie + ); + + await waitFor(() => expect(requestSignal).toBeDefined()); + unmount(); + + expect(requestSignal?.aborted).toBe(true); + fetchSpy.mockRestore(); + }); + + it('rejects lottie data that exceeds the decompressed size limit', async () => { + const oversizedJson = JSON.stringify({ + v: '5.11.0', + padding: 'a'.repeat(8 * 1024 * 1024), + }); + const gzipped = Buffer.from(gzipSync(oversizedJson)).toString('base64'); + render( + oversized lottie {}} + /> + ); + + const image = screen.getByAltText('oversized lottie'); + await waitFor(() => expect(image).toHaveAttribute('src')); + expect(image.tagName).toBe('IMG'); + }); + it('forwards a candidate fallback error after detection fails', async () => { let errorTarget: EventTarget | null = null; const onError = vi.fn<(event: React.SyntheticEvent) => void>((event) => { diff --git a/src/app/components/media/Image.tsx b/src/app/components/media/Image.tsx index b51f07285a..059f8dd5fa 100644 --- a/src/app/components/media/Image.tsx +++ b/src/app/components/media/Image.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps, ImgHTMLAttributes } from 'react'; +import type { ComponentProps, ForwardedRef, ImgHTMLAttributes, PointerEventHandler } from 'react'; import { forwardRef, lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import type { DotLottieReact as DotLottieReactComponent } from '@lottiefiles/dotlottie-react'; @@ -7,18 +7,19 @@ import { isPixelatedRendering, settingsAtom } from '$state/settings'; import * as css from './media.css'; import type { IImageInfo } from '$types/matrix/common'; -type ImageProps = ImgHTMLAttributes & { +type ImageProps = Omit, 'onPointerDown'> & { info?: IImageInfo; mimeType?: string; disablePixelation?: boolean; pixelated?: boolean; onLottieLoad?: (canvas?: HTMLCanvasElement) => void; onLottieError?: () => void; + onPointerDown?: PointerEventHandler; }; type LottieDotProps = Omit< ComponentProps, - 'src' | 'alt' | 'loading' + 'src' | 'alt' | 'loading' | 'onPointerDown' >; type DotLottieInstance = Parameters< NonNullable['dotLottieRefCallback']> @@ -31,6 +32,11 @@ const DotLottieReact = lazy(() => ) as typeof DotLottieReactComponent; const GZIPPED_LOTTIE_MIME = /^application\/(?:(?:x-)?gzip|x-tgsticker)(?:;|$)/i; +const MAX_COMPRESSED_LOTTIE_BYTES = 1024 * 1024; +const MAX_DECOMPRESSED_LOTTIE_BYTES = 8 * 1024 * 1024; +const MAX_LOTTIE_DIMENSION = 4096; +const MAX_LOTTIE_FRAMES = 10_000; +const MAX_LOTTIE_LAYERS = 1_000; const UNSAFE_LOTTIE_KEYS = new Set([ 'expression', 'expressions', @@ -66,28 +72,109 @@ function isGzippedLottieCandidate( ); } -async function loadBytes(src: string): Promise { +async function readBytes( + stream: ReadableStream, + limit: number, + signal: AbortSignal +): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + let completed = false; + + try { + while (true) { + if (signal.aborted) throw new DOMException('Aborted', 'AbortError'); + // Stream chunks must be consumed sequentially to enforce the running byte limit. + // eslint-disable-next-line no-await-in-loop + const { done, value } = await reader.read(); + if (done) { + completed = true; + break; + } + size += value.byteLength; + if (size > limit) throw new Error('Lottie data exceeds the size limit'); + chunks.push(value); + } + } finally { + if (!completed) await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } + + const bytes = new Uint8Array(size); + let offset = 0; + chunks.forEach((chunk) => { + bytes.set(chunk, offset); + offset += chunk.byteLength; + }); + return bytes; +} + +async function loadBytes(src: string, signal: AbortSignal): Promise { const encoded = src.match(/^data:[^;,]*;base64,(.+)$/i)?.[1]; if (encoded) { + if (encoded.length > Math.ceil((MAX_COMPRESSED_LOTTIE_BYTES * 4) / 3) + 4) return null; return Uint8Array.from(atob(encoded), (char) => char.charCodeAt(0)); } - const response = await fetch(src, { credentials: 'include' }); - return response.ok ? new Uint8Array(await response.arrayBuffer()) : null; + const response = await fetch(src, { credentials: 'include', signal }); + if (!response.ok || !response.body) return null; + const contentLength = Number(response.headers.get('content-length')); + if (Number.isFinite(contentLength) && contentLength > MAX_COMPRESSED_LOTTIE_BYTES) return null; + return readBytes(response.body, MAX_COMPRESSED_LOTTIE_BYTES, signal); +} + +function hasSafeLottieComplexity(value: unknown): value is Record { + if (!value || typeof value !== 'object' || !('v' in value)) return false; + const lottie = value as Record; + const width = Number(lottie.w); + const height = Number(lottie.h); + const firstFrame = Number(lottie.ip); + const lastFrame = Number(lottie.op); + if ( + (Number.isFinite(width) && (width <= 0 || width > MAX_LOTTIE_DIMENSION)) || + (Number.isFinite(height) && (height <= 0 || height > MAX_LOTTIE_DIMENSION)) || + (Number.isFinite(firstFrame) && + Number.isFinite(lastFrame) && + (lastFrame <= firstFrame || lastFrame - firstFrame > MAX_LOTTIE_FRAMES)) + ) { + return false; + } + + let layerCount = 0; + const pending: unknown[] = [value]; + while (pending.length > 0) { + const current = pending.pop(); + if (!current || typeof current !== 'object') continue; + if (Array.isArray(current)) { + pending.push(...current); + continue; + } + Object.entries(current).forEach(([key, child]) => { + if (key === 'layers' && Array.isArray(child)) { + layerCount += child.length; + } + pending.push(child); + }); + if (layerCount > MAX_LOTTIE_LAYERS) return false; + } + return true; } -async function resolveLottieDataUrl(src: string): Promise { +async function resolveLottieDataUrl(src: string, signal: AbortSignal): Promise { try { - const bytes = await loadBytes(src); + const bytes = await loadBytes(src, signal); if (!bytes || bytes[0] !== 0x1f || bytes[1] !== 0x8b) return null; const stream = new Blob([bytes.buffer as ArrayBuffer]) .stream() .pipeThrough(new DecompressionStream('gzip')); - const jsonText = await new Response(stream).text(); - const json = sanitizeLottieJson(JSON.parse(jsonText)); + const decompressed = await readBytes(stream, MAX_DECOMPRESSED_LOTTIE_BYTES, signal); + const parsed = JSON.parse(new TextDecoder().decode(decompressed)); + if (!hasSafeLottieComplexity(parsed)) return null; + const json = sanitizeLottieJson(parsed); const safeJsonText = JSON.stringify(json); - return json && typeof json === 'object' && 'v' in json && safeJsonText + return safeJsonText ? `data:application/json;charset=utf-8,${encodeURIComponent(safeJsonText)}` : null; } catch { @@ -101,6 +188,8 @@ type LottieImageProps = LottieDotProps & { onLottieLoad?: (canvas?: HTMLCanvasElement) => void; onLottieError?: () => void; pixelated?: boolean; + forwardedRef?: ForwardedRef; + onPointerDown?: PointerEventHandler; }; function LottieImage({ @@ -110,6 +199,8 @@ function LottieImage({ onLottieLoad, onLottieError, pixelated, + forwardedRef, + onPointerDown, ...props }: Readonly) { const wrapperRef = useRef(null); @@ -117,27 +208,52 @@ function LottieImage({ const pixelation = useRef(pixelated); callbacks.current = { onLottieLoad, onLottieError }; pixelation.current = pixelated; + const setForwardedRef = useCallback( + (canvas: HTMLCanvasElement | null) => { + if (typeof forwardedRef === 'function') forwardedRef(canvas); + else if (forwardedRef) forwardedRef.current = canvas; + }, + [forwardedRef] + ); + useEffect(() => { + const updateRef = () => { + const canvas = wrapperRef.current?.querySelector('canvas') ?? null; + if (canvas) setForwardedRef(canvas); + }; + updateRef(); + const observer = new MutationObserver(updateRef); + if (wrapperRef.current) + observer.observe(wrapperRef.current, { childList: true, subtree: true }); + return () => { + observer.disconnect(); + setForwardedRef(null); + }; + }, [setForwardedRef]); useEffect(() => { const canvas = wrapperRef.current?.querySelector('canvas'); if (canvas) canvas.style.imageRendering = pixelated ? 'pixelated' : 'auto'; }, [pixelated]); - const handlePlayer = useCallback((player: DotLottieInstance) => { - if (!player) return; - let didLoad = false; - const handleLoad = () => { - if (didLoad) return; - didLoad = true; - (player.canvas as HTMLCanvasElement).style.imageRendering = pixelation.current - ? 'pixelated' - : 'auto'; - callbacks.current.onLottieLoad?.(player.canvas as HTMLCanvasElement); - }; - const handleError = () => callbacks.current.onLottieError?.(); + const handlePlayer = useCallback( + (player: DotLottieInstance) => { + if (!player) return; + let didLoad = false; + const handleLoad = () => { + if (didLoad) return; + didLoad = true; + (player.canvas as HTMLCanvasElement).style.imageRendering = pixelation.current + ? 'pixelated' + : 'auto'; + callbacks.current.onLottieLoad?.(player.canvas as HTMLCanvasElement); + setForwardedRef(player.canvas as HTMLCanvasElement); + }; + const handleError = () => callbacks.current.onLottieError?.(); - player.addEventListener('load', handleLoad); - player.addEventListener('loadError', handleError); - if (player.isLoaded) handleLoad(); - }, []); + player.addEventListener('load', handleLoad); + player.addEventListener('loadError', handleError); + if (player.isLoaded) handleLoad(); + }, + [setForwardedRef] + ); return ( @@ -145,6 +261,7 @@ function LottieImage({ ref={wrapperRef} className={className} style={{ width: '100%', height: '100%', ...style }} + onPointerDown={onPointerDown} > ( +export const Image = forwardRef( ( { className, @@ -183,16 +300,18 @@ export const Image = forwardRef( source: string; resolved: string | null; }>(); + const [fallbackSource, setFallbackSource] = useState(); const lottieProps = props as LottieDotProps; - const isLottieCandidate = isGzippedLottieCandidate( + const declaredLottieCandidate = isGzippedLottieCandidate( src, mimeType ?? info?.mimetype, typeof props.title === 'string' ? props.title : alt ); + const isLottieCandidate = declaredLottieCandidate || fallbackSource === src; const resolvedLottieSrc = isLottieCandidate && lottieResolution?.source === src - ? lottieResolution.resolved + ? (lottieResolution?.resolved ?? null) : isLottieCandidate ? undefined : null; @@ -205,21 +324,23 @@ export const Image = forwardRef( ); useEffect(() => { - let cancelled = false; + const controller = new AbortController(); - if (isLottieCandidate) { - void resolveLottieDataUrl(src).then((result) => { - if (!cancelled) { + if (isLottieCandidate && src) { + void resolveLottieDataUrl(src, controller.signal).then((result) => { + if (!controller.signal.aborted) { setLottieResolution({ source: src, resolved: result }); } }); } - return () => { - cancelled = true; - }; + return () => controller.abort(); }, [isLottieCandidate, src]); + useEffect(() => { + setFallbackSource(undefined); + }, [src]); + const shouldRenderLottie = typeof resolvedLottieSrc === 'string'; if (shouldRenderLottie) { @@ -232,6 +353,8 @@ export const Image = forwardRef( alt={alt} onLottieLoad={onLottieLoad} onLottieError={onLottieError} + forwardedRef={ref} + onPointerDown={onPointerDown} pixelated={ !disablePixelation && (pixelated ?? isPixelatedRendering(pixelatedImageRendering, info)) } @@ -251,9 +374,15 @@ export const Image = forwardRef( style={style} onLoad={onLoad} onPointerDown={onPointerDown} - onError={onError} + onError={(event) => { + if (!declaredLottieCandidate && fallbackSource !== src) { + setFallbackSource(src); + return; + } + onError?.(event); + }} {...props} - ref={ref} + ref={ref as ForwardedRef} /> ); } diff --git a/src/app/hooks/useImageGestures.ts b/src/app/hooks/useImageGestures.ts index abf745574a..44dd620879 100644 --- a/src/app/hooks/useImageGestures.ts +++ b/src/app/hooks/useImageGestures.ts @@ -33,7 +33,7 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 const shouldResizeWithWindowRef = useRef(true); const [fitRatio, setFitRatio] = useState(1); const containerRef = useRef(null); - const imageRef = useRef(null); + const imageRef = useRef(null); const setShouldResizeWithWindow = useCallback((next: boolean) => { shouldResizeWithWindowRef.current = next; @@ -229,13 +229,15 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 if ( !img || // Image not loaded !shouldResizeWithWindowRef.current || // Resizing disabled - !img.naturalWidth || - !img.naturalHeight // Invalid image dimensions + !(img instanceof HTMLCanvasElement ? img.width : img.naturalWidth) || + !(img instanceof HTMLCanvasElement ? img.height : img.naturalHeight) // Invalid dimensions ) { return; } - const heightRatio = height / img.naturalHeight; - const widthRatio = width / img.naturalWidth; + const imageHeight = img instanceof HTMLCanvasElement ? img.height : img.naturalHeight; + const imageWidth = img instanceof HTMLCanvasElement ? img.width : img.naturalWidth; + const heightRatio = height / imageHeight; + const widthRatio = width / imageWidth; const fitZoom = Math.min(heightRatio, widthRatio); img.style.transition = 'none'; diff --git a/src/app/utils/mimeTypes.test.ts b/src/app/utils/mimeTypes.test.ts index 30ca46548b..599980a4de 100644 --- a/src/app/utils/mimeTypes.test.ts +++ b/src/app/utils/mimeTypes.test.ts @@ -66,6 +66,15 @@ describe('TGS uploads', () => { expect(upload.type).toBe(FALLBACK_MIMETYPE); expect(isImageMimeType(upload.type)).toBe(false); }); + + it('does not treat an ordinary gzip archive as a TGS image', async () => { + const archive = new File([tgsBytes], 'archive.tar.gz', { type: 'application/gzip' }); + const upload = await safeUploadFile(archive); + + expect(await isTgsFile(archive)).toBe(false); + expect(upload.type).toBe(FALLBACK_MIMETYPE); + expect(isImageMimeType(upload.type)).toBe(false); + }); }); describe('mimeTypeToExt', () => { diff --git a/src/app/utils/mimeTypes.ts b/src/app/utils/mimeTypes.ts index 9e85d84539..9aac4a0a39 100644 --- a/src/app/utils/mimeTypes.ts +++ b/src/app/utils/mimeTypes.ts @@ -109,8 +109,6 @@ const ALLOWED_BLOB_MIME_TYPES = new Set([ export const FALLBACK_MIMETYPE = 'application/octet-stream'; export const TGS_MIMETYPE = 'application/x-tgsticker'; -const GZIP_MIME_TYPES = new Set(['application/gzip', 'application/x-gzip', TGS_MIMETYPE]); - export const isTgsMimeType = (mimeType: string): boolean => mimeType.split(';', 1)[0]?.toLowerCase() === TGS_MIMETYPE; @@ -118,8 +116,7 @@ export const isImageMimeType = (mimeType: string): boolean => mimeType.toLowerCase().startsWith('image/') || isTgsMimeType(mimeType); const isTgsCandidate = (file: File): boolean => - file.name.toLowerCase().endsWith('.tgs') || - GZIP_MIME_TYPES.has(file.type.split(';', 1)[0]?.toLowerCase() ?? ''); + file.name.toLowerCase().endsWith('.tgs') || isTgsMimeType(file.type); export const isTgsFile = async (file: File): Promise => { if (!isTgsCandidate(file)) return false; From 8a1589c0cfdaeb572eaf45d4384219db6c068640 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sun, 26 Jul 2026 11:52:48 -0500 Subject: [PATCH 4/6] perf: cache lottie info --- src/app/components/media/Image.test.tsx | 74 +++++++++++++++++ src/app/components/media/Image.tsx | 106 +++++++++++++++++++++++- 2 files changed, 179 insertions(+), 1 deletion(-) diff --git a/src/app/components/media/Image.test.tsx b/src/app/components/media/Image.test.tsx index d3dd236d77..50f8c0ef7d 100644 --- a/src/app/components/media/Image.test.tsx +++ b/src/app/components/media/Image.test.tsx @@ -107,6 +107,80 @@ describe('Image', () => { fetchSpy.mockRestore(); }); + it('forwards one error for an undeclared broken non-lottie image', async () => { + const onError = vi.fn<(event: React.SyntheticEvent) => void>(); + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('not a gzip file')); + render( + undeclared broken image + ); + + const image = screen.getByAltText('undeclared broken image'); + fireEvent.error(image); + expect(image).not.toHaveAttribute('src'); + expect(onError).not.toHaveBeenCalled(); + + await waitFor(() => + expect(image).toHaveAttribute('src', 'https://example.com/undeclared-broken-media') + ); + fireEvent.error(image); + + expect(onError).toHaveBeenCalledOnce(); + expect(fetchSpy).toHaveBeenCalledOnce(); + fetchSpy.mockRestore(); + }); + + it('shares lottie resolution work for repeated media', async () => { + const bytes = Uint8Array.from(gzipSync(lottieJson)); + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(bytes, { headers: { 'content-length': `${bytes.length}` } })); + render( + <> + + + + ); + + await waitFor(() => { + expect(screen.getAllByLabelText('repeated lottie')).toHaveLength(2); + expect( + screen.getAllByLabelText('repeated lottie').every((item) => item.tagName === 'CANVAS') + ).toBe(true); + }); + expect(fetchSpy).toHaveBeenCalledOnce(); + fetchSpy.mockRestore(); + }); + + it('retries lottie resolution after a transient failure', async () => { + const bytes = Uint8Array.from(gzipSync(lottieJson)); + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response('temporary failure')) + .mockResolvedValueOnce( + new Response(bytes, { headers: { 'content-length': `${bytes.length}` } }) + ); + const source = 'https://example.com/transient-sticker.tgs'; + const firstRender = render(); + + await waitFor(() => expect(screen.getByLabelText('transient lottie')).toHaveAttribute('src')); + expect(screen.getByLabelText('transient lottie').tagName).toBe('IMG'); + firstRender.unmount(); + + render(); + await waitFor(() => { + expect(screen.getByLabelText('transient lottie').tagName).toBe('CANVAS'); + }); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + fetchSpy.mockRestore(); + }); + it('aborts an in-flight lottie download when unmounted', async () => { let requestSignal: AbortSignal | undefined; const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { diff --git a/src/app/components/media/Image.tsx b/src/app/components/media/Image.tsx index 059f8dd5fa..8a864c18b3 100644 --- a/src/app/components/media/Image.tsx +++ b/src/app/components/media/Image.tsx @@ -37,6 +37,9 @@ const MAX_DECOMPRESSED_LOTTIE_BYTES = 8 * 1024 * 1024; const MAX_LOTTIE_DIMENSION = 4096; const MAX_LOTTIE_FRAMES = 10_000; const MAX_LOTTIE_LAYERS = 1_000; +const LOTTIE_PROBE_TIMEOUT_MS = 5_000; +const MAX_LOTTIE_CACHE_ENTRIES = 32; +const MAX_CACHED_LOTTIE_DATA_URL_LENGTH = 2 * 1024 * 1024; const UNSAFE_LOTTIE_KEYS = new Set([ 'expression', 'expressions', @@ -182,6 +185,107 @@ async function resolveLottieDataUrl(src: string, signal: AbortSignal): Promise; +}; + +const lottieResolutionCache = new Map(); + +function trimLottieResolutionCache(): void { + let scanned = 0; + + while ( + lottieResolutionCache.size > MAX_LOTTIE_CACHE_ENTRIES && + scanned < lottieResolutionCache.size + ) { + const oldest = lottieResolutionCache.entries().next().value as + | [string, LottieResolutionEntry] + | undefined; + if (!oldest) return; + const [source, entry] = oldest; + + if (entry.settled && entry.consumers === 0) { + lottieResolutionCache.delete(source); + scanned = 0; + continue; + } + + lottieResolutionCache.delete(source); + lottieResolutionCache.set(source, entry); + scanned += 1; + } +} + +function createLottieResolutionEntry(src: string): LottieResolutionEntry { + const controller = new AbortController(); + const entry = { + controller, + consumers: 0, + settled: false, + } as LottieResolutionEntry; + const timeout = window.setTimeout(() => controller.abort(), LOTTIE_PROBE_TIMEOUT_MS); + + entry.promise = resolveLottieDataUrl(src, controller.signal).then((result) => { + entry.settled = true; + window.clearTimeout(timeout); + if ( + controller.signal.aborted || + result === null || + result.length > MAX_CACHED_LOTTIE_DATA_URL_LENGTH + ) { + if (lottieResolutionCache.get(src) === entry) lottieResolutionCache.delete(src); + } else { + trimLottieResolutionCache(); + } + return result; + }); + lottieResolutionCache.set(src, entry); + return entry; +} + +function resolveCachedLottieDataUrl(src: string, signal: AbortSignal): Promise { + let entry = lottieResolutionCache.get(src); + if (!entry) { + entry = createLottieResolutionEntry(src); + } else { + lottieResolutionCache.delete(src); + lottieResolutionCache.set(src, entry); + } + entry.consumers += 1; + + return new Promise((resolve) => { + let released = false; + const release = () => { + if (released) return; + released = true; + signal.removeEventListener('abort', handleAbort); + entry.consumers -= 1; + if (entry.consumers === 0) { + if (!entry.settled) { + entry.controller.abort(); + if (lottieResolutionCache.get(src) === entry) lottieResolutionCache.delete(src); + } else { + trimLottieResolutionCache(); + } + } + }; + const handleAbort = () => { + release(); + resolve(null); + }; + + signal.addEventListener('abort', handleAbort, { once: true }); + entry.promise.then((result) => { + release(); + resolve(result); + }); + if (signal.aborted) handleAbort(); + }); +} + type LottieImageProps = LottieDotProps & { src: string; alt?: string; @@ -327,7 +431,7 @@ export const Image = forwardRef { + void resolveCachedLottieDataUrl(src, controller.signal).then((result) => { if (!controller.signal.aborted) { setLottieResolution({ source: src, resolved: result }); } From 380ade185bdd3cd1fc883a0beb523b6103dcf5ed Mon Sep 17 00:00:00 2001 From: 7w1 Date: Sun, 26 Jul 2026 12:14:39 -0500 Subject: [PATCH 5/6] simplify some things --- src/app/components/media/Image.test.tsx | 21 +- src/app/components/media/Image.tsx | 372 ++++++++++++------------ 2 files changed, 192 insertions(+), 201 deletions(-) diff --git a/src/app/components/media/Image.test.tsx b/src/app/components/media/Image.test.tsx index 50f8c0ef7d..b7f32a6622 100644 --- a/src/app/components/media/Image.test.tsx +++ b/src/app/components/media/Image.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { createRef } from 'react'; -import { Image, sanitizeLottieJson } from './Image'; +import { Image, processAndSanitizeLottie } from './Image'; import { Blob as NodeBlob } from 'node:buffer'; import { gzipSync } from 'node:zlib'; import { DecompressionStream as NodeDecompressionStream } from 'node:stream/web'; @@ -15,14 +15,13 @@ const lottieJson = describe('Image', () => { it('removes executable-looking fields from Lottie JSON', () => { - expect( - sanitizeLottieJson({ - v: '5.11.0', - x: 'this should not run', - expression: 'alert(1)', - layers: [{ script: 'alert(2)', x: 'wiggle()' }], - }) - ).toEqual({ v: '5.11.0', layers: [{}] }); + const processed = processAndSanitizeLottie({ + v: '5.11.0', + x: 'this should not run', + expression: 'alert(1)', + layers: [{ script: 'alert(2)', x: 'wiggle()' }], + }); + expect(processed ? JSON.parse(processed) : null).toEqual({ v: '5.11.0', layers: [{}] }); }); it('renders a regular img without probing its source', () => { @@ -181,7 +180,7 @@ describe('Image', () => { fetchSpy.mockRestore(); }); - it('aborts an in-flight lottie download when unmounted', async () => { + it('bounds in-flight lottie downloads with a timeout signal', async () => { let requestSignal: AbortSignal | undefined; const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => { requestSignal = init?.signal ?? undefined; @@ -192,9 +191,9 @@ describe('Image', () => { ); await waitFor(() => expect(requestSignal).toBeDefined()); + expect(requestSignal?.aborted).toBe(false); unmount(); - expect(requestSignal?.aborted).toBe(true); fetchSpy.mockRestore(); }); diff --git a/src/app/components/media/Image.tsx b/src/app/components/media/Image.tsx index 8a864c18b3..e9e39634d7 100644 --- a/src/app/components/media/Image.tsx +++ b/src/app/components/media/Image.tsx @@ -19,7 +19,7 @@ type ImageProps = Omit, 'onPointerDown'> & { type LottieDotProps = Omit< ComponentProps, - 'src' | 'alt' | 'loading' | 'onPointerDown' + 'src' | 'data' | 'alt' | 'loading' | 'onPointerDown' >; type DotLottieInstance = Parameters< NonNullable['dotLottieRefCallback']> @@ -37,9 +37,11 @@ const MAX_DECOMPRESSED_LOTTIE_BYTES = 8 * 1024 * 1024; const MAX_LOTTIE_DIMENSION = 4096; const MAX_LOTTIE_FRAMES = 10_000; const MAX_LOTTIE_LAYERS = 1_000; +const MAX_LOTTIE_NODES = 50_000; +const MAX_LOTTIE_DEPTH = 64; const LOTTIE_PROBE_TIMEOUT_MS = 5_000; const MAX_LOTTIE_CACHE_ENTRIES = 32; -const MAX_CACHED_LOTTIE_DATA_URL_LENGTH = 2 * 1024 * 1024; +const MAX_CACHED_LOTTIE_JSON_LENGTH = 2 * 1024 * 1024; const UNSAFE_LOTTIE_KEYS = new Set([ 'expression', 'expressions', @@ -50,16 +52,97 @@ const UNSAFE_LOTTIE_KEYS = new Set([ 'onclick', ]); -export function sanitizeLottieJson(value: unknown): unknown { - if (Array.isArray(value)) return value.map(sanitizeLottieJson); - if (!value || typeof value !== 'object') return value; +export function processAndSanitizeLottie(root: unknown): string | null { + if (!root || typeof root !== 'object' || Array.isArray(root)) return null; + const lottie = root as Record; - return Object.fromEntries( - Object.entries(value).flatMap(([key, child]) => { - if (UNSAFE_LOTTIE_KEYS.has(key) || (key === 'x' && typeof child === 'string')) return []; - return [[key, sanitizeLottieJson(child)]]; - }) - ); + if (!('v' in lottie)) return null; + const width = Number(lottie.w); + const height = Number(lottie.h); + const firstFrame = Number(lottie.ip); + const lastFrame = Number(lottie.op); + if ( + (Number.isFinite(width) && (width <= 0 || width > MAX_LOTTIE_DIMENSION)) || + (Number.isFinite(height) && (height <= 0 || height > MAX_LOTTIE_DIMENSION)) || + (Number.isFinite(firstFrame) && + Number.isFinite(lastFrame) && + (lastFrame <= firstFrame || lastFrame - firstFrame > MAX_LOTTIE_FRAMES)) + ) { + return null; + } + + let totalNodes = 0; + let layerCount = 0; + + const rootClone = Object.create(null) as Record; + const stack: { + parent: Record | unknown[]; + key: string | number; + value: unknown; + depth: number; + }[] = [{ parent: rootClone, key: 'root', value: root, depth: 0 }]; + + const canEnqueue = (count: number): boolean => + totalNodes + stack.length + count <= MAX_LOTTIE_NODES; + + while (stack.length > 0) { + const item = stack.pop(); + if (!item) continue; + const { parent, key, value, depth } = item; + + totalNodes++; + if (totalNodes > MAX_LOTTIE_NODES || depth > MAX_LOTTIE_DEPTH) return null; + + if (Array.isArray(value)) { + if (!canEnqueue(value.length)) return null; + + const newArray: unknown[] = []; + if (Array.isArray(parent)) { + parent[key as number] = newArray; + } else { + (parent as Record)[key as string] = newArray; + } + for (let i = value.length - 1; i >= 0; i--) { + stack.push({ parent: newArray, key: i, value: value[i], depth: depth + 1 }); + } + } else if (value && typeof value === 'object') { + const entries = Object.entries(value as Record).filter( + ([k, child]) => + !UNSAFE_LOTTIE_KEYS.has(k) && + !(k === 'x' && typeof child === 'string') + ); + + for (const [k, child] of entries) { + if (k === 'layers' && Array.isArray(child)) { + layerCount += child.length; + if (layerCount > MAX_LOTTIE_LAYERS) return null; + } + } + + if (!canEnqueue(entries.length)) return null; + + const newObj = Object.create(null) as Record; + if (Array.isArray(parent)) { + parent[key as number] = newObj; + } else { + (parent as Record)[key as string] = newObj; + } + + for (let i = entries.length - 1; i >= 0; i--) { + const [k, child] = entries[i]; + stack.push({ parent: newObj, key: k, value: child, depth: depth + 1 }); + } + } else { + if (Array.isArray(parent)) { + parent[key as number] = value; + } else { + (parent as Record)[key as string] = value; + } + } + } + + const sanitized = rootClone.root; + return JSON.stringify(sanitized); } function isGzippedLottieCandidate( @@ -127,44 +210,7 @@ async function loadBytes(src: string, signal: AbortSignal): Promise { - if (!value || typeof value !== 'object' || !('v' in value)) return false; - const lottie = value as Record; - const width = Number(lottie.w); - const height = Number(lottie.h); - const firstFrame = Number(lottie.ip); - const lastFrame = Number(lottie.op); - if ( - (Number.isFinite(width) && (width <= 0 || width > MAX_LOTTIE_DIMENSION)) || - (Number.isFinite(height) && (height <= 0 || height > MAX_LOTTIE_DIMENSION)) || - (Number.isFinite(firstFrame) && - Number.isFinite(lastFrame) && - (lastFrame <= firstFrame || lastFrame - firstFrame > MAX_LOTTIE_FRAMES)) - ) { - return false; - } - - let layerCount = 0; - const pending: unknown[] = [value]; - while (pending.length > 0) { - const current = pending.pop(); - if (!current || typeof current !== 'object') continue; - if (Array.isArray(current)) { - pending.push(...current); - continue; - } - Object.entries(current).forEach(([key, child]) => { - if (key === 'layers' && Array.isArray(child)) { - layerCount += child.length; - } - pending.push(child); - }); - if (layerCount > MAX_LOTTIE_LAYERS) return false; - } - return true; -} - -async function resolveLottieDataUrl(src: string, signal: AbortSignal): Promise { +async function resolveLottieJson(src: string, signal: AbortSignal): Promise { try { const bytes = await loadBytes(src, signal); if (!bytes || bytes[0] !== 0x1f || bytes[1] !== 0x8b) return null; @@ -174,120 +220,59 @@ async function resolveLottieDataUrl(src: string, signal: AbortSignal): Promise; -}; - -const lottieResolutionCache = new Map(); - -function trimLottieResolutionCache(): void { - let scanned = 0; +const resolvedLottieCache = new Map(); +const inFlightLottieLoads = new Map>(); - while ( - lottieResolutionCache.size > MAX_LOTTIE_CACHE_ENTRIES && - scanned < lottieResolutionCache.size - ) { - const oldest = lottieResolutionCache.entries().next().value as - | [string, LottieResolutionEntry] - | undefined; - if (!oldest) return; - const [source, entry] = oldest; - - if (entry.settled && entry.consumers === 0) { - lottieResolutionCache.delete(source); - scanned = 0; - continue; +function trimResolvedCache(): void { + while (resolvedLottieCache.size > MAX_LOTTIE_CACHE_ENTRIES) { + const oldest = resolvedLottieCache.keys().next().value; + if (oldest !== undefined) { + resolvedLottieCache.delete(oldest); + } else { + break; } - - lottieResolutionCache.delete(source); - lottieResolutionCache.set(source, entry); - scanned += 1; } } -function createLottieResolutionEntry(src: string): LottieResolutionEntry { +function resolveCachedLottieJson(src: string): Promise { + const cached = resolvedLottieCache.get(src); + if (cached !== undefined) { + resolvedLottieCache.delete(src); + resolvedLottieCache.set(src, cached); + return Promise.resolve(cached); + } + + const existing = inFlightLottieLoads.get(src); + if (existing) return existing; + const controller = new AbortController(); - const entry = { - controller, - consumers: 0, - settled: false, - } as LottieResolutionEntry; const timeout = window.setTimeout(() => controller.abort(), LOTTIE_PROBE_TIMEOUT_MS); - entry.promise = resolveLottieDataUrl(src, controller.signal).then((result) => { - entry.settled = true; - window.clearTimeout(timeout); - if ( - controller.signal.aborted || - result === null || - result.length > MAX_CACHED_LOTTIE_DATA_URL_LENGTH - ) { - if (lottieResolutionCache.get(src) === entry) lottieResolutionCache.delete(src); - } else { - trimLottieResolutionCache(); - } - return result; - }); - lottieResolutionCache.set(src, entry); - return entry; -} - -function resolveCachedLottieDataUrl(src: string, signal: AbortSignal): Promise { - let entry = lottieResolutionCache.get(src); - if (!entry) { - entry = createLottieResolutionEntry(src); - } else { - lottieResolutionCache.delete(src); - lottieResolutionCache.set(src, entry); - } - entry.consumers += 1; - - return new Promise((resolve) => { - let released = false; - const release = () => { - if (released) return; - released = true; - signal.removeEventListener('abort', handleAbort); - entry.consumers -= 1; - if (entry.consumers === 0) { - if (!entry.settled) { - entry.controller.abort(); - if (lottieResolutionCache.get(src) === entry) lottieResolutionCache.delete(src); - } else { - trimLottieResolutionCache(); - } + const promise = resolveLottieJson(src, controller.signal) + .then((result) => { + if (result !== null && result.length <= MAX_CACHED_LOTTIE_JSON_LENGTH) { + resolvedLottieCache.set(src, result); + trimResolvedCache(); } - }; - const handleAbort = () => { - release(); - resolve(null); - }; - - signal.addEventListener('abort', handleAbort, { once: true }); - entry.promise.then((result) => { - release(); - resolve(result); + return result; + }) + .finally(() => { + window.clearTimeout(timeout); + inFlightLottieLoads.delete(src); }); - if (signal.aborted) handleAbort(); - }); + + inFlightLottieLoads.set(src, promise); + return promise; } type LottieImageProps = LottieDotProps & { - src: string; + data: string; alt?: string; onLottieLoad?: (canvas?: HTMLCanvasElement) => void; onLottieError?: () => void; @@ -297,6 +282,7 @@ type LottieImageProps = LottieDotProps & { }; function LottieImage({ + data, alt, className, style, @@ -307,11 +293,12 @@ function LottieImage({ onPointerDown, ...props }: Readonly) { - const wrapperRef = useRef(null); + const [player, setPlayer] = useState(null); const callbacks = useRef({ onLottieLoad, onLottieError }); const pixelation = useRef(pixelated); callbacks.current = { onLottieLoad, onLottieError }; pixelation.current = pixelated; + const setForwardedRef = useCallback( (canvas: HTMLCanvasElement | null) => { if (typeof forwardedRef === 'function') forwardedRef(canvas); @@ -319,59 +306,62 @@ function LottieImage({ }, [forwardedRef] ); + useEffect(() => { - const updateRef = () => { - const canvas = wrapperRef.current?.querySelector('canvas') ?? null; - if (canvas) setForwardedRef(canvas); + if (!player) { + setForwardedRef(null); + return; + } + + const canvas = player.canvas as HTMLCanvasElement; + if (canvas) { + setForwardedRef(canvas); + } + + const handleLoad = () => { + if (canvas) { + canvas.style.imageRendering = pixelation.current ? 'pixelated' : 'auto'; + } + callbacks.current.onLottieLoad?.(canvas); + }; + + const handleError = () => { + callbacks.current.onLottieError?.(); }; - updateRef(); - const observer = new MutationObserver(updateRef); - if (wrapperRef.current) - observer.observe(wrapperRef.current, { childList: true, subtree: true }); + + player.addEventListener('load', handleLoad); + player.addEventListener('loadError', handleError); + + if (player.isLoaded) { + handleLoad(); + } + return () => { - observer.disconnect(); + player.removeEventListener('load', handleLoad); + player.removeEventListener('loadError', handleError); setForwardedRef(null); }; - }, [setForwardedRef]); - useEffect(() => { - const canvas = wrapperRef.current?.querySelector('canvas'); - if (canvas) canvas.style.imageRendering = pixelated ? 'pixelated' : 'auto'; - }, [pixelated]); - const handlePlayer = useCallback( - (player: DotLottieInstance) => { - if (!player) return; - let didLoad = false; - const handleLoad = () => { - if (didLoad) return; - didLoad = true; - (player.canvas as HTMLCanvasElement).style.imageRendering = pixelation.current - ? 'pixelated' - : 'auto'; - callbacks.current.onLottieLoad?.(player.canvas as HTMLCanvasElement); - setForwardedRef(player.canvas as HTMLCanvasElement); - }; - const handleError = () => callbacks.current.onLottieError?.(); + }, [player, setForwardedRef]); - player.addEventListener('load', handleLoad); - player.addEventListener('loadError', handleError); - if (player.isLoaded) handleLoad(); - }, - [setForwardedRef] - ); + useEffect(() => { + if (player?.canvas) { + (player.canvas as HTMLCanvasElement).style.imageRendering = pixelated ? 'pixelated' : 'auto'; + } + }, [player, pixelated]); return (
@@ -413,7 +403,7 @@ export const Image = forwardRef { - const controller = new AbortController(); + let active = true; if (isLottieCandidate && src) { - void resolveCachedLottieDataUrl(src, controller.signal).then((result) => { - if (!controller.signal.aborted) { + void resolveCachedLottieJson(src).then((result) => { + if (active) { setLottieResolution({ source: src, resolved: result }); } }); } - return () => controller.abort(); + return () => { + active = false; + }; }, [isLottieCandidate, src]); useEffect(() => { setFallbackSource(undefined); }, [src]); - const shouldRenderLottie = typeof resolvedLottieSrc === 'string'; + const shouldRenderLottie = typeof resolvedLottieJson === 'string'; if (shouldRenderLottie) { return ( @@ -453,7 +445,7 @@ export const Image = forwardRef Date: Sun, 26 Jul 2026 12:27:43 -0500 Subject: [PATCH 6/6] lint --- scripts/utils/console-style.js | 2 +- src/app/components/media/Image.tsx | 20 ++++++++++---------- src/app/pages/client/space/Space.tsx | 2 -- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/scripts/utils/console-style.js b/scripts/utils/console-style.js index ae7e29b792..433a362027 100644 --- a/scripts/utils/console-style.js +++ b/scripts/utils/console-style.js @@ -11,7 +11,7 @@ export const ANSI = { export function shouldUseColor() { if (process.env.NO_COLOR !== undefined) return false; if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== '0') return true; - return Boolean(process.stdout.isTTY); + return process.stdout.isTTY; } export function styleText(text, color, enabled) { diff --git a/src/app/components/media/Image.tsx b/src/app/components/media/Image.tsx index e9e39634d7..7c01e13725 100644 --- a/src/app/components/media/Image.tsx +++ b/src/app/components/media/Image.tsx @@ -107,9 +107,7 @@ export function processAndSanitizeLottie(root: unknown): string | null { } } else if (value && typeof value === 'object') { const entries = Object.entries(value as Record).filter( - ([k, child]) => - !UNSAFE_LOTTIE_KEYS.has(k) && - !(k === 'x' && typeof child === 'string') + ([k, child]) => !UNSAFE_LOTTIE_KEYS.has(k) && !(k === 'x' && typeof child === 'string') ); for (const [k, child] of entries) { @@ -129,7 +127,9 @@ export function processAndSanitizeLottie(root: unknown): string | null { } for (let i = entries.length - 1; i >= 0; i--) { - const [k, child] = entries[i]; + const entry = entries[i]; + if (!entry) continue; + const [k, child] = entry; stack.push({ parent: newObj, key: k, value: child, depth: depth + 1 }); } } else { @@ -183,7 +183,7 @@ async function readBytes( chunks.push(value); } } finally { - if (!completed) await reader.cancel().catch(() => undefined); + if (!completed) await reader.cancel().catch(() => {}); reader.releaseLock(); } @@ -215,9 +215,9 @@ async function resolveLottieJson(src: string, signal: AbortSignal): Promise { + useEffect((): (() => void) | undefined => { if (!player) { setForwardedRef(null); - return; + return undefined; } const canvas = player.canvas as HTMLCanvasElement; diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 74a632104a..744530b5e6 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -98,8 +98,6 @@ import { ModalWide } from '$styles/Modal.css'; import { ImageViewer } from '$components/image-viewer'; import { reportMediaLoadFailure } from '$utils/mediaLoadDiagnostics'; import * as css from './styles.css'; -import { isResizingSidebarAtom } from '$state/isResizingSidebar'; -import { UserQuickTools } from '../sidebar/UserQuickTools'; import { Image as MediaImage } from '$components/media'; import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';