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