Skip to content
Open
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
1 change: 1 addition & 0 deletions src/app/components/emoji-board/EmojiBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ const useItemRenderer = (tab: EmojiBoardTab, saveStickerEmojiBandwidth: boolean)
>
<MediaImage
loading="lazy"
queueLoad
alt=""
aria-hidden
src={gifUrl}
Expand Down
2 changes: 2 additions & 0 deletions src/app/components/emoji-board/components/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export function CustomEmojiItem({
>
<MediaImage
loading="lazy"
queueLoad
className={css.CustomEmojiImg}
alt={image.body || image.shortcode}
mimeType={image.info?.mimetype}
Expand Down Expand Up @@ -143,6 +144,7 @@ export function StickerItem({
>
<MediaImage
loading="lazy"
queueLoad
className={css.StickerImg}
alt={image.body || image.shortcode}
mimeType={image.info?.mimetype}
Expand Down
34 changes: 33 additions & 1 deletion src/app/components/media/Image.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { createRef } from 'react';
import { Image, processAndSanitizeLottie } from './Image';
import { Blob as NodeBlob } from 'node:buffer';
Expand Down Expand Up @@ -44,6 +44,38 @@ describe('Image', () => {
fetchSpy.mockRestore();
});

it('only starts a queued image when it is visible', () => {
let notify: IntersectionObserverCallback | undefined;
const originalIntersectionObserver = globalThis.IntersectionObserver;
class MockIntersectionObserver {
constructor(callback: IntersectionObserverCallback) {
notify = callback;
}

observe() {}

disconnect() {}
}
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);

try {
render(<Image queueLoad src="https://example.com/queued.png" alt="queued" />);

const image = screen.getByAltText('queued');
expect(image).not.toHaveAttribute('src');

act(() => {
notify?.(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver
);
});
expect(image).toHaveAttribute('src', 'https://example.com/queued.png');
} finally {
vi.stubGlobal('IntersectionObserver', originalIntersectionObserver);
}
});

it('uses the lottie renderer for gzipped lottie data', async () => {
const gzipped = Buffer.from(gzipSync(lottieJson)).toString('base64');
render(
Expand Down
54 changes: 50 additions & 4 deletions src/app/components/media/Image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { forwardRef, lazy, Suspense, useCallback, useEffect, useRef, useState }
import classNames from 'classnames';
import type { DotLottieReact as DotLottieReactComponent } from '@lottiefiles/dotlottie-react';
import { useSetting } from '$state/hooks/settings';
import { useMediaLoadSlot } from '$hooks/useMediaLoadSlot';
import { isPixelatedRendering, settingsAtom } from '$state/settings';
import * as css from './media.css';
import type { IImageInfo } from '$types/matrix/common';
Expand All @@ -13,6 +14,11 @@ type ImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, 'onPointerDown'> & {
disableDefaultSizing?: boolean;
disablePixelation?: boolean;
pixelated?: boolean;
/**
* Load through the media queue. Only for images inside a virtualized list, where being
* mounted means being on screen: a mounted image holds its slot until it reports back.
*/
queueLoad?: boolean;
onLottieLoad?: (canvas?: HTMLCanvasElement) => void;
onLottieError?: () => void;
onPointerDown?: PointerEventHandler<HTMLElement>;
Expand Down Expand Up @@ -399,11 +405,16 @@ export const Image = forwardRef<HTMLImageElement | HTMLCanvasElement, ImageProps
onLottieLoad,
onLottieError,
pixelated,
queueLoad,
...props
},
ref
) => {
const [pixelatedImageRendering] = useSetting(settingsAtom, 'pixelatedImageRendering');
const [imageElement, setImageElement] = useState<HTMLImageElement | null>(null);
const [isNearViewport, setIsNearViewport] = useState(
() => !queueLoad || typeof IntersectionObserver === 'undefined'
);
const [lottieResolution, setLottieResolution] = useState<{
source: string;
resolved: string | null;
Expand Down Expand Up @@ -452,6 +463,37 @@ export const Image = forwardRef<HTMLImageElement | HTMLCanvasElement, ImageProps
setFallbackSource(undefined);
}, [src]);

useEffect(() => {
if (!queueLoad || !imageElement || typeof IntersectionObserver === 'undefined') {
setIsNearViewport(true);
return undefined;
}

setIsNearViewport(false);
const observer = new IntersectionObserver(([entry]) => {
setIsNearViewport(entry?.isIntersecting === true);
});
observer.observe(imageElement);
return () => observer.disconnect();
}, [imageElement, queueLoad]);

// Only the plain <img> branch consumes a queue slot: a candidate that is still being
// probed, or that resolved to animation data, never becomes an image request.
const imageSource = resolvedLottieJson === null ? src : undefined;
const { src: queuedSrc, onSettled: onImageSettled } = useMediaLoadSlot(
queueLoad && isNearViewport ? imageSource : undefined
);
const renderedSrc = queueLoad ? queuedSrc : imageSource;

const setImageRef = useCallback(
(image: HTMLImageElement | null) => {
setImageElement(image);
if (typeof ref === 'function') ref(image);
else if (ref) ref.current = image;
},
[ref]
);

const shouldRenderLottie = typeof resolvedLottieJson === 'string';

if (shouldRenderLottie) {
Expand Down Expand Up @@ -481,20 +523,24 @@ export const Image = forwardRef<HTMLImageElement | HTMLCanvasElement, ImageProps
className={imageClass}
alt={alt}
loading={loading}
src={resolvedLottieJson === undefined ? undefined : src}
aria-busy={resolvedLottieJson === undefined ? true : undefined}
src={renderedSrc}
aria-busy={src !== undefined && renderedSrc === undefined ? true : undefined}
style={style}
onLoad={onLoad}
onLoad={(event) => {
onImageSettled();
onLoad?.(event);
}}
onPointerDown={onPointerDown}
onError={(event) => {
onImageSettled();
if (!declaredLottieCandidate && fallbackSource !== src) {
setFallbackSource(src);
return;
}
onError?.(event);
}}
{...props}
ref={ref as ForwardedRef<HTMLImageElement>}
ref={setImageRef}
/>
);
}
Expand Down
89 changes: 89 additions & 0 deletions src/app/hooks/useMediaLoadSlot.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useMediaLoadSlot } from './useMediaLoadSlot';

const renderSlot = (src: string | undefined) => renderHook(() => useMediaLoadSlot(src));

describe('useMediaLoadSlot', () => {
it('passes a local source straight through', () => {
const { result } = renderSlot('blob:https://example.com/1234');

expect(result.current.src).toBe('blob:https://example.com/1234');
});

it('allows a remote source right away while the queue has room', () => {
const { result, unmount } = renderSlot('https://example.com/emote.png');

expect(result.current.src).toBe('https://example.com/emote.png');
unmount();
});

it('holds a remote source back once the queue is full', () => {
const held = Array.from({ length: 6 }, (_, index) =>
renderSlot(`https://example.com/held-${index}.png`)
);

const queued = renderSlot('https://example.com/queued.png');
expect(queued.result.current.src).toBeUndefined();

held[0]?.unmount();
expect(queued.result.current.src).toBe('https://example.com/queued.png');

held.slice(1).forEach((slot) => slot.unmount());
queued.unmount();
});

it('gives up a queued slot on unmount without ever exposing the source', () => {
const held = Array.from({ length: 6 }, (_, index) =>
renderSlot(`https://example.com/held-${index}.png`)
);

const scrolledPast = renderSlot('https://example.com/scrolled-past.png');
expect(scrolledPast.result.current.src).toBeUndefined();
scrolledPast.unmount();

const next = renderSlot('https://example.com/next.png');
held.forEach((slot) => slot.unmount());

expect(scrolledPast.result.current.src).toBeUndefined();
expect(next.result.current.src).toBe('https://example.com/next.png');

next.unmount();
});

it('stops showing the previous source while the next one waits for a slot', () => {
const { rerender, result, unmount } = renderHook(({ src }) => useMediaLoadSlot(src), {
initialProps: { src: 'https://example.com/a.png' },
});
expect(result.current.src).toBe('https://example.com/a.png');

const held = Array.from({ length: 5 }, (_, index) =>
renderSlot(`https://example.com/held-${index}.png`)
);
// Takes the slot released by the source change, so the next source has to queue.
const next = renderSlot('https://example.com/next.png');

rerender({ src: 'https://example.com/b.png' });

expect(result.current.src).toBeUndefined();
expect(next.result.current.src).toBe('https://example.com/next.png');

[...held, next].forEach((slot) => slot.unmount());
unmount();
});

it('frees the slot when the image reports back', () => {
const held = Array.from({ length: 6 }, (_, index) =>
renderSlot(`https://example.com/held-${index}.png`)
);

const queued = renderSlot('https://example.com/queued.png');
expect(queued.result.current.src).toBeUndefined();

act(() => held[0]?.result.current.onSettled());
expect(queued.result.current.src).toBe('https://example.com/queued.png');

held.forEach((slot) => slot.unmount());
queued.unmount();
});
});
53 changes: 53 additions & 0 deletions src/app/hooks/useMediaLoadSlot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { requestMediaLoadSlot } from '$utils/mediaLoadQueue';

const noop = (): void => {};

const isRemoteSource = (src: string | undefined): src is string =>
src !== undefined && !src.startsWith('blob:') && !src.startsWith('data:');

/**
* Holds a remote `src` back until the media queue has room for it. A source still queued
* when the element unmounts never becomes a request at all, so scrolling past media costs
* nothing and never leaves a half-finished download to repeat later.
*
* Runs as a layout effect so a source that is granted straight away is in place before
* the first paint, leaving media on an idle queue exactly as fast as it was before.
*/
export const useMediaLoadSlot = (
src: string | undefined
): { src: string | undefined; onSettled: () => void } => {
const [allowedSrc, setAllowedSrc] = useState<string | undefined>(undefined);
const settleRef = useRef<() => void>(noop);

useLayoutEffect(() => {
if (!isRemoteSource(src)) {
settleRef.current = noop;
setAllowedSrc(src);
return undefined;
}

let grantedNow = false;
const slot = requestMediaLoadSlot(() => {
grantedNow = true;
setAllowedSrc(src);
});
// Drop the previous source while this one waits. Leaving it in place would render an
// image that holds no slot, and its load event would settle the slot this one is
// queued for, stranding it.
if (!grantedNow) setAllowedSrc(undefined);
settleRef.current = slot.settle;

return () => {
settleRef.current = noop;
slot.settle();
};
}, [src]);

const onSettled = useCallback(() => {
settleRef.current();
settleRef.current = noop;
}, []);

return { src: allowedSrc, onSettled };
};
Loading
Loading