diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts index 41d292c1e..3c817c6f6 100644 --- a/app/api/favicon/route.ts +++ b/app/api/favicon/route.ts @@ -18,6 +18,9 @@ const cache = new Map(); const negativeCache = new Map(); const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day const NEGATIVE_CACHE_MAX_SIZE = 2000; +// A refresh request (?v=) 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 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', diff --git a/components/settings/appearance-settings.tsx b/components/settings/appearance-settings.tsx index 09cf63087..df3020751 100644 --- a/components/settings/appearance-settings.tsx +++ b/components/settings/appearance-settings.tsx @@ -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 = { '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() { )} + {!IS_LITE && senderFavicons && ( + + + + )} + updateSetting('showAvatarsInJunk', checked)} /> diff --git a/components/ui/__tests__/avatar.test.tsx b/components/ui/__tests__/avatar.test.tsx index 14267b86c..63da44c33 100644 --- a/components/ui/__tests__/avatar.test.tsx +++ b/components/ui/__tests__/avatar.test.tsx @@ -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(); + 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}`); + }); }); diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx index 2478c78a6..82f10828d 100644 --- a/components/ui/avatar.tsx +++ b/components/ui/avatar.tsx @@ -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(); // 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(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; diff --git a/lib/__tests__/favicon-route-refresh.test.ts b/lib/__tests__/favicon-route-refresh.test.ts new file mode 100644 index 000000000..d15ceba08 --- /dev/null +++ b/lib/__tests__/favicon-route-refresh.test.ts @@ -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= 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); + }); +}); diff --git a/locales/ar/common.json b/locales/ar/common.json index 5f37b3696..1789c06be 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -2094,7 +2094,11 @@ }, "sender_favicons": { "label": "أيقونات مواقع المرسلين", - "description": "إظهار أيقونات المواقع كصور شخصية للمرسلين من الشركات" + "description": "إظهار أيقونات المواقع كصور شخصية للمرسلين من الشركات", + "refresh_label": "تحديث أيقونات المرسلين", + "refresh_description": "جلب أيقونات المواقع مرة أخرى، مثلاً عندما يغيّر المرسل أيقونته", + "refresh_button": "تحديث", + "refresh_done": "سيتم جلب أيقونات المرسلين مرة أخرى" }, "show_avatars_in_junk": { "label": "إظهار الصور الشخصية في مجلد البريد المزعج", diff --git a/locales/ca/common.json b/locales/ca/common.json index 214dac12c..646530d1c 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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", diff --git a/locales/cs/common.json b/locales/cs/common.json index ffdfc4d77..387d381f9 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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", diff --git a/locales/da/common.json b/locales/da/common.json index a35a306a3..24adbcaa3 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index 0efab2a2c..3832be9df 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index 102ed0f14..4b3d3e2ce 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es/common.json b/locales/es/common.json index c8ff4d650..ebc26351b 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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", diff --git a/locales/fa/common.json b/locales/fa/common.json index 105cdc629..40782c9f8 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -2094,7 +2094,11 @@ }, "sender_favicons": { "label": "Favicon فرستنده", - "description": "نمایش آیکون وبسایت به عنوان تصویر پروفایل فرستندگان تجاری" + "description": "نمایش آیکون وبسایت به عنوان تصویر پروفایل فرستندگان تجاری", + "refresh_label": "بازخوانی آیکون فرستندگان", + "refresh_description": "آیکون وب‌سایت‌ها را دوباره دریافت کن، مثلاً وقتی فرستنده آیکون خود را تغییر داده است", + "refresh_button": "بازخوانی", + "refresh_done": "آیکون فرستندگان دوباره دریافت می‌شود" }, "show_avatars_in_junk": { "label": "نمایش آواتار در پوشه هرزنامه", diff --git a/locales/fr/common.json b/locales/fr/common.json index 4f57757e4..4c9e05c1e 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index a62813eb8..bd8fd8b74 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -2052,7 +2052,11 @@ }, "sender_favicons": { "label": "סמלים של השולח (ניסיוני)", - "description": "הצג סמלי אתרים כתמונות פרופיל עבור שולחים עסקיים" + "description": "הצג סמלי אתרים כתמונות פרופיל עבור שולחים עסקיים", + "refresh_label": "רענון סמלי שולחים", + "refresh_description": "טעינה מחדש של סמלי האתרים, למשל כששולח החליף את הסמל שלו", + "refresh_button": "רענון", + "refresh_done": "סמלי השולחים ייטענו מחדש" }, "sender_avatars": {}, "keyboard_shortcuts": { diff --git a/locales/hu/common.json b/locales/hu/common.json index 280aab4bf..8fd6531a1 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index 7e782f095..794d42c7d 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index eda9e4023..ec1791639 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2087,7 +2087,11 @@ }, "sender_favicons": { "label": "送信者ファビコン", - "description": "ビジネス送信者のプロフィール画像としてウェブサイトアイコンを表示" + "description": "ビジネス送信者のプロフィール画像としてウェブサイトアイコンを表示", + "refresh_label": "送信者アイコンを更新", + "refresh_description": "送信者がアイコンを変更した場合などに、ウェブサイトのアイコンを再取得します", + "refresh_button": "更新", + "refresh_done": "送信者アイコンを再取得します" }, "show_avatars_in_junk": { "label": "迷惑メールフォルダでアバターを表示", diff --git a/locales/ko/common.json b/locales/ko/common.json index dd681dfd2..8db0c3a2c 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2087,7 +2087,11 @@ }, "sender_favicons": { "label": "보낸 사람 파비콘", - "description": "비즈니스 메일의 경우 해당 웹사이트의 아이콘을 프로필 사진으로 보여줘요" + "description": "비즈니스 메일의 경우 해당 웹사이트의 아이콘을 프로필 사진으로 보여줘요", + "refresh_label": "발신자 아이콘 새로 고침", + "refresh_description": "발신자가 아이콘을 바꾼 경우 등에 웹사이트 아이콘을 다시 가져옵니다", + "refresh_button": "새로 고침", + "refresh_done": "발신자 아이콘을 다시 가져옵니다" }, "show_avatars_in_junk": { "label": "스팸함에서 아바타 표시", diff --git a/locales/lv/common.json b/locales/lv/common.json index 32e7e55ed..7680004f8 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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ē", diff --git a/locales/mn/common.json b/locales/mn/common.json index ec0c04933..e6bdf1ce7 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -2094,7 +2094,11 @@ }, "sender_favicons": { "label": "Илгээгчийн Favicons", - "description": "Вэб сайтын дүрсийг бизнес илгээгчдэд профайл зураг болгон харуулах" + "description": "Вэб сайтын дүрсийг бизнес илгээгчдэд профайл зураг болгон харуулах", + "refresh_label": "Илгээгчийн дүрсийг шинэчлэх", + "refresh_description": "Вэбсайтын дүрсийг дахин татах, жишээ нь илгээгч дүрсээ сольсон үед", + "refresh_button": "Шинэчлэх", + "refresh_done": "Илгээгчийн дүрсийг дахин татна" }, "show_avatars_in_junk": { "label": "Хогийн хавтас дахь аватаруудыг харуулах", diff --git a/locales/nb/common.json b/locales/nb/common.json index 6726269d1..65b65dd22 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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", diff --git a/locales/nl/common.json b/locales/nl/common.json index 12967b6f8..653b92374 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pl/common.json b/locales/pl/common.json index 7ec43d247..93918746b 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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", diff --git a/locales/pt/common.json b/locales/pt/common.json index 15e8e49b8..da81c662a 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/locales/ro/common.json b/locales/ro/common.json index a281787df..d8ef29c9e 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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”", diff --git a/locales/ru/common.json b/locales/ru/common.json index 3941e248e..49f609c7a 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2087,7 +2087,11 @@ }, "sender_favicons": { "label": "Фавиконы отправителей", - "description": "Показывать иконки сайтов как аватары для корпоративных отправителей" + "description": "Показывать иконки сайтов как аватары для корпоративных отправителей", + "refresh_label": "Обновить значки отправителей", + "refresh_description": "Заново загрузить значки сайтов, например если отправитель сменил свой", + "refresh_button": "Обновить", + "refresh_done": "Значки отправителей будут загружены заново" }, "show_avatars_in_junk": { "label": "Показывать аватары в папке «Спам»", diff --git a/locales/sk/common.json b/locales/sk/common.json index 8a7730411..967d8ccce 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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", diff --git a/locales/tr/common.json b/locales/tr/common.json index 74c700a4e..e360c1795 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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", diff --git a/locales/uk/common.json b/locales/uk/common.json index af5af58f2..70ac98e37 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2087,7 +2087,11 @@ }, "sender_favicons": { "label": "Favicons відправника", - "description": "Показувати піктограми веб-сайтів як зображення профілю для бізнес-відправників" + "description": "Показувати піктограми веб-сайтів як зображення профілю для бізнес-відправників", + "refresh_label": "Оновити значки відправників", + "refresh_description": "Заново завантажити значки сайтів, наприклад якщо відправник змінив свій", + "refresh_button": "Оновити", + "refresh_done": "Значки відправників буде завантажено заново" }, "show_avatars_in_junk": { "label": "Показувати аватари в папці «Спам»", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index dbbc098a1..004f12c2d 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -2094,7 +2094,11 @@ }, "sender_favicons": { "label": "寄件者圖示", - "description": "使用企業寄件者的網站圖示作為頭像" + "description": "使用企業寄件者的網站圖示作為頭像", + "refresh_label": "重新整理寄件者圖示", + "refresh_description": "重新取得網站圖示,例如寄件者更換了圖示時", + "refresh_button": "重新整理", + "refresh_done": "將重新取得寄件者圖示" }, "show_avatars_in_junk": { "label": "在垃圾郵件信件匣顯示頭像", diff --git a/locales/zh/common.json b/locales/zh/common.json index 1f3fc3546..1b079b2f4 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2087,7 +2087,11 @@ }, "sender_favicons": { "label": "发件人图标", - "description": "使用企业发件人的网站图标作为头像" + "description": "使用企业发件人的网站图标作为头像", + "refresh_label": "刷新发件人图标", + "refresh_description": "重新获取网站图标,例如发件人更换了图标时", + "refresh_button": "刷新", + "refresh_done": "将重新获取发件人图标" }, "show_avatars_in_junk": { "label": "在垃圾邮件文件夹中显示头像", diff --git a/stores/favicon-refresh-store.ts b/stores/favicon-refresh-store.ts new file mode 100644 index 000000000..f6087774b --- /dev/null +++ b/stores/favicon-refresh-store.ts @@ -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(); + +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()( + persist( + (set) => ({ + refreshedAt: 0, + refresh: () => { + failedFaviconDomains.clear(); + set({ refreshedAt: Date.now() }); + }, + }), + { name: 'favicon-refresh', partialize: (s) => ({ refreshedAt: s.refreshedAt }) }, + ), +);