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/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/editor/Elements.tsx b/src/app/components/editor/Elements.tsx index d2475b123d..2b14ced9d3 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,8 +92,9 @@ function RenderEmoticonElement({ contentEditable={false} > {element.key.startsWith('mxc://') ? ( - {element.shortcode} diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index a1208d7ebd..45f1ac781d 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -33,6 +33,7 @@ import { ImageUsage } from '$plugins/custom-emoji'; import { getEmoticonSearchStr } from '$plugins/utils'; import { VirtualTile } from '$components/virtualizer'; import { useSetting } from '$state/hooks/settings'; +import { Image as MediaImage } from '$components/media'; import { settingsAtom } from '$state/settings'; import { useEmojiGroupIcons } from './useEmojiGroupIcons'; import { useEmojiGroupLabels } from './useEmojiGroupLabels'; @@ -189,7 +190,7 @@ const useItemRenderer = (tab: EmojiBoardTab, saveStickerEmojiBandwidth: boolean) gif={gif} style={{ aspectRatio }} > - - {image.body @@ -139,10 +141,11 @@ export function StickerItem({ data-emoji-data={image.url} data-emoji-shortcode={image.shortcode} > - {image.body diff --git a/src/app/components/emoji-board/components/Preview.tsx b/src/app/components/emoji-board/components/Preview.tsx index 623557517b..b5b681029f 100644 --- a/src/app/components/emoji-board/components/Preview.tsx +++ b/src/app/components/emoji-board/components/Preview.tsx @@ -3,6 +3,7 @@ import type { Atom } from 'jotai'; import { atom, useAtomValue } from 'jotai'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { Image as MediaImage } from '$components/media'; import { mxcUrlToHttp } from '$utils/matrix'; import * as css from './styles.css'; @@ -35,7 +36,7 @@ export function Preview({ previewAtom }: PreviewProps) { justifyContent="Center" > {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 ( 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>(), @@ -60,3 +63,48 @@ describe('ImageViewer', () => { expect(FileSaver.saveAs).toHaveBeenCalledWith(expect.any(Blob), 'kitten.png'); }); }); + +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', () => { + render( {}} />); + + 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 df2c519a1d..ac284b5429 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'; @@ -62,6 +63,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( imageRef, containerRef, handleImageLoad, + handleImageDimensions, enableResizeWithWindow, } = useImageGestures(true, 0.2, 0.1); useEffect(() => { @@ -319,7 +321,8 @@ export const ImageViewer = as<'div', ImageViewerProps>( onTouchMove={menu.triggerProps.onTouchMove} onTouchCancel={menu.triggerProps.onTouchCancel} > - ( }} 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 new file mode 100644 index 0000000000..b7f32a6622 --- /dev/null +++ b/src/app/components/media/Image.test.tsx @@ -0,0 +1,239 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { createRef } from 'react'; +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'; + +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('removes executable-looking fields from Lottie JSON', () => { + 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', () => { + 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', '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 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('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('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; + return new Promise(() => {}); + }); + const { unmount } = render( + loading lottie + ); + + await waitFor(() => expect(requestSignal).toBeDefined()); + expect(requestSignal?.aborted).toBe(false); + unmount(); + + 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) => { + 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 389bbf810d..7c01e13725 100644 --- a/src/app/components/media/Image.tsx +++ b/src/app/components/media/Image.tsx @@ -1,27 +1,484 @@ -import type { ImgHTMLAttributes } from 'react'; -import { forwardRef } 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'; 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 = Omit, 'onPointerDown'> & { + info?: IImageInfo; + mimeType?: string; + disablePixelation?: boolean; + pixelated?: boolean; + onLottieLoad?: (canvas?: HTMLCanvasElement) => void; + onLottieError?: () => void; + onPointerDown?: PointerEventHandler; +}; -export const Image = forwardRef( - ({ className, alt, info, ...props }, ref) => { +type LottieDotProps = Omit< + ComponentProps, + 'src' | 'data' | 'alt' | 'loading' | 'onPointerDown' +>; +type DotLottieInstance = Parameters< + NonNullable['dotLottieRefCallback']> +>[0]; + +const DotLottieReact = lazy(() => + import('@lottiefiles/dotlottie-react').then((module) => ({ + default: module.DotLottieReact, + })) +) 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 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_JSON_LENGTH = 2 * 1024 * 1024; +const UNSAFE_LOTTIE_KEYS = new Set([ + 'expression', + 'expressions', + 'script', + 'scripts', + 'javascript', + 'onload', + 'onclick', +]); + +export function processAndSanitizeLottie(root: unknown): string | null { + if (!root || typeof root !== 'object' || Array.isArray(root)) return null; + const lottie = root as Record; + + 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 entry = entries[i]; + if (!entry) continue; + const [k, child] = entry; + 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( + 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')) + ); +} + +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(() => {}); + 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', 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); +} + +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; + + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + const stream = new Blob([buffer]).stream().pipeThrough(new DecompressionStream('gzip')); + const decompressed = await readBytes(stream, MAX_DECOMPRESSED_LOTTIE_BYTES, signal); + const parsed = JSON.parse(new TextDecoder().decode(decompressed)); + return processAndSanitizeLottie(parsed); + } catch { + return null; + } +} + +const resolvedLottieCache = new Map(); +const inFlightLottieLoads = new Map>(); + +function trimResolvedCache(): void { + while (resolvedLottieCache.size > MAX_LOTTIE_CACHE_ENTRIES) { + const oldest = resolvedLottieCache.keys().next().value; + if (oldest !== undefined) { + resolvedLottieCache.delete(oldest); + } else { + break; + } + } +} + +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 timeout = window.setTimeout(() => controller.abort(), LOTTIE_PROBE_TIMEOUT_MS); + + const promise = resolveLottieJson(src, controller.signal) + .then((result) => { + if (result !== null && result.length <= MAX_CACHED_LOTTIE_JSON_LENGTH) { + resolvedLottieCache.set(src, result); + trimResolvedCache(); + } + return result; + }) + .finally(() => { + window.clearTimeout(timeout); + inFlightLottieLoads.delete(src); + }); + + inFlightLottieLoads.set(src, promise); + return promise; +} + +type LottieImageProps = LottieDotProps & { + data: string; + alt?: string; + onLottieLoad?: (canvas?: HTMLCanvasElement) => void; + onLottieError?: () => void; + pixelated?: boolean; + forwardedRef?: ForwardedRef; + onPointerDown?: PointerEventHandler; +}; + +function LottieImage({ + data, + alt, + className, + style, + onLottieLoad, + onLottieError, + pixelated, + forwardedRef, + onPointerDown, + ...props +}: Readonly) { + 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); + else if (forwardedRef) forwardedRef.current = canvas; + }, + [forwardedRef] + ); + + useEffect((): (() => void) | undefined => { + if (!player) { + setForwardedRef(null); + return undefined; + } + + 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?.(); + }; + + player.addEventListener('load', handleLoad); + player.addEventListener('loadError', handleError); + + if (player.isLoaded) { + handleLoad(); + } + + return () => { + player.removeEventListener('load', handleLoad); + player.removeEventListener('loadError', handleError); + setForwardedRef(null); + }; + }, [player, setForwardedRef]); + + useEffect(() => { + if (player?.canvas) { + (player.canvas as HTMLCanvasElement).style.imageRendering = pixelated ? 'pixelated' : 'auto'; + } + }, [player, pixelated]); + + return ( + +
+ +
+
+ ); +} + +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 [lottieResolution, setLottieResolution] = useState<{ + source: string; + resolved: string | null; + }>(); + const [fallbackSource, setFallbackSource] = useState(); + + const lottieProps = props as LottieDotProps; + const declaredLottieCandidate = isGzippedLottieCandidate( + src, + mimeType ?? info?.mimetype, + typeof props.title === 'string' ? props.title : alt + ); + const isLottieCandidate = declaredLottieCandidate || fallbackSource === src; + const resolvedLottieJson = + isLottieCandidate && lottieResolution?.source === src + ? (lottieResolution?.resolved ?? null) + : isLottieCandidate + ? undefined + : null; + const imageClass = classNames( + css.Image, + !disablePixelation && + (pixelated ?? isPixelatedRendering(pixelatedImageRendering, info)) && + css.ImagePixelated, + className + ); + + useEffect(() => { + let active = true; + + if (isLottieCandidate && src) { + void resolveCachedLottieJson(src).then((result) => { + if (active) { + setLottieResolution({ source: src, resolved: result }); + } + }); + } + + return () => { + active = false; + }; + }, [isLottieCandidate, src]); + + useEffect(() => { + setFallbackSource(undefined); + }, [src]); + + const shouldRenderLottie = typeof resolvedLottieJson === 'string'; + + if (shouldRenderLottie) { + return ( + + ); + } return ( {alt} { + if (!declaredLottieCandidate && fallbackSource !== src) { + setFallbackSource(src); + return; + } + onError?.(event); + }} {...props} - ref={ref} + ref={ref as ForwardedRef} /> ); } diff --git a/src/app/components/message-preview/MessagePreview.tsx b/src/app/components/message-preview/MessagePreview.tsx index fa97eb4bcd..34017482a1 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.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/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} 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/PowerIcon.tsx b/src/app/components/power/PowerIcon.tsx index 5601101d80..bfb5131b15 100644 --- a/src/app/components/power/PowerIcon.tsx +++ b/src/app/components/power/PowerIcon.tsx @@ -1,4 +1,5 @@ import { isJumboEmojiText } from '$utils/emojiDetection'; +import { Image as MediaImage } from '$components/media'; import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; import * as css from './style.css'; @@ -27,5 +28,5 @@ export function PowerIcon({ size, iconSrc, name }: PowerIconProps) { } if (!safeUrl) return null; - return {name}; + return ; } 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/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 ( - - {fileItem.originalFile.type.startsWith('image') && ( + {isImageMimeType(fileItem.originalFile.type) && ( diff --git a/src/app/components/url-preview/ClientPreview.tsx b/src/app/components/url-preview/ClientPreview.tsx index 00e1266269..d67e9c95b6 100644 --- a/src/app/components/url-preview/ClientPreview.tsx +++ b/src/app/components/url-preview/ClientPreview.tsx @@ -8,7 +8,7 @@ import { settingsAtom } from '$state/settings'; import { encodeBlurHash } from '$utils/blurHash'; import { fetch } from '$utils/fetch'; import { Attachment, AttachmentBox, AttachmentHeader } from '../message/attachment'; -import { Image } from '../media'; +import { Image as MediaImage } from '../media'; import { UrlPreview } from './UrlPreview'; import { VideoContent } from '../message'; import { MATRIX_UNSTABLE_BLUR_HASH_PROPERTY_NAME } from '../../../unstable/prefixes'; @@ -124,7 +124,7 @@ const YoutubeElement = as<'div', YoutubeElementProps>(({ videoInfo, embedData }) thumbnail_info: { [MATRIX_UNSTABLE_BLUR_HASH_PROPERTY_NAME]: blurHash }, }} renderThumbnail={() => ( - ( - {`${userId}) { }} > {previewUrl ? ( - ( 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/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index c3078af76c..35438d767c 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -21,6 +21,7 @@ import { toSettingsFocusIdPart } from '$features/settings/settingsLink'; import type { UploadSuccess } from '$state/upload'; import { createUploadAtom } from '$state/upload'; import { CompactUploadCardRenderer } from '$components/upload-card'; +import { Image as MediaImage } from '$components/media'; import { useCapabilities } from '$hooks/useCapabilities'; import { profilesCacheAtom } from '$state/userRoomProfile'; import { useUserPresence } from '$hooks/useUserPresence'; @@ -189,7 +190,7 @@ function ProfileBanner({ profile }: Readonly>) { }} > {previewUrl ? ( - encryptFile(f))); diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index 81a2bd91d9..31a0498c13 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -51,7 +51,7 @@ import { ReactionKeyInline, Time, } from '$components/message'; -import { Image } from '$components/media'; +import { Image as MediaImage } from '$components/media'; import { ImageViewer } from '$components/image-viewer'; import { RenderMessageContent } from '$components/RenderMessageContent'; import { ClientSideHoverFreeze } from '$components/ClientSideHoverFreeze'; @@ -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..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'; @@ -277,6 +279,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 +377,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/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 0d7c167a1b..744530b5e6 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -98,6 +98,7 @@ import { ModalWide } from '$styles/Modal.css'; import { ImageViewer } from '$components/image-viewer'; import { reportMediaLoadFailure } from '$utils/mediaLoadDiagnostics'; import * as css from './styles.css'; +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 +378,7 @@ function SpaceHeader({ hideText, mx }: { hideText?: boolean; mx: MatrixClient }) } }} > - { 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 187ca61c76..f63c2858ac 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -37,6 +37,7 @@ import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; import { getSettingsLinkChipLabel, parseSettingsLink } from '$features/settings/settingsLink'; import { ClientSideHoverFreeze } from '$components/ClientSideHoverFreeze'; import { CodeHighlightRenderer } from '$components/code-highlight'; +import { Image as MediaImage } from '$components/media'; import { isRedundantMatrixToAnchorText, parseMatrixToRoom, @@ -559,12 +560,23 @@ export function CodeBlock({ function FallbackImg({ fallback, src, + className, + style, ...props -}: ComponentPropsWithoutRef<'img'> & { 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)} + onLottieError={() => setFailed(true)} + /> + ); } export const getReactCustomHtmlParser = ( diff --git a/src/app/utils/mimeTypes.test.ts b/src/app/utils/mimeTypes.test.ts index 3b60e711a1..599980a4de 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,37 @@ 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); + }); + + 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', () => { it.each([ ['image/jpeg', 'jpeg'], diff --git a/src/app/utils/mimeTypes.ts b/src/app/utils/mimeTypes.ts index 3d97f87d47..9aac4a0a39 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,40 @@ 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'; + +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') || isTgsMimeType(file.type); + +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 +149,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;