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://') ? (
-
-
);
@@ -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}
>
-
-
{children}
{isHovered && (
-
+
);
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) =>
,
+}));
+
+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();
+
+ 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 (
-
{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
;
+ 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>(
}}
/>
) : (
-
) {
const fileUrl = useObjectURL(originalFile);
return (
-
(
-
{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) {
-
>) {
}}
>
{previewUrl ? (
-
{
if (matchPath(LOGIN_PATH, pathname)) {
@@ -203,7 +204,7 @@ export function AuthLayout() {
-
+
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"
/>
);
}