add better loading states to media
Some checks failed
Some checks failed
This commit is contained in:
@ -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 && (
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 === ':') {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user