diff --git a/src/hooks/use-fetch-gif-first-frame.ts b/src/hooks/use-fetch-gif-first-frame.ts index 97c86cba..88c85bd3 100644 --- a/src/hooks/use-fetch-gif-first-frame.ts +++ b/src/hooks/use-fetch-gif-first-frame.ts @@ -1,16 +1,14 @@ import { useEffect, useState } from 'react'; +import localForageLru from '@plebbit/plebbit-react-hooks/dist/lib/localforage-lru/index.js'; -const GIF_FRAME_CACHE_KEY = 'gifFrameCache'; +const gifFrameDb = localForageLru.createInstance({ name: 'plebchanGifFrames', size: 500 }); -const getCachedGifFrame = (url: string): string | null => { - const cache = JSON.parse(localStorage.getItem(GIF_FRAME_CACHE_KEY) || '{}'); - return cache[url] || null; +const getCachedGifFrame = async (url: string): Promise => { + return await gifFrameDb.getItem(url); }; -const setCachedGifFrame = (url: string, frameUrl: string): void => { - const cache = JSON.parse(localStorage.getItem(GIF_FRAME_CACHE_KEY) || '{}'); - cache[url] = frameUrl; - localStorage.setItem(GIF_FRAME_CACHE_KEY, JSON.stringify(cache)); +const setCachedGifFrame = async (url: string, frameUrl: string): Promise => { + await gifFrameDb.setItem(url, frameUrl); }; export const fetchImage = (url: string): Promise => { @@ -75,7 +73,7 @@ const useFetchGifFirstFrame = (url: string | undefined) => { const fetchFrame = async () => { try { - const cachedFrame = getCachedGifFrame(url); + const cachedFrame = await getCachedGifFrame(url); if (cachedFrame) { if (isActive) setFrameUrl(cachedFrame); return; @@ -85,7 +83,7 @@ const useFetchGifFirstFrame = (url: string | undefined) => { const objectUrl = URL.createObjectURL(blob); if (isActive) { setFrameUrl(objectUrl); - setCachedGifFrame(url, objectUrl); + await setCachedGifFrame(url, objectUrl); } } catch (error) { console.error('Failed to load GIF frame:', error); @@ -95,7 +93,6 @@ const useFetchGifFirstFrame = (url: string | undefined) => { fetchFrame(); - // Cleanup function to avoid setting state on unmounted component return () => { isActive = false; }; diff --git a/src/lib/utils/media-utils.ts b/src/lib/utils/media-utils.ts index 04807852..a003350c 100644 --- a/src/lib/utils/media-utils.ts +++ b/src/lib/utils/media-utils.ts @@ -1,3 +1,4 @@ +import localForageLru from '@plebbit/plebbit-react-hooks/dist/lib/localforage-lru/index.js'; import { Comment } from '@plebbit/plebbit-react-hooks'; import extName from 'ext-name'; import { canEmbed } from '../../components/embed'; @@ -88,7 +89,7 @@ export const getLinkMediaInfo = memoize( } try { - mime = extName(new URL(link).pathname.toLowerCase().replace('/', ''))[0]?.mime; + mime = extName(url.pathname.toLowerCase().replace('/', ''))[0]?.mime; if (mime) { if (mime.startsWith('image')) { type = mime === 'image/gif' ? 'gif' : 'image'; @@ -119,14 +120,41 @@ export const getLinkMediaInfo = memoize( const fetchWebpageThumbnail = async (url: string): Promise => { try { let html: string; + const MAX_HTML_SIZE = 1024 * 1024; + const TIMEOUT = 5000; + if (Capacitor.isNativePlatform()) { // in the native app, the Capacitor HTTP plugin is used to fetch the thumbnail - const response = await CapacitorHttp.get({ url }); - html = response.data; + const response = await CapacitorHttp.get({ + url, + readTimeout: TIMEOUT, + connectTimeout: TIMEOUT, + responseType: 'text', + headers: { Accept: 'text/html', Range: `bytes=0-${MAX_HTML_SIZE - 1}` }, + }); + html = response.data.slice(0, MAX_HTML_SIZE); } else { // some sites have CORS access, from which the thumbnail can be fetched client-side, which is helpful if subplebbit.settings.fetchThumbnailUrls is false - const response = await fetch(url); - html = await response.text(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), TIMEOUT); + + const response = await fetch(url, { + signal: controller.signal, + headers: { Accept: 'text/html' }, + }); + + clearTimeout(timeoutId); + + if (!response.ok) throw new Error('Network response was not ok'); + + const reader = response.body?.getReader(); + let result = ''; + while (true) { + const { done, value } = await reader!.read(); + if (done || result.length >= MAX_HTML_SIZE) break; + result += new TextDecoder().decode(value); + } + html = result.slice(0, MAX_HTML_SIZE); } const parser = new DOMParser(); @@ -201,29 +229,27 @@ export const getMediaDimensions = (commentMediaInfo: CommentMediaInfo | undefine return ''; }; +const thumbnailUrlsDb = localForageLru.createInstance({ name: 'plebchanThumbnailUrls', size: 500 }); + +export const getCachedThumbnail = async (url: string): Promise => { + return await thumbnailUrlsDb.getItem(url); +}; + +export const setCachedThumbnail = async (url: string, thumbnail: string): Promise => { + await thumbnailUrlsDb.setItem(url, thumbnail); +}; + export const fetchWebpageThumbnailIfNeeded = async (commentMediaInfo: CommentMediaInfo): Promise => { if (commentMediaInfo.type === 'webpage' && !commentMediaInfo.thumbnail) { - const cachedThumbnail = getCachedThumbnail(commentMediaInfo.url); + const cachedThumbnail = await getCachedThumbnail(commentMediaInfo.url); if (cachedThumbnail) { return { ...commentMediaInfo, thumbnail: cachedThumbnail }; } const thumbnail = await fetchWebpageThumbnail(commentMediaInfo.url); if (thumbnail) { - setCachedThumbnail(commentMediaInfo.url, thumbnail); + await setCachedThumbnail(commentMediaInfo.url, thumbnail); } return { ...commentMediaInfo, thumbnail }; } return commentMediaInfo; }; -const THUMBNAIL_CACHE_KEY = 'webpageThumbnailCache'; - -export const getCachedThumbnail = (url: string): string | null => { - const cache = JSON.parse(localStorage.getItem(THUMBNAIL_CACHE_KEY) || '{}'); - return cache[url] || null; -}; - -export const setCachedThumbnail = (url: string, thumbnail: string): void => { - const cache = JSON.parse(localStorage.getItem(THUMBNAIL_CACHE_KEY) || '{}'); - cache[url] = thumbnail; - localStorage.setItem(THUMBNAIL_CACHE_KEY, JSON.stringify(cache)); -}; diff --git a/src/views/board/board.module.css b/src/views/board/board.module.css index cb16a088..9fd60433 100644 --- a/src/views/board/board.module.css +++ b/src/views/board/board.module.css @@ -4,6 +4,7 @@ } .footer a, .newerPostsButton { + cursor: pointer; text-decoration: var(--button-text-decoration); color: var(--button-desktop-text-color); }