From 34e2c2bc4da23a3f739192dc081640bfb0ef829d Mon Sep 17 00:00:00 2001 From: plebeius Date: Sat, 7 Mar 2026 21:16:13 +0800 Subject: [PATCH] fix: hold gif thumbnails until first frame is ready Distinguish GIF frame loading from GIF frame failure in useFetchGifFirstFrame(), so thread and catalog thumbnails no longer render animated GIFs before the still frame arrives. Keep the existing animated fallback when the first-frame fetch actually fails. --- src/components/catalog-row/catalog-row.tsx | 6 +- .../comment-media/comment-media.module.css | 37 ++++++++++--- .../comment-media/comment-media.tsx | 55 ++++++++++++------- src/components/post-desktop/post-desktop.tsx | 6 +- src/components/post-form/post-form.tsx | 6 +- src/hooks/use-fetch-gif-first-frame.ts | 41 ++++++++++---- src/lib/utils/media-utils.ts | 2 + 7 files changed, 108 insertions(+), 45 deletions(-) diff --git a/src/components/catalog-row/catalog-row.tsx b/src/components/catalog-row/catalog-row.tsx index 3492a6b7..65df2b4d 100644 --- a/src/components/catalog-row/catalog-row.tsx +++ b/src/components/catalog-row/catalog-row.tsx @@ -35,7 +35,7 @@ interface CatalogPostMediaProps { export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight }: CatalogPostMediaProps) => { const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {}; const iframeThumbnail = patternThumbnailUrl || thumbnail; - const gifFrameUrl = useFetchGifFirstFrame(type === 'gif' ? url : undefined); + const { frameUrl: gifFrameUrl, status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? url : undefined); const [isLoaded, setIsLoaded] = useState(false); const [hasError, setHasError] = useState(false); const handleLoad = () => setIsLoaded(true); @@ -76,8 +76,10 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight let thumbnailComponent: React.ReactNode = null; - if (type === 'gif' && gifFrameUrl && !hasError) { + if (type === 'gif' && gifFrameStatus === 'ready' && gifFrameUrl && !hasError) { thumbnailComponent = ; + } else if (type === 'gif' && gifFrameStatus === 'failed' && !hasError) { + thumbnailComponent = ; } else if (type === 'image' && !hasError) { thumbnailComponent = ; } else if (type === 'video' && !hasError) { diff --git a/src/components/comment-media/comment-media.module.css b/src/components/comment-media/comment-media.module.css index fc893763..dafd562a 100644 --- a/src/components/comment-media/comment-media.module.css +++ b/src/components/comment-media/comment-media.module.css @@ -11,7 +11,8 @@ overflow: hidden; } -.floatingEmbed, .subplebbitAvatar { +.floatingEmbed, +.subplebbitAvatar { max-width: 250px; max-height: 250px; display: inline-flex; @@ -53,7 +54,8 @@ overflow: hidden; } -.thumbnailSmall span, .thumbnailSmall a { +.thumbnailSmall span, +.thumbnailSmall a { width: 100%; display: inline-block; word-wrap: break-word; @@ -81,7 +83,10 @@ display: block; } -.content img, .content video, .content iframe, .content audio { +.content img, +.content video, +.content iframe, +.content audio { max-width: 100%; max-height: 100%; object-fit: contain; @@ -91,6 +96,13 @@ cursor: pointer; } +.gifPlaceholder { + width: 100%; + height: 100%; + display: inline-block; + cursor: pointer; +} + .content iframe { border: none; color-scheme: light; @@ -99,7 +111,7 @@ .fileInfo { padding-bottom: 5px; text-align: center; - color: var(--post-mobile-file-info-text-color); + color: var(--post-mobile-file-info-text-color); font-size: 9pt; } @@ -125,11 +137,17 @@ margin: 8px 10px 5px 5px; } -.mediaMobile img, .mediaMobile video, .mediaMobile iframe, .mediaMobile audio { +.mediaMobile img, +.mediaMobile video, +.mediaMobile iframe, +.mediaMobile audio { padding: 3px 0 5px 0; } -.mediaDesktopReply img, .mediaDesktopReply video, .mediaDesktopReply iframe, .mediaDesktopReply audio { +.mediaDesktopReply img, +.mediaDesktopReply video, +.mediaDesktopReply iframe, +.mediaDesktopReply audio { padding: 3px 20px 5px 20px; } @@ -138,7 +156,10 @@ box-sizing: border-box; } -.mediaDesktopOp img, .mediaDesktopOp video, .mediaDesktopOp iframe, .mediaDesktopOp audio { +.mediaDesktopOp img, +.mediaDesktopOp video, +.mediaDesktopOp iframe, +.mediaDesktopOp audio { padding: 3px 20px 5px 20px; max-width: 100%; box-sizing: border-box; @@ -164,4 +185,4 @@ .fitToScreen video { max-width: 100%; max-height: 100vh; -} \ No newline at end of file +} diff --git a/src/components/comment-media/comment-media.tsx b/src/components/comment-media/comment-media.tsx index 30b7ed6d..4353acd9 100644 --- a/src/components/comment-media/comment-media.tsx +++ b/src/components/comment-media/comment-media.tsx @@ -45,25 +45,42 @@ const Thumbnail = ({ let thumbnailComponent: React.ReactNode = null; const iframeThumbnail = patternThumbnailUrl || thumbnail; - const gifFrameUrl = useFetchGifFirstFrame(type === 'gif' ? url : undefined); + const { frameUrl: gifFrameUrl, status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? url : undefined); const hasThumbnail = getHasThumbnail(commentMediaInfo, url); + const gifThumbnailButtonProps = + gifFrameStatus === 'loading' + ? { + role: 'button' as const, + tabIndex: 0, + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setShowThumbnail(false); + } + }, + onClick: () => setShowThumbnail(false), + } + : {}; if (type === 'gif') { - thumbnailComponent = ( - { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setShowThumbnail(false); - } - }} - onClick={() => setShowThumbnail(false)} - /> - ); + thumbnailComponent = + gifFrameStatus === 'loading' ? ( + + ) : ( + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setShowThumbnail(false); + } + }} + onClick={() => setShowThumbnail(false)} + /> + ); } else if (type === 'video') { thumbnailComponent = thumbnail ? ( @@ -405,11 +422,11 @@ const CommentMedia = ({ const isMobile = useIsMobile(); const { thumbnailHeight, thumbnailWidth, url } = commentMediaInfo || {}; let type = commentMediaInfo?.type; - const gifFrameUrl = useFetchGifFirstFrame(type === 'gif' ? url : undefined); + const { status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? url : undefined); - if (type === 'gif' && gifFrameUrl) { + if (type === 'gif' && gifFrameStatus === 'ready') { type = 'animated gif'; - } else if (type === 'gif' && !gifFrameUrl) { + } else if (type === 'gif' && gifFrameStatus === 'failed') { type = 'static gif'; } diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index b48967a8..91ae3b28 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -590,12 +590,12 @@ const PostMedia = ({ const { t } = useTranslation(); const { url } = commentMediaInfo || {}; let type = commentMediaInfo?.type; - const gifFrameUrl = useFetchGifFirstFrame(type === 'gif' ? url : undefined); + const { status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? url : undefined); const directories = useDirectories(); - if (type === 'gif' && gifFrameUrl !== null) { + if (type === 'gif' && gifFrameStatus === 'ready') { type = 'animated gif'; - } else if (type === 'gif' && gifFrameUrl === null) { + } else if (type === 'gif' && gifFrameStatus === 'failed') { type = 'static gif'; } diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index cc012378..5eb18bd1 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -39,11 +39,11 @@ export const LinkTypePreviewer = ({ link }: { link: string }) => { const { t } = useTranslation(); const mediaInfo = getLinkMediaInfo(link); let type = mediaInfo?.type; - const gifFrameUrl = useFetchGifFirstFrame(type === 'gif' ? mediaInfo?.url : undefined); + const { status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? mediaInfo?.url : undefined); - if (type === 'gif' && gifFrameUrl !== null) { + if (type === 'gif' && gifFrameStatus === 'ready') { type = t('animated_gif'); - } else if (type === 'gif' && gifFrameUrl === null) { + } else if (type === 'gif') { type = t('gif'); } diff --git a/src/hooks/use-fetch-gif-first-frame.ts b/src/hooks/use-fetch-gif-first-frame.ts index 4b3d0272..5cc0a5d6 100644 --- a/src/hooks/use-fetch-gif-first-frame.ts +++ b/src/hooks/use-fetch-gif-first-frame.ts @@ -4,6 +4,13 @@ import localForageLru from '@bitsocialhq/bitsocial-react-hooks/dist/lib/localfor const gifFrameDb = localForageLru.createInstance({ name: '5chanGifFrames', size: 500 }); const failedUrls = new Set(); +type GifFirstFrameStatus = 'idle' | 'loading' | 'ready' | 'failed'; + +interface GifFirstFrameState { + frameUrl: string | null; + status: GifFirstFrameStatus; +} + const getCachedGifFrame = async (url: string): Promise => { return await gifFrameDb.getItem(url); }; @@ -40,10 +47,20 @@ export const readImage = (file: File): Promise => { const parseGif = async (buf: ArrayBuffer): Promise => { const image = new Image(); - await new Promise((resolve) => { - image.src = URL.createObjectURL(new Blob([buf])); - image.onload = resolve; + const sourceUrl = URL.createObjectURL(new Blob([buf])); + + await new Promise((resolve, reject) => { + image.onload = () => { + URL.revokeObjectURL(sourceUrl); + resolve(undefined); + }; + image.onerror = () => { + URL.revokeObjectURL(sourceUrl); + reject(new Error('Failed to parse GIF')); + }; + image.src = sourceUrl; }); + const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height; @@ -62,28 +79,30 @@ const parseGif = async (buf: ArrayBuffer): Promise => { }; const useFetchGifFirstFrame = (url: string | undefined) => { - const [frameUrl, setFrameUrl] = useState(null); + const [gifFirstFrame, setGifFirstFrame] = useState({ frameUrl: null, status: 'idle' }); useEffect(() => { if (!url) { - setFrameUrl(null); + setGifFirstFrame({ frameUrl: null, status: 'idle' }); return; } let isActive = true; + setGifFirstFrame({ frameUrl: null, status: 'loading' }); const fetchFrame = async () => { if (failedUrls.has(url)) { - if (isActive) setFrameUrl(null); + if (isActive) setGifFirstFrame({ frameUrl: null, status: 'failed' }); return; } + try { const cachedFrame = await getCachedGifFrame(url); if (cachedFrame) { try { const response = await fetch(cachedFrame); if (response.ok) { - if (isActive) setFrameUrl(cachedFrame); + if (isActive) setGifFirstFrame({ frameUrl: cachedFrame, status: 'ready' }); return; } } catch {} @@ -92,13 +111,15 @@ const useFetchGifFirstFrame = (url: string | undefined) => { const blob = typeof url === 'string' ? await parseGif(await fetchImage(url)) : await parseGif(await readImage(url as File)); const objectUrl = URL.createObjectURL(blob); if (isActive) { - setFrameUrl(objectUrl); + setGifFirstFrame({ frameUrl: objectUrl, status: 'ready' }); await setCachedGifFrame(url, objectUrl); + } else { + URL.revokeObjectURL(objectUrl); } } catch (error) { failedUrls.add(url); console.error('Failed to load GIF frame:', error); - if (isActive) setFrameUrl(null); + if (isActive) setGifFirstFrame({ frameUrl: null, status: 'failed' }); } }; @@ -109,7 +130,7 @@ const useFetchGifFirstFrame = (url: string | undefined) => { }; }, [url]); - return frameUrl; + return gifFirstFrame; }; export default useFetchGifFirstFrame; diff --git a/src/lib/utils/media-utils.ts b/src/lib/utils/media-utils.ts index fd08e2d9..506f91f0 100644 --- a/src/lib/utils/media-utils.ts +++ b/src/lib/utils/media-utils.ts @@ -19,6 +19,8 @@ export const getDisplayMediaInfoType = (type: string, t: any) => { switch (type) { case 'image': return t('image'); + case 'gif': + return t('gif'); case 'animated gif': return t('animated_gif'); case 'static gif':