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} alt={attachment.description}
letterboxed={letterboxed} letterboxed={letterboxed}
showExt showExt
blurhash={attachment.blurhash}
/> />
</a> </a>
{attachment.description && ( {attachment.description && (

View File

@ -1,6 +1,7 @@
import clsx from 'clsx'; 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'; import { useSettings } from '@/stores/settings';
interface IStillImage { interface IStillImage {
@ -28,20 +29,31 @@ interface IStillImage {
isGif?: boolean; isGif?: boolean;
/** Specify that the group is defined by the parent */ /** Specify that the group is defined by the parent */
noGroup?: boolean; 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. */ /** Renders images on a canvas, only playing GIFs if autoPlayGif is enabled. */
const StillImage: React.FC<IStillImage> = ({ 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 { autoPlayGif } = useSettings();
const canvas = useRef<HTMLCanvasElement>(null); const canvas = useRef<HTMLCanvasElement>(null);
const img = useRef<HTMLImageElement>(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 hoverToPlay = src && !autoPlayGif && (isGif || src.endsWith('.gif') || src.startsWith('blob:') || (src && staticSrc && src !== staticSrc));
const handleImageLoad: React.ReactEventHandler<HTMLImageElement> = (e) => { const handleImageLoad: React.ReactEventHandler<HTMLImageElement> = (e) => {
setLoaded(true);
if (hoverToPlay && !staticSrc && canvas.current && img.current) { if (hoverToPlay && !staticSrc && canvas.current && img.current) {
canvas.current.width = img.current.naturalWidth; canvas.current.width = img.current.naturalWidth;
canvas.current.height = img.current.naturalHeight; canvas.current.height = img.current.naturalHeight;
@ -69,14 +81,26 @@ const StillImage: React.FC<IStillImage> = ({
className={clsx(className, 'relative isolate', { 'group': !noGroup })} className={clsx(className, 'relative isolate', { 'group': !noGroup })}
style={style} 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 <img
src={src} src={src}
alt={alt} alt={alt}
ref={img} ref={img}
decoding='async'
onLoad={handleImageLoad} onLoad={handleImageLoad}
onError={onError} onError={onError}
className={clsx(baseClassName, { className={clsx(baseClassName, 'no-reduce-motion:transition-opacity no-reduce-motion:duration-200', {
'invisible group-hover:visible': hoverToPlay, 'invisible group-hover:visible': hoverToPlay,
'opacity-0': !loaded,
})} })}
/> />
@ -84,7 +108,9 @@ const StillImage: React.FC<IStillImage> = ({
<img <img
src={staticSrc} src={staticSrc}
alt={alt} 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 <canvas

View File

@ -1,3 +1,4 @@
import clsx from 'clsx';
import React from 'react'; import React from 'react';
import StillImage from '@/components/still-image'; import StillImage from '@/components/still-image';
@ -40,6 +41,7 @@ const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
isGif isGif
noGroup={noGroup} noGroup={noGroup}
letterboxed letterboxed
placeholder='none'
{...rest} {...rest}
/> />
); );
@ -49,10 +51,13 @@ const Emoji: React.FC<IEmoji> = (props): JSX.Element | null => {
return ( return (
<img <img
{...rest}
draggable='false' draggable='false'
alt={alt || emoji} alt={alt || emoji}
src={joinPublicPath(`packs/emoji/${filename}.svg`)} 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 <img
key={index} key={index}
draggable={false} 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} alt={c}
title={`:${shortcode}:`} title={`:${shortcode}:`}
src={joinPublicPath(`packs/emoji/${unified}.svg`)} src={joinPublicPath(`packs/emoji/${unified}.svg`)}
decoding='async'
onLoad={(e) => e.currentTarget.classList.remove('opacity-0')}
/>, />,
); );
} else if (!systemEmojiFont && unqualified in unicodeMapping) { } else if (!systemEmojiFont && unqualified in unicodeMapping) {
@ -108,10 +110,12 @@ const Emojify: React.FC<IEmojify> = React.memo(({ text, emojis = {}, large }) =>
<img <img
key={index} key={index}
draggable={false} 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} alt={unqualified}
title={`:${shortcode}:`} title={`:${shortcode}:`}
src={joinPublicPath(`packs/emoji/${unified}.svg`)} src={joinPublicPath(`packs/emoji/${unified}.svg`)}
decoding='async'
onLoad={(e) => e.currentTarget.classList.remove('opacity-0')}
/>, />,
); );
} else if (!disableUserProvidedMedia && c === ':') { } else if (!disableUserProvidedMedia && c === ':') {

View File

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