add better loading states to media
Some checks failed
pl-api CI / Test for pl-api formatting (22.x) (push) Has been cancelled
pl-fe CI / Test and upload artifacts (22.x) (push) Has been cancelled
pl-hooks CI / Test for a successful build (22.x) (push) Has been cancelled
pl-fe CI / deploy (push) Has been cancelled

This commit is contained in:
2026-02-14 20:15:46 +00:00
parent 755965ab5f
commit 1926a1d6bb
5 changed files with 44 additions and 7 deletions

View File

@ -190,6 +190,7 @@ const Item: React.FC<IItem> = ({
alt={attachment.description}
letterboxed={letterboxed}
showExt
blurhash={attachment.blurhash}
/>
</a>
{attachment.description && (

View File

@ -1,6 +1,7 @@
import clsx from 'clsx';
import React, { useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import Blurhash from '@/components/blurhash';
import { useSettings } from '@/stores/settings';
interface IStillImage {
@ -28,20 +29,31 @@ interface IStillImage {
isGif?: boolean;
/** Specify that the group is defined by the parent */
noGroup?: boolean;
/** Blurhash string for placeholder preview. */
blurhash?: string | null;
/** Loading placeholder style. */
placeholder?: 'shimmer' | 'pulse' | 'none';
}
/** Renders images on a canvas, only playing GIFs if autoPlayGif is enabled. */
const StillImage: React.FC<IStillImage> = ({
alt, className, innerClassName, src, staticSrc, style, letterboxed = false, showExt = false, onError, onLoad, isGif, noGroup,
alt, className, innerClassName, src, staticSrc, style, letterboxed = false, showExt = false, onError, onLoad, isGif, noGroup, blurhash, placeholder = 'pulse',
}) => {
const { autoPlayGif } = useSettings();
const canvas = useRef<HTMLCanvasElement>(null);
const img = useRef<HTMLImageElement>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
setLoaded(false);
}, [src]);
const hoverToPlay = src && !autoPlayGif && (isGif || src.endsWith('.gif') || src.startsWith('blob:') || (src && staticSrc && src !== staticSrc));
const handleImageLoad: React.ReactEventHandler<HTMLImageElement> = (e) => {
setLoaded(true);
if (hoverToPlay && !staticSrc && canvas.current && img.current) {
canvas.current.width = img.current.naturalWidth;
canvas.current.height = img.current.naturalHeight;
@ -69,14 +81,26 @@ const StillImage: React.FC<IStillImage> = ({
className={clsx(className, 'relative isolate', { 'group': !noGroup })}
style={style}
>
{!loaded && (blurhash ? (
<Blurhash hash={blurhash} className='absolute inset-0 z-0 size-full rounded-[inherit]' />
) : placeholder === 'shimmer' ? (
<span className='absolute inset-0 z-0 overflow-hidden rounded-[inherit] bg-gray-200 dark:bg-gray-700'>
<span className='absolute inset-0 no-reduce-motion:animate-shimmer' style={{ background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent)' }} />
</span>
) : placeholder === 'pulse' ? (
<span className='absolute inset-0 z-0 rounded-[inherit] bg-gray-200 no-reduce-motion:animate-pulse dark:bg-gray-700' />
) : null)}
<img
src={src}
alt={alt}
ref={img}
decoding='async'
onLoad={handleImageLoad}
onError={onError}
className={clsx(baseClassName, {
className={clsx(baseClassName, 'no-reduce-motion:transition-opacity no-reduce-motion:duration-200', {
'invisible group-hover:visible': hoverToPlay,
'opacity-0': !loaded,
})}
/>
@ -84,7 +108,9 @@ const StillImage: React.FC<IStillImage> = ({
<img
src={staticSrc}
alt={alt}
className={clsx(baseClassName, 'absolute top-0 group-hover:invisible')}
decoding='async'
className={clsx(baseClassName, 'absolute top-0 opacity-0 no-reduce-motion:transition-opacity no-reduce-motion:duration-200 group-hover:invisible')}
onLoad={(e) => e.currentTarget.classList.remove('opacity-0')}
/>
) : (
<canvas

View File

@ -1,3 +1,4 @@
import clsx from 'clsx';
import React from 'react';
import StillImage from '@/components/still-image';
@ -40,6 +41,7 @@ const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
isGif
noGroup={noGroup}
letterboxed
placeholder='none'
{...rest}
/>
);
@ -49,10 +51,13 @@ const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
return (
<img
{...rest}
draggable='false'
alt={alt || emoji}
src={joinPublicPath(`packs/emoji/${filename}.svg`)}
{...rest}
decoding='async'
className={clsx('opacity-0 no-reduce-motion:transition-opacity no-reduce-motion:duration-150', rest.className)}
onLoad={(e) => e.currentTarget.classList.remove('opacity-0')}
/>
);
};

View File

@ -93,10 +93,12 @@ const Emojify: React.FC<IEmojify> = React.memo(({ text, emojis = {}, large }) =>
<img
key={index}
draggable={false}
className='emojione transition-transform hover:scale-125'
className='emojione opacity-0 no-reduce-motion:transition-opacity no-reduce-motion:duration-150 transition-transform hover:scale-125'
alt={c}
title={`:${shortcode}:`}
src={joinPublicPath(`packs/emoji/${unified}.svg`)}
decoding='async'
onLoad={(e) => e.currentTarget.classList.remove('opacity-0')}
/>,
);
} else if (!systemEmojiFont && unqualified in unicodeMapping) {
@ -108,10 +110,12 @@ const Emojify: React.FC<IEmojify> = React.memo(({ text, emojis = {}, large }) =>
<img
key={index}
draggable={false}
className='emojione transition-transform hover:scale-125'
className='emojione opacity-0 no-reduce-motion:transition-opacity no-reduce-motion:duration-150 transition-transform hover:scale-125'
alt={unqualified}
title={`:${shortcode}:`}
src={joinPublicPath(`packs/emoji/${unified}.svg`)}
decoding='async'
onLoad={(e) => e.currentTarget.classList.remove('opacity-0')}
/>,
);
} else if (!disableUserProvidedMedia && c === ':') {

View File

@ -77,6 +77,7 @@ const MediaItem: React.FC<IMediaItem> = ({ attachment, onOpenMedia, isLast }) =>
alt={attachment.description}
style={{ objectPosition: `${x}% ${y}%` }}
className={clsx('size-full overflow-hidden', { 'rounded-br-md': isLast })}
blurhash={attachment.blurhash}
/>
);
} else if (['gifv', 'video'].indexOf(attachment.type) !== -1) {