From 700034db6af26dad2fb5049fb0a73d6dd9b5f35e Mon Sep 17 00:00:00 2001 From: Mohammad-Mohsen Date: Fri, 17 Jul 2026 14:59:04 +0330 Subject: [PATCH] Add support for displaying gzipped lottie (e.g. tgs) --- package.json | 1 + pnpm-lock.yaml | 18 +++ src/app/components/editor/Elements.tsx | 3 +- src/app/components/emoji-board/EmojiBoard.tsx | 15 +- .../emoji-board/components/Item.tsx | 14 +- .../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 | 34 +++++ .../components/image-viewer/ImageViewer.tsx | 3 +- src/app/components/media/Image.test.tsx | 29 ++++ src/app/components/media/Image.tsx | 134 +++++++++++++++++- src/app/components/message/Reaction.tsx | 3 +- .../components/message/ReactionKeyInline.tsx | 3 +- .../NotificationBanner.tsx | 7 +- src/app/components/power/PowerIcon.tsx | 3 +- src/app/components/room-card/RoomCard.tsx | 3 +- .../upload-card/UploadCardRenderer.tsx | 3 +- src/app/components/user-profile/UserHero.tsx | 14 +- .../common-settings/general/RoomProfile.tsx | 3 +- src/app/features/settings/about/About.tsx | 9 +- src/app/features/settings/account/Profile.tsx | 3 +- src/app/pages/auth/AuthLayout.tsx | 3 +- src/app/pages/client/WelcomePage.tsx | 3 +- src/app/pages/client/space/Space.tsx | 7 +- src/app/plugins/react-custom-html-parser.tsx | 72 ++++++---- 27 files changed, 345 insertions(+), 62 deletions(-) create mode 100644 src/app/components/image-viewer/ImageViewer.test.tsx create mode 100644 src/app/components/media/Image.test.tsx diff --git a/package.json b/package.json index a2d8d8abba..e631b6ab49 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.0.0", "@fontsource-variable/nunito": "5.2.7", "@fontsource/space-mono": "5.2.9", + "@lottiefiles/dotlottie-react": "^0.12.0", "@phosphor-icons/react": "^2.1.10", "@sableclient/twemoji-font": "^1.0.4", "@sentry/react": "^10.63.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 082e727ca0..f03be4e59e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,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) '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1524,6 +1527,14 @@ packages: '@juggle/resize-observer@3.4.0': resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} + '@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'} @@ -6422,6 +6433,13 @@ snapshots: '@juggle/resize-observer@3.4.0': {} + '@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 6d8cbb2975..26a2121d4e 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} - ); @@ -428,7 +434,10 @@ function EmojiGroupHolder({ ); } -const DefaultEmojiPreview: PreviewData = { key: '🙂', shortcode: 'slight_smile' }; +const DefaultEmojiPreview: PreviewData = { + key: '🙂', + shortcode: 'slight_smile', +}; const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, diff --git a/src/app/components/emoji-board/components/Item.tsx b/src/app/components/emoji-board/components/Item.tsx index ea70226357..fdd34233b7 100644 --- a/src/app/components/emoji-board/components/Item.tsx +++ b/src/app/components/emoji-board/components/Item.tsx @@ -3,6 +3,7 @@ import type { MatrixClient } from '$types/matrix-sdk'; import type { PackImageReader } from '$plugins/custom-emoji'; import type { IEmoji } from '$plugins/emoji'; import { mxcUrlToHttp } from '$utils/matrix'; +import { Image as MediaImage } from '$components/media'; import type { EmojiItemInfo, GifData } from '$components/emoji-board/types'; import { EmojiType } from '$components/emoji-board/types'; import type { CSSProperties, ReactNode } from 'react'; @@ -103,7 +104,7 @@ export function CustomEmojiItem({ data-emoji-data={image.url} data-emoji-shortcode={image.shortcode} > - {image.body - {image.body {children} {isHovered && ( - + {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 8e2854648e..1cd60698bc 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'; export 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 0038ca7342..19bc745033 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 ( ({ + Image: ({ alt, ...props }: ImgHTMLAttributes) => {alt}, +})); + +vi.mock('$hooks/useImageGestures', () => ({ + useImageGestures: () => ({ + transforms: { zoom: 1, pan: { x: 0, y: 0 } }, + cursor: 'initial', + handleWheel: () => {}, + onPointerDown: () => {}, + resetTransforms: () => {}, + zoomIn: () => {}, + zoomOut: () => {}, + setZoom: () => {}, + fitRatio: 1, + imageRef: { current: null }, + containerRef: { current: null }, + handleImageLoad: () => {}, + enableResizeWithWindow: () => {}, + }), +})); + +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 55df2294bd..2c6d7fd921 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -27,6 +27,7 @@ import { phosphorSizeRem, sizedIcon, } from '$components/icons/phosphor'; +import { Image as MediaImage } from '$components/media'; import { useImageGestures } from '$hooks/useImageGestures'; import { useSetting } from '$state/hooks/settings'; import { isPixelatedRendering, settingsAtom } from '$state/settings'; @@ -340,7 +341,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( onPointerDown={onPointerDown} onContextMenu={handleContextMenu} > - { + 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..f4bdda5e96 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,127 @@ import type { IImageInfo } from '$types/matrix/common'; type ImageProps = ImgHTMLAttributes & { info?: IImageInfo }; +type LottieDotProps = Omit, 'src' | 'alt' | 'loading'>; + +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/Reaction.tsx b/src/app/components/message/Reaction.tsx index 3b4c3cebb4..0611104914 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'; 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} {notification.icon && ( - ); diff --git a/src/app/components/power/PowerIcon.tsx b/src/app/components/power/PowerIcon.tsx index 63f9d6d8eb..e3bb78ea06 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 * as css from './style.css'; type PowerIconProps = css.PowerIconVariants & { @@ -25,5 +26,5 @@ export function PowerIcon({ size, iconSrc, name }: PowerIconProps) { const safeIconUrl = getSafeIconUrl(iconSrc); if (!safeIconUrl) return null; - return {name}; + return ; } diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index 1c00610a47..7b1c88e290 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -40,6 +40,7 @@ import type { RoomBannerContent } from '$types/matrix-sdk-events'; import { CustomStateEvent } from '$types/matrix/room'; import colorMXID from '$utils/colorMXID'; import { reportMediaLoadFailure } from '$utils/mediaLoadDiagnostics'; +import { Image as MediaImage } from '$components/media'; type GridColumnCount = '1' | '2' | '3'; const getGridColumnCount = (gridWidth: number): GridColumnCount => { @@ -234,7 +235,7 @@ export const RoomCard = as<'div', RoomCardProps>( }} /> ) : ( - {`${name}) { const fileUrl = useObjectURL(originalFile); return ( - ( - {`${userId} {isFullStatus ? ( @@ -327,7 +331,11 @@ function UserHeroNameInner({ } else isSuccess.current = false; setCopied(); }} - style={{ backgroundColor: 'transparent', color: 'inherit', padding: '0' }} + style={{ + backgroundColor: 'transparent', + color: 'inherit', + padding: '0', + }} onPointerEnter={() => setIsHovered(true)} onPointerLeave={() => setIsHovered(false)} before={ diff --git a/src/app/features/common-settings/general/RoomProfile.tsx b/src/app/features/common-settings/general/RoomProfile.tsx index ccfbb0e6a1..0fcad83293 100644 --- a/src/app/features/common-settings/general/RoomProfile.tsx +++ b/src/app/features/common-settings/general/RoomProfile.tsx @@ -32,6 +32,7 @@ import classNames from 'classnames'; import type { MatrixError, StateEvents } from '$types/matrix-sdk'; import { JoinRule, EventType } from '$types/matrix-sdk'; import { SequenceCard } 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'; @@ -400,7 +401,7 @@ function RoomBannerEdit({ bannerURI, permissions }: Readonly) { }} > {previewUrl ? ( - setVersion({ - server: fetched_version as { name?: string; version?: string; compiler?: string }, + server: fetched_version as { + name?: string; + version?: string; + compiler?: string; + }, }) ) .catch((error) => { @@ -189,7 +194,7 @@ export function About({ requestBack, requestClose }: Readonly) { - Sable logo>) { }} > {previewUrl ? ( - { if (matchPath(LOGIN_PATH, pathname)) { @@ -203,7 +204,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 a7f0f63234..460ee33e24 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -111,6 +111,7 @@ 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'; const debugLog = createDebugLogger('Space'); @@ -389,7 +390,7 @@ function SpaceHeader({ hideText, mx }: { hideText?: boolean; mx: MatrixClient }) } }} > - - {menuIcon(Flag, { weight: lobbySelected ? 'fill' : 'regular' })} + {menuIcon(Flag, { + weight: lobbySelected ? 'fill' : 'regular', + })} {!hideText && ( diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index cd5c9a3432..44f7df34a6 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -36,6 +36,7 @@ import { useTimeoutToggle } from '$hooks/useTimeoutToggle'; import { getSettingsLinkChipLabel, parseSettingsLink } from '$features/settings/settingsLink'; import { ClientSideHoverFreeze } from '$components/ClientSideHoverFreeze'; import { CodeHighlightRenderer } from '$components/code-highlight'; +import { Image } from '$components/media'; import { isRedundantMatrixToAnchorText, parseMatrixToRoom, @@ -125,7 +126,12 @@ function KatexRenderer({ let mounted = true; void Promise.all([import('katex'), import('katex/dist/katex.min.css')]).then(([katex]) => { if (mounted) { - setHtml(katex.default.renderToString(math, { throwOnError: false, displayMode })); + setHtml( + katex.default.renderToString(math, { + throwOnError: false, + displayMode, + }) + ); } }); return () => { @@ -901,31 +907,39 @@ export const getReactCustomHtmlParser = ( {!params.autoplayEmojis ? ( - - {props.alt || props.title || '?'} - + alt={ + typeof props.alt === 'string' + ? props.alt + : typeof props.title === 'string' + ? props.title + : '' } + title={typeof props.title === 'string' ? props.title : undefined} + loading="lazy" /> ) : ( - - {props.alt || props.title || '?'} - + alt={ + typeof props.alt === 'string' + ? props.alt + : typeof props.title === 'string' + ? props.title + : '' } + title={typeof props.title === 'string' ? props.title : undefined} + loading="lazy" /> )} @@ -939,27 +953,37 @@ export const getReactCustomHtmlParser = ( {!params.autoplayEmojis ? ( - - {props.alt || props.title || '?'} - + alt={ + typeof props.alt === 'string' + ? props.alt + : typeof props.title === 'string' + ? props.title + : '' } + title={typeof props.title === 'string' ? props.title : undefined} + loading="lazy" /> ) : ( - {props.alt || props.title || '?'} + alt={ + typeof props.alt === 'string' + ? props.alt + : typeof props.title === 'string' + ? props.title + : '' } + title={typeof props.title === 'string' ? props.title : undefined} + loading="lazy" /> )} @@ -977,16 +1001,14 @@ export const getReactCustomHtmlParser = ( if (htmlSrc) return ( - - {props.alt || '[media]'} - - } + loading="lazy" /> ); }