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':