Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gzipped-lottie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Add support for displaying gzipped lottie (e.g. tgs) files.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion scripts/utils/console-style.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion src/app/components/editor/Elements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -91,8 +92,9 @@ function RenderEmoticonElement({
contentEditable={false}
>
{element.key.startsWith('mxc://') ? (
<img
<MediaImage
className={css.EmoticonImg}
style={{ width: 'auto', height: '1em' }}
src={mxcUrlToHttp(mx, element.key, useAuthentication) ?? undefined}
alt={element.shortcode}
/>
Expand Down
3 changes: 2 additions & 1 deletion src/app/components/emoji-board/EmojiBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -189,7 +190,7 @@ const useItemRenderer = (tab: EmojiBoardTab, saveStickerEmojiBandwidth: boolean)
gif={gif}
style={{ aspectRatio }}
>
<img
<MediaImage
loading="lazy"
alt=""
aria-hidden
Expand Down
7 changes: 5 additions & 2 deletions src/app/components/emoji-board/components/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -103,10 +104,11 @@ export function CustomEmojiItem({
data-emoji-data={image.url}
data-emoji-shortcode={image.shortcode}
>
<img
<MediaImage
loading="lazy"
className={css.CustomEmojiImg}
alt={image.body || image.shortcode}
mimeType={image.info?.mimetype}
src={getPackImageSrc(mx, image, useAuthentication, saveStickerEmojiBandwidth, 32, 32)}
/>
</Box>
Expand Down Expand Up @@ -139,10 +141,11 @@ export function StickerItem({
data-emoji-data={image.url}
data-emoji-shortcode={image.shortcode}
>
<img
<MediaImage
loading="lazy"
className={css.StickerImg}
alt={image.body || image.shortcode}
mimeType={image.info?.mimetype}
src={getPackImageSrc(mx, image, useAuthentication, saveStickerEmojiBandwidth, 125, 125)}
/>
</Box>
Expand Down
3 changes: 2 additions & 1 deletion src/app/components/emoji-board/components/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -35,7 +36,7 @@ export function Preview({ previewAtom }: PreviewProps) {
justifyContent="Center"
>
{key.startsWith('mxc://') ? (
<img
<MediaImage
className={css.PreviewImg}
src={mxcUrlToHttp(mx, key, useAuthentication) ?? undefined}
alt={shortcode}
Expand Down
7 changes: 4 additions & 3 deletions src/app/components/emoji-board/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { ReactNode } from 'react';
import { Box, Scroll, Line, as, TooltipProvider, Tooltip, Text, IconButton } from 'folds';
import classNames from 'classnames';
import { sizedIcon, Image, type PhosphorIcon } from '$components/icons/phosphor';
import { Image as MediaImage } from '$components/media';
import { sizedIcon, Image as ImageIcon, type PhosphorIcon } from '$components/icons/phosphor';
import * as css from './styles.css';

export function Sidebar({ children }: { children: ReactNode }) {
Expand Down Expand Up @@ -110,9 +111,9 @@ export function ImageGroupIcon<T extends string>({
return (
<SidebarBtn active={active} id={id} label={label} onClick={onClick}>
{url ? (
<img className={css.SidebarBtnImg} src={url} alt={label} />
<MediaImage className={css.SidebarBtnImg} src={url} alt={label} />
) : (
sizedIcon(Image, '200', { filled: active })
sizedIcon(ImageIcon, '200', { filled: active })
)}
</SidebarBtn>
);
Expand Down
3 changes: 2 additions & 1 deletion src/app/components/image-editor/ImageEditor.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -43,7 +44,7 @@ export const ImageEditor = as<'div', ImageEditorProps>(
justifyContent="Center"
alignItems="Center"
>
<img className={css.Image} src={url} alt={name} />
<MediaImage className={css.Image} src={url} alt={name} />
</Box>
</Box>
);
Expand Down
7 changes: 4 additions & 3 deletions src/app/components/image-pack-view/ImageTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -40,7 +41,7 @@ export function ImageTile({
return (
<SettingTile
before={
<img
<MediaImage
className={css.ImagePackImage}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? undefined}
alt={image.shortcode}
Expand Down Expand Up @@ -102,7 +103,7 @@ export function ImageTileUpload({ file, children }: ImageTileUploadProps) {
const uploadAtom = useMemo(() => createUploadAtom(file), [file]);

return (
<SettingTile before={<img className={css.ImagePackImage} src={url} alt={file.name} />}>
<SettingTile before={<MediaImage className={css.ImagePackImage} src={url} alt={file.name} />}>
{children(uploadAtom)}
</SettingTile>
);
Expand Down Expand Up @@ -164,7 +165,7 @@ export function ImageTileEdit({
return (
<SettingTile
before={
<img
<MediaImage
className={css.ImagePackImage}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? undefined}
alt={image.shortcode}
Expand Down
52 changes: 50 additions & 2 deletions src/app/components/image-viewer/ImageViewer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { 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<Blob>>();
const gestureMocks = vi.hoisted(() => ({
onPointerDown: vi.fn<(event: React.PointerEvent) => void>(),
}));

vi.mock('$hooks/useImageGestures', () => ({
useImageGestures: () => ({
Expand All @@ -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<HTMLImageElement>) => void>(),
setZoom: vi.fn<(next: number) => void>(),
resetTransforms: vi.fn<() => void>(),
Expand Down Expand Up @@ -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<HTMLImageElement> & { info?: { mimetype?: string } }
>(({ alt, info, ...props }, ref) =>
info?.mimetype === 'application/x-tgsticker' ? (
<canvas
aria-label={alt}
{...(props as React.CanvasHTMLAttributes<HTMLCanvasElement>)}
ref={ref as React.ForwardedRef<HTMLCanvasElement>}
/>
) : (
<img alt={alt} {...props} ref={ref as React.ForwardedRef<HTMLImageElement>} />
)
),
};
});

describe('ImageViewer', () => {
it('renders the fullscreen image without crashing', () => {
render(<ImageViewer alt="demo" src="https://example.com/demo.png" requestClose={() => {}} />);

expect(screen.getByAltText('demo')).toBeInTheDocument();
});

it('starts viewer gestures from the rendered lottie canvas', () => {
gestureMocks.onPointerDown.mockClear();
render(
<ImageViewer
alt="animated sticker"
src="https://example.com/sticker"
info={{ mimetype: 'application/x-tgsticker' }}
requestClose={() => {}}
/>
);

const canvas = screen.getByLabelText('animated sticker');
expect(canvas.tagName).toBe('CANVAS');
fireEvent.pointerDown(canvas);
expect(gestureMocks.onPointerDown).toHaveBeenCalled();
});
});
14 changes: 13 additions & 1 deletion src/app/components/image-viewer/ImageViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -62,6 +63,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
imageRef,
containerRef,
handleImageLoad,
handleImageDimensions,
enableResizeWithWindow,
} = useImageGestures(true, 0.2, 0.1);
useEffect(() => {
Expand Down Expand Up @@ -319,7 +321,8 @@ export const ImageViewer = as<'div', ImageViewerProps>(
onTouchMove={menu.triggerProps.onTouchMove}
onTouchCancel={menu.triggerProps.onTouchCancel}
>
<img
<MediaImage
ref={imageRef}
className={classNames(css.ImageViewerImg, isPixelated && css.ImageViewerImgPixelated)}
draggable={false}
data-gestures="ignore"
Expand All @@ -330,11 +333,20 @@ export const ImageViewer = as<'div', ImageViewerProps>(
}}
src={src}
alt={alt}
info={info}
pixelated={isPixelated}
onPointerDown={onPointerDown}
onLoad={(event: React.SyntheticEvent<HTMLImageElement>) => {
handleImageLoad(event);
setIsImageReady(true);
}}
onLottieLoad={(canvas) => {
handleImageDimensions(
info?.w ?? canvas?.width ?? 0,
info?.h ?? canvas?.height ?? 0
);
setIsImageReady(true);
}}
/>
</Box>
</Box>
Expand Down
Loading
Loading