adding icon cache

This commit is contained in:
2026-08-07 12:45:12 -03:00
parent 552379b2a6
commit 7635471ed6
4 changed files with 237 additions and 9 deletions
+50 -2
View File
@@ -1,5 +1,6 @@
import React, { memo, useState } from 'react';
import React, { memo, useEffect, useState } from 'react';
import { Website } from '../types';
import { cacheWebsiteIcon, getCachedWebsiteIcon, removeCachedWebsiteIcon } from './utils/iconService';
interface WebsiteTileProps {
website: Website;
@@ -52,6 +53,46 @@ const getIconLoadingPixelSize = (size: string | undefined): number => {
const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, onMove, tileSize }) => {
const [isLoading, setIsLoading] = useState(false);
const [iconSource, setIconSource] = useState<string | null>(null);
const [usingCachedIcon, setUsingCachedIcon] = useState(false);
useEffect(() => {
let cancelled = false;
setIconSource(null);
setUsingCachedIcon(false);
const loadIcon = async () => {
const cachedIcon = await getCachedWebsiteIcon(website.icon);
if (cancelled) return;
if (cachedIcon) {
setIconSource(cachedIcon);
setUsingCachedIcon(cachedIcon !== website.icon);
return;
}
setIconSource(website.icon);
const newlyCachedIcon = await cacheWebsiteIcon(website.icon);
if (!cancelled && newlyCachedIcon) {
setIconSource(newlyCachedIcon);
setUsingCachedIcon(newlyCachedIcon !== website.icon);
}
};
void loadIcon();
return () => {
cancelled = true;
};
}, [website.icon]);
const handleIconError = () => {
if (!usingCachedIcon) return;
setIconSource(website.icon);
setUsingCachedIcon(false);
void removeCachedWebsiteIcon(website.icon);
};
const handleClick = (e: React.MouseEvent) => {
if (isEditing) {
@@ -84,7 +125,14 @@ const WebsiteTile: React.FC<WebsiteTileProps> = ({ website, isEditing, onEdit, o
)}
<div className={`relative z-10 flex items-center transition-all duration-200 ease-ios ${isLoading ? 'translate-y-5 gap-2' : 'flex-col gap-3'}`}>
<div className={`transition-all duration-200 ease-ios drop-shadow-[0_10px_20px_rgba(0,0,0,0.28)] ${isLoading ? iconSizeLoadingClass : iconSizeClass}`}>
<img src={website.icon} alt={`${website.name} icon`} className="object-contain w-full h-full" />
{iconSource && (
<img
src={iconSource}
alt={`${website.name} icon`}
className="object-contain w-full h-full"
onError={handleIconError}
/>
)}
</div>
<span className={`max-w-full px-1 text-slate-50 font-semibold text-base text-center leading-tight transition-all duration-200 ease-ios [text-shadow:0_2px_12px_rgba(2,6,23,0.44)] ${isLoading ? 'text-sm' : ''}`}>
{website.name}
+56 -1
View File
@@ -18,6 +18,11 @@ declare global {
let isChromeStorageLocalAvailable: boolean | null = null;
const ICON_CACHE_KEY_PREFIX = 'vision-start:icon:';
const getIconCacheKey = (sourceUrl: string): string =>
`${ICON_CACHE_KEY_PREFIX}${encodeURIComponent(sourceUrl)}`;
/**
* Checks if chrome.storage.local is available and caches the result.
@@ -32,6 +37,56 @@ export function checkChromeStorageLocalAvailable(): boolean {
return isChromeStorageLocalAvailable;
}
export async function getCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<string | null> {
if (!checkChromeStorageLocalAvailable()) return null;
return new Promise<string | null>((resolve) => {
if (!window.chrome?.storage?.local) {
resolve(null);
return;
}
const key = getIconCacheKey(sourceUrl);
window.chrome.storage.local.get([key], function (result: { [key: string]: string }) {
if (window.chrome?.runtime?.lastError) {
resolve(null);
return;
}
resolve(result[key] || null);
});
});
}
export async function saveCachedIconToChromeStorageLocal(sourceUrl: string, dataUrl: string): Promise<boolean> {
if (!checkChromeStorageLocalAvailable()) return false;
return new Promise<boolean>((resolve) => {
if (!window.chrome?.storage?.local) {
resolve(false);
return;
}
window.chrome.storage.local.set({ [getIconCacheKey(sourceUrl)]: dataUrl }, function () {
resolve(!window.chrome?.runtime?.lastError);
});
});
}
export async function removeCachedIconFromChromeStorageLocal(sourceUrl: string): Promise<boolean> {
if (!checkChromeStorageLocalAvailable()) return false;
return new Promise<boolean>((resolve) => {
if (!window.chrome?.storage?.local) {
resolve(false);
return;
}
window.chrome.storage.local.remove(getIconCacheKey(sourceUrl), function () {
resolve(!window.chrome?.runtime?.lastError);
});
});
}
/**
* Adds a new wallpaper to chrome.storage.local.
* If the URL is fetchable, it will be stored as base64 and the name will be derived from the URL.
@@ -161,4 +216,4 @@ export async function removeWallpaperFromChromeStorageLocal(name: string): Promi
reject(new Error('chrome.storage.local is not available'));
}
});
}
}
+119 -1
View File
@@ -1,3 +1,42 @@
import {
checkChromeStorageLocalAvailable,
getCachedIconFromChromeStorageLocal,
removeCachedIconFromChromeStorageLocal,
saveCachedIconToChromeStorageLocal,
} from './StorageLocalManager';
const MAX_CACHED_ICON_BYTES = 256 * 1024;
const resolvedIconCache = new Map<string, string>();
const iconCacheLookups = new Map<string, Promise<string | null>>();
const iconCacheRequests = new Map<string, Promise<string | null>>();
const isDataUrl = (value: string): boolean => value.startsWith('data:');
const isCacheableIconUrl = (value: string): boolean => {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
};
const isValidCachedIcon = (value: string | null): value is string =>
typeof value === 'string' && isDataUrl(value);
const blobToDataUrl = (blob: Blob): Promise<string> =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('Could not convert icon to a data URL'));
}
};
reader.onerror = () => reject(reader.error || new Error('Could not read icon data'));
reader.readAsDataURL(blob);
});
async function getWebsiteIcon(url: string): Promise<string> {
try {
@@ -28,4 +67,83 @@ async function getWebsiteIcon(url: string): Promise<string> {
return `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=128`;
}
export { getWebsiteIcon };
async function getCachedWebsiteIcon(iconUrl: string): Promise<string | null> {
if (isDataUrl(iconUrl)) return iconUrl;
const inMemoryIcon = resolvedIconCache.get(iconUrl);
if (inMemoryIcon) return inMemoryIcon;
if (!isCacheableIconUrl(iconUrl) || !checkChromeStorageLocalAvailable()) return null;
const existingLookup = iconCacheLookups.get(iconUrl);
if (existingLookup) return existingLookup;
const lookup = getCachedIconFromChromeStorageLocal(iconUrl)
.then((cachedIcon) => {
if (isValidCachedIcon(cachedIcon)) {
resolvedIconCache.set(iconUrl, cachedIcon);
return cachedIcon;
}
return null;
})
.catch(() => null)
.finally(() => {
iconCacheLookups.delete(iconUrl);
});
iconCacheLookups.set(iconUrl, lookup);
return lookup;
}
async function cacheWebsiteIcon(iconUrl: string): Promise<string | null> {
if (!isCacheableIconUrl(iconUrl) || !checkChromeStorageLocalAvailable()) return null;
const inMemoryIcon = resolvedIconCache.get(iconUrl);
if (inMemoryIcon) return inMemoryIcon;
const existingRequest = iconCacheRequests.get(iconUrl);
if (existingRequest) return existingRequest;
const request = (async () => {
const cachedIcon = await getCachedWebsiteIcon(iconUrl);
if (cachedIcon) return cachedIcon;
try {
const response = await fetch(iconUrl, { mode: 'cors' });
if (!response.ok || response.type === 'opaque') return null;
const blob = await response.blob();
const contentType = (blob.type || response.headers.get('content-type') || '')
.split(';', 1)[0]
.trim()
.toLowerCase();
if (!contentType.startsWith('image/') || blob.size === 0 || blob.size > MAX_CACHED_ICON_BYTES) {
return null;
}
const dataUrl = await blobToDataUrl(blob);
resolvedIconCache.set(iconUrl, dataUrl);
await saveCachedIconToChromeStorageLocal(iconUrl, dataUrl);
return dataUrl;
} catch {
return null;
}
})().finally(() => {
iconCacheRequests.delete(iconUrl);
});
iconCacheRequests.set(iconUrl, request);
return request;
}
async function removeCachedWebsiteIcon(iconUrl: string): Promise<void> {
resolvedIconCache.delete(iconUrl);
iconCacheLookups.delete(iconUrl);
await removeCachedIconFromChromeStorageLocal(iconUrl);
}
export {
cacheWebsiteIcon,
getCachedWebsiteIcon,
getWebsiteIcon,
removeCachedWebsiteIcon,
};