feat: refresh sender icons that changed on the sender's website #960

This commit is contained in:
Linus Rath
2026-10-17 19:18:18 +02:00
parent 481665b435
commit b611d9c028
33 changed files with 304 additions and 35 deletions
+27 -2
View File
@@ -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',
+18 -1
View File
@@ -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>
+19 -1
View File
@@ -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 -4
View File
@@ -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);
});
});
+5 -1
View File
@@ -2094,7 +2094,11 @@
},
"sender_favicons": {
"label": "أيقونات مواقع المرسلين",
"description": "إظهار أيقونات المواقع كصور شخصية للمرسلين من الشركات"
"description": "إظهار أيقونات المواقع كصور شخصية للمرسلين من الشركات",
"refresh_label": "تحديث أيقونات المرسلين",
"refresh_description": "جلب أيقونات المواقع مرة أخرى، مثلاً عندما يغيّر المرسل أيقونته",
"refresh_button": "تحديث",
"refresh_done": "سيتم جلب أيقونات المرسلين مرة أخرى"
},
"show_avatars_in_junk": {
"label": "إظهار الصور الشخصية في مجلد البريد المزعج",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -2094,7 +2094,11 @@
},
"sender_favicons": {
"label": "Favicon فرستنده",
"description": "نمایش آیکون وبسایت به عنوان تصویر پروفایل فرستندگان تجاری"
"description": "نمایش آیکون وبسایت به عنوان تصویر پروفایل فرستندگان تجاری",
"refresh_label": "بازخوانی آیکون فرستندگان",
"refresh_description": "آیکون وب‌سایت‌ها را دوباره دریافت کن، مثلاً وقتی فرستنده آیکون خود را تغییر داده است",
"refresh_button": "بازخوانی",
"refresh_done": "آیکون فرستندگان دوباره دریافت می‌شود"
},
"show_avatars_in_junk": {
"label": "نمایش آواتار در پوشه هرزنامه",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -2052,7 +2052,11 @@
},
"sender_favicons": {
"label": "סמלים של השולח (ניסיוני)",
"description": "הצג סמלי אתרים כתמונות פרופיל עבור שולחים עסקיים"
"description": "הצג סמלי אתרים כתמונות פרופיל עבור שולחים עסקיים",
"refresh_label": "רענון סמלי שולחים",
"refresh_description": "טעינה מחדש של סמלי האתרים, למשל כששולח החליף את הסמל שלו",
"refresh_button": "רענון",
"refresh_done": "סמלי השולחים ייטענו מחדש"
},
"sender_avatars": {},
"keyboard_shortcuts": {
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -2087,7 +2087,11 @@
},
"sender_favicons": {
"label": "送信者ファビコン",
"description": "ビジネス送信者のプロフィール画像としてウェブサイトアイコンを表示"
"description": "ビジネス送信者のプロフィール画像としてウェブサイトアイコンを表示",
"refresh_label": "送信者アイコンを更新",
"refresh_description": "送信者がアイコンを変更した場合などに、ウェブサイトのアイコンを再取得します",
"refresh_button": "更新",
"refresh_done": "送信者アイコンを再取得します"
},
"show_avatars_in_junk": {
"label": "迷惑メールフォルダでアバターを表示",
+5 -1
View File
@@ -2087,7 +2087,11 @@
},
"sender_favicons": {
"label": "보낸 사람 파비콘",
"description": "비즈니스 메일의 경우 해당 웹사이트의 아이콘을 프로필 사진으로 보여줘요"
"description": "비즈니스 메일의 경우 해당 웹사이트의 아이콘을 프로필 사진으로 보여줘요",
"refresh_label": "발신자 아이콘 새로 고침",
"refresh_description": "발신자가 아이콘을 바꾼 경우 등에 웹사이트 아이콘을 다시 가져옵니다",
"refresh_button": "새로 고침",
"refresh_done": "발신자 아이콘을 다시 가져옵니다"
},
"show_avatars_in_junk": {
"label": "스팸함에서 아바타 표시",
+5 -1
View File
@@ -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ē",
+5 -1
View File
@@ -2094,7 +2094,11 @@
},
"sender_favicons": {
"label": "Илгээгчийн Favicons",
"description": "Вэб сайтын дүрсийг бизнес илгээгчдэд профайл зураг болгон харуулах"
"description": "Вэб сайтын дүрсийг бизнес илгээгчдэд профайл зураг болгон харуулах",
"refresh_label": "Илгээгчийн дүрсийг шинэчлэх",
"refresh_description": "Вэбсайтын дүрсийг дахин татах, жишээ нь илгээгч дүрсээ сольсон үед",
"refresh_button": "Шинэчлэх",
"refresh_done": "Илгээгчийн дүрсийг дахин татна"
},
"show_avatars_in_junk": {
"label": "Хогийн хавтас дахь аватаруудыг харуулах",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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”",
+5 -1
View File
@@ -2087,7 +2087,11 @@
},
"sender_favicons": {
"label": "Фавиконы отправителей",
"description": "Показывать иконки сайтов как аватары для корпоративных отправителей"
"description": "Показывать иконки сайтов как аватары для корпоративных отправителей",
"refresh_label": "Обновить значки отправителей",
"refresh_description": "Заново загрузить значки сайтов, например если отправитель сменил свой",
"refresh_button": "Обновить",
"refresh_done": "Значки отправителей будут загружены заново"
},
"show_avatars_in_junk": {
"label": "Показывать аватары в папке «Спам»",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -2087,7 +2087,11 @@
},
"sender_favicons": {
"label": "Favicons відправника",
"description": "Показувати піктограми веб-сайтів як зображення профілю для бізнес-відправників"
"description": "Показувати піктограми веб-сайтів як зображення профілю для бізнес-відправників",
"refresh_label": "Оновити значки відправників",
"refresh_description": "Заново завантажити значки сайтів, наприклад якщо відправник змінив свій",
"refresh_button": "Оновити",
"refresh_done": "Значки відправників буде завантажено заново"
},
"show_avatars_in_junk": {
"label": "Показувати аватари в папці «Спам»",
+5 -1
View File
@@ -2094,7 +2094,11 @@
},
"sender_favicons": {
"label": "寄件者圖示",
"description": "使用企業寄件者的網站圖示作為頭像"
"description": "使用企業寄件者的網站圖示作為頭像",
"refresh_label": "重新整理寄件者圖示",
"refresh_description": "重新取得網站圖示,例如寄件者更換了圖示時",
"refresh_button": "重新整理",
"refresh_done": "將重新取得寄件者圖示"
},
"show_avatars_in_junk": {
"label": "在垃圾郵件信件匣顯示頭像",
+5 -1
View File
@@ -2087,7 +2087,11 @@
},
"sender_favicons": {
"label": "发件人图标",
"description": "使用企业发件人的网站图标作为头像"
"description": "使用企业发件人的网站图标作为头像",
"refresh_label": "刷新发件人图标",
"refresh_description": "重新获取网站图标,例如发件人更换了图标时",
"refresh_button": "刷新",
"refresh_done": "将重新获取发件人图标"
},
"show_avatars_in_junk": {
"label": "在垃圾邮件文件夹中显示头像",
+29
View File
@@ -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 }) },
),
);