mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: refresh sender icons that changed on the sender's website #960
This commit is contained in:
@@ -18,6 +18,9 @@ const cache = new Map<string, CacheEntry>();
|
||||
const negativeCache = new Map<string, NegativeCacheEntry>();
|
||||
const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
|
||||
const NEGATIVE_CACHE_MAX_SIZE = 2000;
|
||||
// A refresh request (?v=<ms>) refetches a domain at most this often, so the
|
||||
// unauthenticated route can't be used to hammer the upstream icon service.
|
||||
const MIN_REFRESH_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
// 1x1 transparent PNG. Returned with HTTP 200 (instead of 404) when no
|
||||
// favicon exists for a domain, so the browser's <img> tag loads it cleanly
|
||||
@@ -444,16 +447,17 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// Resolve to root domain so subdomains share the same favicon lookup
|
||||
const normalizedDomain = getRootDomain(domain.toLowerCase());
|
||||
const refreshAfter = parseRefreshAfter(request.nextUrl.searchParams.get('v'));
|
||||
|
||||
// Check negative cache (domains known to have no favicon)
|
||||
const neg = negativeCache.get(normalizedDomain);
|
||||
if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS) {
|
||||
if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS && !isStale(neg.fetchedAt, refreshAfter)) {
|
||||
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
|
||||
}
|
||||
|
||||
// Check cache
|
||||
const cached = cache.get(normalizedDomain);
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS && !isStale(cached.fetchedAt, refreshAfter)) {
|
||||
return new NextResponse(cached.data, {
|
||||
headers: faviconHeaders(cached.contentType),
|
||||
});
|
||||
@@ -490,6 +494,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
negativeCache.delete(normalizedDomain);
|
||||
evictOldest();
|
||||
cache.set(normalizedDomain, { data, contentType, fetchedAt: Date.now() });
|
||||
|
||||
@@ -497,6 +502,10 @@ export async function GET(request: NextRequest) {
|
||||
headers: faviconHeaders(contentType),
|
||||
});
|
||||
} catch {
|
||||
// A refresh that can't reach upstream keeps showing the icon we have.
|
||||
if (cached) {
|
||||
return new NextResponse(cached.data, { headers: faviconHeaders(cached.contentType) });
|
||||
}
|
||||
return new NextResponse(null, {
|
||||
status: 502,
|
||||
headers: { 'Cache-Control': 'public, max-age=300' }, // 5 min
|
||||
@@ -504,6 +513,22 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The time from a ?v= refresh stamp, or null. Avatars send the moment the
|
||||
* user asked for fresh sender icons (#960); a stamp in the future is clamped
|
||||
* to now so it can't keep a domain permanently uncached.
|
||||
*/
|
||||
function parseRefreshAfter(value: string | null): number | null {
|
||||
if (!value || !/^\d{1,15}$/.test(value)) return null;
|
||||
return Math.min(Number(value), Date.now());
|
||||
}
|
||||
|
||||
/** Whether a cache entry predates the refresh the client asked for. */
|
||||
function isStale(fetchedAt: number, refreshAfter: number | null): boolean {
|
||||
if (refreshAfter === null || fetchedAt >= refreshAfter) return false;
|
||||
return Date.now() - fetchedAt >= MIN_REFRESH_INTERVAL_MS;
|
||||
}
|
||||
|
||||
const RASTER_FAVICON_TYPES = new Set([
|
||||
'image/x-icon',
|
||||
'image/vnd.microsoft.icon',
|
||||
|
||||
@@ -7,10 +7,12 @@ import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settin
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTour } from '@/components/tour/tour-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PlayCircle } from '@/components/icons';
|
||||
import { PlayCircle, RefreshCw } from '@/components/icons';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { MessageListOrderSettings } from './message-list-order-settings';
|
||||
import { useFaviconRefreshStore } from '@/stores/favicon-refresh-store';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
|
||||
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
|
||||
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
|
||||
@@ -71,6 +73,7 @@ export function AppearanceSettings() {
|
||||
const { theme, setTheme } = useThemeStore();
|
||||
const { fontSize, density, animationsEnabled, senderFavicons, showAvatarsInJunk, showOnboardingOnNewDevices, updateSetting } = useSettingsStore();
|
||||
const { startTour, resetTourCompletion } = useTour();
|
||||
const refreshFavicons = useFaviconRefreshStore((s) => s.refresh);
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
|
||||
return (
|
||||
@@ -137,6 +140,20 @@ export function AppearanceSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{!IS_LITE && senderFavicons && (
|
||||
<SettingItem label={tAdvanced('sender_favicons.refresh_label')} description={tAdvanced('sender_favicons.refresh_description')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { refreshFavicons(); toast.success(tAdvanced('sender_favicons.refresh_done')); }}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 me-1" />
|
||||
{tAdvanced('sender_favicons.refresh_button')}
|
||||
</Button>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem label={tAdvanced('show_avatars_in_junk.label')} description={tAdvanced('show_avatars_in_junk.description')}>
|
||||
<ToggleSwitch checked={showAvatarsInJunk} onChange={(checked) => updateSetting('showAvatarsInJunk', checked)} />
|
||||
</SettingItem>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { act, fireEvent, render } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Avatar } from '../avatar';
|
||||
import { useFaviconRefreshStore } from '@/stores/favicon-refresh-store';
|
||||
|
||||
describe('Avatar', () => {
|
||||
it('renders initials from full name', () => {
|
||||
@@ -30,4 +31,21 @@ describe('Avatar', () => {
|
||||
const colorB = (b.firstChild as HTMLElement).style.backgroundColor;
|
||||
expect(colorA).toBe(colorB);
|
||||
});
|
||||
|
||||
// #960: a refresh must reach past the browser's two-week cache of the old
|
||||
// icon and retry domains whose favicon failed before.
|
||||
it('requests the sender favicon again after a refresh', () => {
|
||||
useFaviconRefreshStore.setState({ refreshedAt: 0 });
|
||||
const { container } = render(<Avatar name="Jan" email="jan@example.cz" />);
|
||||
const img = () => container.querySelector('img');
|
||||
expect(img()?.getAttribute('src')).toBe('/api/favicon?domain=example.cz');
|
||||
|
||||
fireEvent.error(img()!);
|
||||
expect(img()).toBeNull();
|
||||
|
||||
act(() => useFaviconRefreshStore.getState().refresh());
|
||||
const stamp = useFaviconRefreshStore.getState().refreshedAt;
|
||||
expect(stamp).toBeGreaterThan(0);
|
||||
expect(img()?.getAttribute('src')).toBe(`/api/favicon?domain=example.cz&v=${stamp}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useConfig } from "@/hooks/use-config";
|
||||
import { avatarHooks } from "@/lib/plugin-hooks";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { IS_LITE } from "@/lib/lite";
|
||||
import { useFaviconRefreshStore, failedFaviconDomains } from "@/stores/favicon-refresh-store";
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV !== "production";
|
||||
|
||||
@@ -63,9 +64,6 @@ function getRootDomain(domain: string): string {
|
||||
return parts.slice(-2).join(".");
|
||||
}
|
||||
|
||||
// Module-level cache of domains whose favicons failed to load.
|
||||
// Shared across all Avatar instances to avoid re-requesting known-bad domains.
|
||||
const failedFaviconDomains = new Set<string>();
|
||||
// Personal email domains where the favicon is the mail provider logo, not the sender
|
||||
const PERSONAL_DOMAINS = new Set([
|
||||
"gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com",
|
||||
@@ -155,6 +153,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
|
||||
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
|
||||
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
|
||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
|
||||
const faviconRefreshedAt = useFaviconRefreshStore((s) => s.refreshedAt);
|
||||
const contacts = useContactStore((s) => s.contacts);
|
||||
const { devMode } = useConfig();
|
||||
|
||||
@@ -171,6 +170,11 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
|
||||
return () => { cancelled = true; };
|
||||
}, [email, name]);
|
||||
|
||||
// A refresh of the sender icons gives a failed favicon another chance.
|
||||
useEffect(() => {
|
||||
setImgError(false);
|
||||
}, [faviconRefreshedAt]);
|
||||
|
||||
// Look up contact photo by email from the contact store
|
||||
const resolvedContactPhoto = useMemo(() => {
|
||||
if (contactPhotoUri) return contactPhotoUri;
|
||||
@@ -238,7 +242,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
|
||||
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
||||
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
|
||||
const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null;
|
||||
const faviconSrc = !imgError && !domainFailed && showFavicon ? withBasePath(`/api/favicon?domain=${encodeURIComponent(faviconDomain!)}`) : null;
|
||||
const faviconSrc = !imgError && !domainFailed && showFavicon ? withBasePath(`/api/favicon?domain=${encodeURIComponent(faviconDomain!)}${faviconRefreshedAt ? `&v=${faviconRefreshedAt}` : ""}`) : null;
|
||||
const imgSrc = disableImages ? null : (photoSrc || faviconSrc);
|
||||
const isFavicon = imgSrc !== null && imgSrc === faviconSrc;
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
// #960: the route caches a domain's favicon for two weeks. A sender who
|
||||
// replaced their icon kept showing the old one; a ?v=<ms> refresh stamp newer
|
||||
// than the cached copy must fetch it again.
|
||||
|
||||
const T0 = Date.UTC(2026, 9, 1);
|
||||
let now = T0;
|
||||
let icon = 1;
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(new Uint8Array(64).fill(icon), { headers: { 'content-type': 'image/png' } }),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
now = T0;
|
||||
icon = 1;
|
||||
fetchMock.mockClear();
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
async function get(query: string) {
|
||||
const { GET } = await import('@/app/api/favicon/route');
|
||||
const res = await GET(new NextRequest(`https://webmail.example/api/favicon?${query}`));
|
||||
return new Uint8Array(await res.arrayBuffer())[0];
|
||||
}
|
||||
|
||||
describe('favicon route refresh', () => {
|
||||
it('serves the cached icon until a newer refresh stamp arrives', async () => {
|
||||
expect(await get('domain=example.cz')).toBe(1);
|
||||
|
||||
icon = 2; // the sender replaces their favicon
|
||||
now = T0 + 60 * 60 * 1000;
|
||||
expect(await get('domain=example.cz')).toBe(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(await get(`domain=example.cz&v=${now}`)).toBe(2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The fresh copy is cached again: same stamp, plain request, no refetch.
|
||||
expect(await get(`domain=example.cz&v=${now}`)).toBe(2);
|
||||
expect(await get('domain=example.cz')).toBe(2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('refetches a domain at most once a minute, even with future stamps', async () => {
|
||||
expect(await get('domain=example.cz')).toBe(1);
|
||||
icon = 2;
|
||||
now = T0 + 30 * 1000;
|
||||
expect(await get(`domain=example.cz&v=${now + 10 ** 9}`)).toBe(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the cached icon when the refresh cannot reach upstream', async () => {
|
||||
expect(await get('domain=example.cz')).toBe(1);
|
||||
now = T0 + 60 * 60 * 1000;
|
||||
fetchMock.mockRejectedValueOnce(new Error('offline'));
|
||||
expect(await get(`domain=example.cz&v=${now}`)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -2094,7 +2094,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "أيقونات مواقع المرسلين",
|
||||
"description": "إظهار أيقونات المواقع كصور شخصية للمرسلين من الشركات"
|
||||
"description": "إظهار أيقونات المواقع كصور شخصية للمرسلين من الشركات",
|
||||
"refresh_label": "تحديث أيقونات المرسلين",
|
||||
"refresh_description": "جلب أيقونات المواقع مرة أخرى، مثلاً عندما يغيّر المرسل أيقونته",
|
||||
"refresh_button": "تحديث",
|
||||
"refresh_done": "سيتم جلب أيقونات المرسلين مرة أخرى"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "إظهار الصور الشخصية في مجلد البريد المزعج",
|
||||
|
||||
@@ -2094,7 +2094,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Icones dels remitents",
|
||||
"description": "Mostra icones de llocs web com a imatges de perfil per als remitents d'empresa"
|
||||
"description": "Mostra icones de llocs web com a imatges de perfil per als remitents d'empresa",
|
||||
"refresh_label": "Actualitza les icones dels remitents",
|
||||
"refresh_description": "Torna a obtenir les icones dels llocs web, per exemple quan un remitent ha canviat la seva",
|
||||
"refresh_button": "Actualitza",
|
||||
"refresh_done": "Es tornaran a obtenir les icones dels remitents"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Mostra els avatars a la carpeta de brossa",
|
||||
|
||||
@@ -2087,7 +2087,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Favikony odesílatelů",
|
||||
"description": "Zobrazovat ikony webových stránek jako profilové obrázky firemních odesílatelů"
|
||||
"description": "Zobrazovat ikony webových stránek jako profilové obrázky firemních odesílatelů",
|
||||
"refresh_label": "Obnovit ikony odesílatelů",
|
||||
"refresh_description": "Znovu načíst ikony webů, například když odesílatel změnil svou ikonu",
|
||||
"refresh_button": "Obnovit",
|
||||
"refresh_done": "Ikony odesílatelů se načtou znovu"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Zobrazovat avatary ve spamu",
|
||||
|
||||
@@ -2090,7 +2090,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Afsender-favicons",
|
||||
"description": "Vis hjemmesideikoner som profilbilleder for forretningsafsendere"
|
||||
"description": "Vis hjemmesideikoner som profilbilleder for forretningsafsendere",
|
||||
"refresh_label": "Opdater afsenderikoner",
|
||||
"refresh_description": "Hent webstedsikonerne igen, f.eks. når en afsender har skiftet sit ikon",
|
||||
"refresh_button": "Opdater",
|
||||
"refresh_done": "Afsenderikonerne hentes igen"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Vis avatarer i spam-mappe",
|
||||
|
||||
@@ -2087,7 +2087,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Absender-Favicons",
|
||||
"description": "Website-Symbole als Profilbilder für geschäftliche Absender anzeigen"
|
||||
"description": "Website-Symbole als Profilbilder für geschäftliche Absender anzeigen",
|
||||
"refresh_label": "Absender-Symbole aktualisieren",
|
||||
"refresh_description": "Website-Symbole neu laden, zum Beispiel wenn ein Absender sein Symbol geändert hat",
|
||||
"refresh_button": "Aktualisieren",
|
||||
"refresh_done": "Absender-Symbole werden neu geladen"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Avatare im Spam-Ordner anzeigen",
|
||||
|
||||
@@ -2094,7 +2094,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Sender Favicons",
|
||||
"description": "Show website icons as profile pictures for business senders"
|
||||
"description": "Show website icons as profile pictures for business senders",
|
||||
"refresh_label": "Refresh Sender Icons",
|
||||
"refresh_description": "Fetch the website icons again, for example when a sender changed theirs",
|
||||
"refresh_button": "Refresh",
|
||||
"refresh_done": "Sender icons will be fetched again"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Show Avatars in Junk Folder",
|
||||
|
||||
@@ -2087,7 +2087,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Favicons de remitente",
|
||||
"description": "Mostrar iconos de sitios web como fotos de perfil para remitentes empresariales"
|
||||
"description": "Mostrar iconos de sitios web como fotos de perfil para remitentes empresariales",
|
||||
"refresh_label": "Actualizar iconos de remitentes",
|
||||
"refresh_description": "Volver a obtener los iconos de los sitios web, por ejemplo cuando un remitente ha cambiado el suyo",
|
||||
"refresh_button": "Actualizar",
|
||||
"refresh_done": "Los iconos de los remitentes se volverán a obtener"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Mostrar avatares en la carpeta de spam",
|
||||
|
||||
@@ -2094,7 +2094,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Favicon فرستنده",
|
||||
"description": "نمایش آیکون وبسایت به عنوان تصویر پروفایل فرستندگان تجاری"
|
||||
"description": "نمایش آیکون وبسایت به عنوان تصویر پروفایل فرستندگان تجاری",
|
||||
"refresh_label": "بازخوانی آیکون فرستندگان",
|
||||
"refresh_description": "آیکون وبسایتها را دوباره دریافت کن، مثلاً وقتی فرستنده آیکون خود را تغییر داده است",
|
||||
"refresh_button": "بازخوانی",
|
||||
"refresh_done": "آیکون فرستندگان دوباره دریافت میشود"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "نمایش آواتار در پوشه هرزنامه",
|
||||
|
||||
@@ -2087,7 +2087,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Favicons des expéditeurs",
|
||||
"description": "Afficher les icônes de sites web comme photos de profil pour les expéditeurs professionnels"
|
||||
"description": "Afficher les icônes de sites web comme photos de profil pour les expéditeurs professionnels",
|
||||
"refresh_label": "Actualiser les icônes des expéditeurs",
|
||||
"refresh_description": "Récupérer à nouveau les icônes des sites web, par exemple quand un expéditeur a changé la sienne",
|
||||
"refresh_button": "Actualiser",
|
||||
"refresh_done": "Les icônes des expéditeurs vont être récupérées à nouveau"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Afficher les avatars dans le dossier indésirable",
|
||||
|
||||
@@ -2052,7 +2052,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "סמלים של השולח (ניסיוני)",
|
||||
"description": "הצג סמלי אתרים כתמונות פרופיל עבור שולחים עסקיים"
|
||||
"description": "הצג סמלי אתרים כתמונות פרופיל עבור שולחים עסקיים",
|
||||
"refresh_label": "רענון סמלי שולחים",
|
||||
"refresh_description": "טעינה מחדש של סמלי האתרים, למשל כששולח החליף את הסמל שלו",
|
||||
"refresh_button": "רענון",
|
||||
"refresh_done": "סמלי השולחים ייטענו מחדש"
|
||||
},
|
||||
"sender_avatars": {},
|
||||
"keyboard_shortcuts": {
|
||||
|
||||
@@ -2090,7 +2090,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Feladó faviconok",
|
||||
"description": "Weboldal ikonok megjelenítése profilképekként az üzleti feladók számára"
|
||||
"description": "Weboldal ikonok megjelenítése profilképekként az üzleti feladók számára",
|
||||
"refresh_label": "Feladóikonok frissítése",
|
||||
"refresh_description": "A webhelyikonok újbóli letöltése, például ha egy feladó lecserélte a sajátját",
|
||||
"refresh_button": "Frissítés",
|
||||
"refresh_done": "A feladóikonok újra le lesznek töltve"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Avatárok mutatása a Spam mappában",
|
||||
|
||||
@@ -2087,7 +2087,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Favicon dei mittenti",
|
||||
"description": "Mostra le icone dei siti web come immagini profilo per i mittenti aziendali"
|
||||
"description": "Mostra le icone dei siti web come immagini profilo per i mittenti aziendali",
|
||||
"refresh_label": "Aggiorna icone dei mittenti",
|
||||
"refresh_description": "Scarica di nuovo le icone dei siti web, ad esempio quando un mittente ha cambiato la sua",
|
||||
"refresh_button": "Aggiorna",
|
||||
"refresh_done": "Le icone dei mittenti verranno scaricate di nuovo"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Mostra avatar nella cartella spam",
|
||||
|
||||
@@ -2087,7 +2087,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "送信者ファビコン",
|
||||
"description": "ビジネス送信者のプロフィール画像としてウェブサイトアイコンを表示"
|
||||
"description": "ビジネス送信者のプロフィール画像としてウェブサイトアイコンを表示",
|
||||
"refresh_label": "送信者アイコンを更新",
|
||||
"refresh_description": "送信者がアイコンを変更した場合などに、ウェブサイトのアイコンを再取得します",
|
||||
"refresh_button": "更新",
|
||||
"refresh_done": "送信者アイコンを再取得します"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "迷惑メールフォルダでアバターを表示",
|
||||
|
||||
@@ -2087,7 +2087,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "보낸 사람 파비콘",
|
||||
"description": "비즈니스 메일의 경우 해당 웹사이트의 아이콘을 프로필 사진으로 보여줘요"
|
||||
"description": "비즈니스 메일의 경우 해당 웹사이트의 아이콘을 프로필 사진으로 보여줘요",
|
||||
"refresh_label": "발신자 아이콘 새로 고침",
|
||||
"refresh_description": "발신자가 아이콘을 바꾼 경우 등에 웹사이트 아이콘을 다시 가져옵니다",
|
||||
"refresh_button": "새로 고침",
|
||||
"refresh_done": "발신자 아이콘을 다시 가져옵니다"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "스팸함에서 아바타 표시",
|
||||
|
||||
@@ -2068,7 +2068,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Sūtītāju ikonas",
|
||||
"description": "Rādīt vietņu ikonas kā sūtītāju avatarus"
|
||||
"description": "Rādīt vietņu ikonas kā sūtītāju avatarus",
|
||||
"refresh_label": "Atsvaidzināt sūtītāju ikonas",
|
||||
"refresh_description": "Vēlreiz ielādēt vietņu ikonas, piemēram, ja sūtītājs ir nomainījis savu ikonu",
|
||||
"refresh_button": "Atsvaidzināt",
|
||||
"refresh_done": "Sūtītāju ikonas tiks ielādētas vēlreiz"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Rādīt avatarus mēstuļu mapē",
|
||||
|
||||
@@ -2094,7 +2094,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Илгээгчийн Favicons",
|
||||
"description": "Вэб сайтын дүрсийг бизнес илгээгчдэд профайл зураг болгон харуулах"
|
||||
"description": "Вэб сайтын дүрсийг бизнес илгээгчдэд профайл зураг болгон харуулах",
|
||||
"refresh_label": "Илгээгчийн дүрсийг шинэчлэх",
|
||||
"refresh_description": "Вэбсайтын дүрсийг дахин татах, жишээ нь илгээгч дүрсээ сольсон үед",
|
||||
"refresh_button": "Шинэчлэх",
|
||||
"refresh_done": "Илгээгчийн дүрсийг дахин татна"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Хогийн хавтас дахь аватаруудыг харуулах",
|
||||
|
||||
@@ -2094,7 +2094,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Nettstedsikoner for avsendere",
|
||||
"description": "Vis nettstedsikoner som profilbilder for bedriftsavsendere"
|
||||
"description": "Vis nettstedsikoner som profilbilder for bedriftsavsendere",
|
||||
"refresh_label": "Oppdater avsenderikoner",
|
||||
"refresh_description": "Hent nettstedikonene på nytt, for eksempel når en avsender har byttet ikon",
|
||||
"refresh_button": "Oppdater",
|
||||
"refresh_done": "Avsenderikonene hentes på nytt"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Vis profilbilder i søppelpostmappen",
|
||||
|
||||
@@ -2087,7 +2087,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Afzender-favicons",
|
||||
"description": "Toon websitepictogrammen als profielfoto's voor zakelijke afzenders"
|
||||
"description": "Toon websitepictogrammen als profielfoto's voor zakelijke afzenders",
|
||||
"refresh_label": "Afzenderpictogrammen vernieuwen",
|
||||
"refresh_description": "De websitepictogrammen opnieuw ophalen, bijvoorbeeld als een afzender het zijne heeft gewijzigd",
|
||||
"refresh_button": "Vernieuwen",
|
||||
"refresh_done": "Afzenderpictogrammen worden opnieuw opgehaald"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Avatars tonen in de map ongewenst",
|
||||
|
||||
@@ -2087,7 +2087,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Favikony nadawców",
|
||||
"description": "Pokazuj ikony stron internetowych jako zdjęcia profilowe nadawców firmowych"
|
||||
"description": "Pokazuj ikony stron internetowych jako zdjęcia profilowe nadawców firmowych",
|
||||
"refresh_label": "Odśwież ikony nadawców",
|
||||
"refresh_description": "Pobierz ponownie ikony stron, na przykład gdy nadawca zmienił swoją ikonę",
|
||||
"refresh_button": "Odśwież",
|
||||
"refresh_done": "Ikony nadawców zostaną pobrane ponownie"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Pokazuj awatary w folderze spam",
|
||||
|
||||
@@ -2087,7 +2087,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Favicons de remetente",
|
||||
"description": "Exibir ícones de sites como fotos de perfil para remetentes empresariais"
|
||||
"description": "Exibir ícones de sites como fotos de perfil para remetentes empresariais",
|
||||
"refresh_label": "Atualizar ícones dos remetentes",
|
||||
"refresh_description": "Obter novamente os ícones dos sites, por exemplo quando um remetente alterou o seu",
|
||||
"refresh_button": "Atualizar",
|
||||
"refresh_done": "Os ícones dos remetentes serão obtidos novamente"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Mostrar avatares na pasta de spam",
|
||||
|
||||
@@ -2094,7 +2094,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Favicon-uri ale expeditorului",
|
||||
"description": "Afișați pictogramele site-urilor web ca imagini de profil pentru expeditorii de afaceri"
|
||||
"description": "Afișați pictogramele site-urilor web ca imagini de profil pentru expeditorii de afaceri",
|
||||
"refresh_label": "Reîmprospătează pictogramele expeditorilor",
|
||||
"refresh_description": "Descarcă din nou pictogramele site-urilor, de exemplu când un expeditor și-a schimbat pictograma",
|
||||
"refresh_button": "Reîmprospătează",
|
||||
"refresh_done": "Pictogramele expeditorilor vor fi descărcate din nou"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Afișați avatarurile din folderul „Junk”",
|
||||
|
||||
@@ -2087,7 +2087,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Фавиконы отправителей",
|
||||
"description": "Показывать иконки сайтов как аватары для корпоративных отправителей"
|
||||
"description": "Показывать иконки сайтов как аватары для корпоративных отправителей",
|
||||
"refresh_label": "Обновить значки отправителей",
|
||||
"refresh_description": "Заново загрузить значки сайтов, например если отправитель сменил свой",
|
||||
"refresh_button": "Обновить",
|
||||
"refresh_done": "Значки отправителей будут загружены заново"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Показывать аватары в папке «Спам»",
|
||||
|
||||
@@ -2094,7 +2094,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Favikony odosielateľov",
|
||||
"description": "Zobrazovať ikony webových stránok ako profilové obrázky firemných odosielateľov"
|
||||
"description": "Zobrazovať ikony webových stránok ako profilové obrázky firemných odosielateľov",
|
||||
"refresh_label": "Obnoviť ikony odosielateľov",
|
||||
"refresh_description": "Znova načítať ikony webov, napríklad keď odosielateľ zmenil svoju ikonu",
|
||||
"refresh_button": "Obnoviť",
|
||||
"refresh_done": "Ikony odosielateľov sa načítajú znova"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Zobrazovať avatáry v spame",
|
||||
|
||||
@@ -2087,7 +2087,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Gönderici Favorikonları",
|
||||
"description": "İş göndericileri için profil resmi olarak web sitesi simgelerini göster"
|
||||
"description": "İş göndericileri için profil resmi olarak web sitesi simgelerini göster",
|
||||
"refresh_label": "Gönderen simgelerini yenile",
|
||||
"refresh_description": "Web sitesi simgelerini yeniden al, örneğin bir gönderen simgesini değiştirdiğinde",
|
||||
"refresh_button": "Yenile",
|
||||
"refresh_done": "Gönderen simgeleri yeniden alınacak"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "İstem Dışı Klasöründe Avatarları Göster",
|
||||
|
||||
@@ -2087,7 +2087,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "Favicons відправника",
|
||||
"description": "Показувати піктограми веб-сайтів як зображення профілю для бізнес-відправників"
|
||||
"description": "Показувати піктограми веб-сайтів як зображення профілю для бізнес-відправників",
|
||||
"refresh_label": "Оновити значки відправників",
|
||||
"refresh_description": "Заново завантажити значки сайтів, наприклад якщо відправник змінив свій",
|
||||
"refresh_button": "Оновити",
|
||||
"refresh_done": "Значки відправників буде завантажено заново"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "Показувати аватари в папці «Спам»",
|
||||
|
||||
@@ -2094,7 +2094,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "寄件者圖示",
|
||||
"description": "使用企業寄件者的網站圖示作為頭像"
|
||||
"description": "使用企業寄件者的網站圖示作為頭像",
|
||||
"refresh_label": "重新整理寄件者圖示",
|
||||
"refresh_description": "重新取得網站圖示,例如寄件者更換了圖示時",
|
||||
"refresh_button": "重新整理",
|
||||
"refresh_done": "將重新取得寄件者圖示"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "在垃圾郵件信件匣顯示頭像",
|
||||
|
||||
@@ -2087,7 +2087,11 @@
|
||||
},
|
||||
"sender_favicons": {
|
||||
"label": "发件人图标",
|
||||
"description": "使用企业发件人的网站图标作为头像"
|
||||
"description": "使用企业发件人的网站图标作为头像",
|
||||
"refresh_label": "刷新发件人图标",
|
||||
"refresh_description": "重新获取网站图标,例如发件人更换了图标时",
|
||||
"refresh_button": "刷新",
|
||||
"refresh_done": "将重新获取发件人图标"
|
||||
},
|
||||
"show_avatars_in_junk": {
|
||||
"label": "在垃圾邮件文件夹中显示头像",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
// Domains whose favicon failed to load in this tab. Avatars skip them so a
|
||||
// missing icon isn't requested again on every render.
|
||||
export const failedFaviconDomains = new Set<string>();
|
||||
|
||||
interface FaviconRefreshState {
|
||||
/** When the user last asked for fresh sender icons (ms), 0 if never. */
|
||||
refreshedAt: number;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
// Sender favicons are cached for two weeks by the browser and by the
|
||||
// /api/favicon proxy. Refreshing stamps a new time that avatars append to the
|
||||
// favicon URL (so the browser and any proxy in between miss their cache) and
|
||||
// that the route compares against its own cache entry (issue #960).
|
||||
export const useFaviconRefreshStore = create<FaviconRefreshState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
refreshedAt: 0,
|
||||
refresh: () => {
|
||||
failedFaviconDomains.clear();
|
||||
set({ refreshedAt: Date.now() });
|
||||
},
|
||||
}),
|
||||
{ name: 'favicon-refresh', partialize: (s) => ({ refreshedAt: s.refreshedAt }) },
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user