Replace DomainFavicon with ExternalFavicon using configurable favicon URL template

The old DomainFavicon component had a multi-step fallback chain (HTML scraping,
direct URL guessing, then the configured provider) which meant the configurable
setting was almost never reached. The new ExternalFavicon component uses the
configured favicon URL template directly via RFC 6570 URI templates.

- Rename config field faviconProvider -> faviconUrl
- Add faviconUrl() utility with uri-templates for RFC 6570 support
- Remove local Favicon component from ProfileRightSidebar
- Update all consumers to use ExternalFavicon
This commit is contained in:
Alex Gleason
2026-02-21 15:05:22 -06:00
parent c083c9902f
commit cfb5659020
16 changed files with 151 additions and 230 deletions
+7
View File
@@ -72,6 +72,7 @@
"rehype-sanitize": "^6.0.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"uri-templates": "^0.2.0",
"vaul": "^0.9.3",
"zod": "^4.3.6"
},
@@ -9755,6 +9756,12 @@
"punycode": "^2.1.0"
}
},
"node_modules/uri-templates": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/uri-templates/-/uri-templates-0.2.0.tgz",
"integrity": "sha512-EWkjYEN0L6KOfEoOH6Wj4ghQqU7eBZMJqRHQnxQAq+dSEzRPClkWjf8557HkWQXF6BrAUoLSAyy9i3RVTliaNg==",
"license": "http://geraintluff.github.io/tv4/LICENSE.txt"
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+1
View File
@@ -75,6 +75,7 @@
"rehype-sanitize": "^6.0.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"uri-templates": "^0.2.0",
"vaul": "^0.9.3",
"zod": "^4.3.6"
},
+1 -1
View File
@@ -69,7 +69,7 @@ const defaultConfig: AppConfig = {
nip85OnlyMode: true,
blossomServers: ['https://blossom.primal.net/'],
defaultZapComment: 'Zapped with Mew!',
faviconProvider: 'https://favicon.shakespeare.diy/?url={href}',
faviconUrl: 'https://favicon.shakespeare.diy/?url={href}',
corsProxy: 'https://proxy.shakespeare.diy/?url={href}',
contentWarningPolicy: 'blur',
};
+12 -12
View File
@@ -22,7 +22,7 @@ export function AdvancedSettings() {
const [statsPubkey, setStatsPubkey] = useState(config.nip85StatsPubkey);
const [newBlossomUrl, setNewBlossomUrl] = useState('');
const [defaultZapComment, setDefaultZapComment] = useState(config.defaultZapComment);
const [faviconProvider, setFaviconProvider] = useState(config.faviconProvider);
const [faviconUrl, setFaviconUrl] = useState(config.faviconUrl);
const [corsProxy, setCorsProxy] = useState(config.corsProxy);
const handleAddBlossomServer = () => {
@@ -352,25 +352,25 @@ export function AdvancedSettings() {
</div>
</div>
{/* Favicon Provider */}
{/* Favicon URL */}
<div>
<Label htmlFor="favicon-provider" className="text-sm font-medium">
Favicon Provider
<Label htmlFor="favicon-url" className="text-sm font-medium">
Favicon URL
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
URI template for fetching site favicons. Use <code className="bg-muted px-1 rounded">{'{href}'}</code> as a placeholder for the page URL.
URI template for fetching site favicons. Supports RFC 6570 variables: <code className="bg-muted px-1 rounded">{'{href}'}</code>, <code className="bg-muted px-1 rounded">{'{hostname}'}</code>, <code className="bg-muted px-1 rounded">{'{origin}'}</code>, etc.
</p>
<Input
id="favicon-provider"
value={faviconProvider}
id="favicon-url"
value={faviconUrl}
onChange={(e) => {
setFaviconProvider(e.target.value);
setFaviconUrl(e.target.value);
}}
onBlur={() => {
const trimmed = faviconProvider.trim();
if (trimmed && trimmed !== config.faviconProvider) {
updateConfig(() => ({ faviconProvider: trimmed }));
toast({ title: 'Favicon provider updated' });
const trimmed = faviconUrl.trim();
if (trimmed && trimmed !== config.faviconUrl) {
updateConfig(() => ({ faviconUrl: trimmed }));
toast({ title: 'Favicon URL updated' });
}
}}
placeholder="https://favicon.shakespeare.diy/?url={href}"
+1 -1
View File
@@ -63,7 +63,7 @@ const AppConfigSchema = z.object({
nip85OnlyMode: z.boolean(),
blossomServers: z.array(z.url()),
defaultZapComment: z.string(),
faviconProvider: z.string(),
faviconUrl: z.string(),
corsProxy: z.string(),
contentWarningPolicy: z.enum(['blur', 'hide', 'show']),
});
-168
View File
@@ -1,168 +0,0 @@
import { useState, useMemo, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { useAppContext } from '@/hooks/useAppContext';
interface DomainFaviconProps {
/** Full URL or just domain name */
domain: string;
/** Size in pixels (default: 16) */
size?: number;
className?: string;
}
/**
* Fetches the HTML of a domain via CORS proxy and parses for favicon link tags.
* Returns the discovered favicon URL or null if not found.
*/
async function discoverFavicon(origin: string, corsProxy: string): Promise<string | null> {
try {
// Use CORS proxy to fetch the HTML
const proxyUrl = corsProxy.replace('{href}', encodeURIComponent(origin));
const response = await fetch(proxyUrl, {
method: 'GET',
headers: { 'Accept': 'text/html' }
});
if (!response.ok) return null;
const html = await response.text();
// Parse favicon from <link> tags
// Look for rel="icon", rel="shortcut icon", or rel="apple-touch-icon"
const iconRegex = /<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*>/gi;
const appleIconRegex = /<link[^>]*rel=["']apple-touch-icon["'][^>]*>/gi;
const matches = [...html.matchAll(iconRegex), ...html.matchAll(appleIconRegex)];
for (const match of matches) {
const linkTag = match[0];
const hrefMatch = linkTag.match(/href=["']([^"']+)["']/i);
if (hrefMatch && hrefMatch[1]) {
let href = hrefMatch[1];
// Make absolute URL if relative
if (href.startsWith('/')) {
href = `${origin}${href}`;
} else if (!href.startsWith('http')) {
href = `${origin}/${href}`;
}
return href;
}
}
return null;
} catch {
return null;
}
}
/**
* Displays a favicon for a domain or URL.
* Strategy: Scrape HTML for favicon → try common paths → fallback to favicon provider → hide if all fail
*/
export function DomainFavicon({ domain, size = 16, className }: DomainFaviconProps) {
const { config } = useAppContext();
const [faviconUrl, setFaviconUrl] = useState<string | null>(null);
const [fallbackIndex, setFallbackIndex] = useState(0);
const [triedProvider, setTriedProvider] = useState(false);
const [failed, setFailed] = useState(false);
// Get origin from domain
const origin = useMemo(() => {
try {
if (domain.startsWith('http://') || domain.startsWith('https://')) {
return new URL(domain).origin;
}
return `https://${domain}`;
} catch {
return null;
}
}, [domain]);
// Direct favicon URLs to try (svg, ico, png)
const directUrls = useMemo(() => {
if (!origin) return [];
return [
`${origin}/favicon.svg`,
`${origin}/favicon.ico`,
`${origin}/favicon.png`,
];
}, [origin]);
// Favicon provider fallback URL (configurable template)
const providerUrl = useMemo(() => {
if (!origin) return null;
return config.faviconProvider.replace('{href}', encodeURIComponent(origin));
}, [origin, config.faviconProvider]);
// Discover favicon from HTML on mount
useEffect(() => {
if (!origin) {
setFailed(true);
return;
}
let mounted = true;
// Try to discover favicon from HTML
discoverFavicon(origin, config.corsProxy).then((url) => {
if (mounted) {
if (url) {
// Found favicon in HTML, use it
setFaviconUrl(url);
} else {
// No favicon in HTML, start with first direct URL
setFaviconUrl(directUrls[0] || null);
}
}
});
return () => {
mounted = false;
};
}, [origin, directUrls]);
const handleLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
// If this is a favicon provider service, check if it returned the default placeholder
if (faviconUrl === providerUrl) {
// Google's default placeholder is exactly 16x16 gray globe
// Real favicons from Google are usually larger or have different aspect ratios
if (img.naturalWidth === 16 && img.naturalHeight === 16) {
// This is likely the default globe - reject it
setFailed(true);
return;
}
}
};
const handleError = () => {
// If we haven't tried all direct URLs yet
if (fallbackIndex < directUrls.length - 1) {
setFallbackIndex(fallbackIndex + 1);
setFaviconUrl(directUrls[fallbackIndex + 1]);
} else if (!triedProvider && providerUrl) {
// All direct URLs failed, try favicon provider
setTriedProvider(true);
setFaviconUrl(providerUrl);
} else {
// Even Google failed, hide the favicon
setFailed(true);
}
};
if (!faviconUrl || failed) {
return null;
}
return (
<img
src={faviconUrl}
alt=""
className={cn('shrink-0', className)}
style={{ width: size, height: size }}
loading="lazy"
onLoad={handleLoad}
onError={handleError}
/>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { useAppContext } from '@/hooks/useAppContext';
import { faviconUrl } from '@/lib/faviconUrl';
import { cn } from '@/lib/utils';
import { ReactNode, useMemo } from 'react';
interface ExternalFaviconProps {
/** The URL to fetch the favicon for */
url: string | URL | undefined;
/** Size of the favicon in pixels */
size?: number;
/** Fallback element to display if favicon fails to load */
fallback?: ReactNode;
/** Additional CSS classes */
className?: string;
}
/**
* ExternalFavicon component that fetches and displays a favicon for a given URL
* using the configurable favicon service from app settings.
*/
export function ExternalFavicon({
url,
size = 16,
fallback,
className = '',
}: ExternalFaviconProps) {
const { config } = useAppContext();
// Generate the favicon URL using the configured template
const faviconSrc = useMemo(() => {
if (!url) return;
try {
const parsedUrl = new URL(url);
// Strip `ai.` and `api.` subdomains for a better chance at finding a favicon
parsedUrl.hostname = parsedUrl.hostname.replace(/^(ai\.|api\.|enclave\.)/, '');
// Normalize the URL to ensure it has a protocol
return faviconUrl({ template: config.faviconUrl, url: parsedUrl });
} catch {
return;
}
}, [url, config.faviconUrl]);
// If faviconSrc is not available, render the fallback directly
if (!faviconSrc) {
return (
<span className={cn('inline-flex items-center justify-center', className)}>
<span>{fallback}</span>
</span>
)
}
return (
<span className={cn('inline-flex items-center justify-center', className)}>
<img
src={faviconSrc}
alt=""
className="object-contain"
style={{ width: size, height: size }}
onError={(e) => {
// Hide the image and show the fallback on error
e.currentTarget.style.display = 'none';
const fallbackElement = e.currentTarget.nextElementSibling as HTMLElement;
if (fallbackElement) {
fallbackElement.style.display = 'inline-block';
}
}}
/>
{fallback && (
<span style={{ display: 'none' }}>
{fallback}
</span>
)}
</span>
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { Link } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { DomainFavicon } from '@/components/DomainFavicon';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { getNip05Domain, getNip05User } from '@/lib/nip05';
interface Nip05BadgeProps {
@@ -54,7 +54,7 @@ export function Nip05Badge({ nip05, className, iconSize = 16 }: Nip05BadgeProps)
onClick={(e) => e.stopPropagation()}
title={`View ${domain} feed`}
>
<DomainFavicon domain={domain} size={iconSize} className="shrink-0" />
<ExternalFavicon url={`https://${domain}`} size={iconSize} className="shrink-0" />
</Link>
)}
</span>
+2 -2
View File
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { DomainFavicon } from '@/components/DomainFavicon';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { EmojifiedText } from '@/components/CustomEmoji';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
@@ -83,7 +83,7 @@ export function ProfileHoverCard({ pubkey, children, asChild }: ProfileHoverCard
<div className="flex items-center gap-1 text-sm text-muted-foreground mt-0.5">
<span className="truncate">@{nip05Display}</span>
{nip05Domain && (
<DomainFavicon domain={nip05Domain} size={14} className="shrink-0" />
<ExternalFavicon url={`https://${nip05Domain}`} size={14} className="shrink-0" />
)}
</div>
)}
+3 -37
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
@@ -6,6 +6,7 @@ import { Check, Copy, QrCode, ExternalLink, Bitcoin, ShieldAlert } from 'lucide-
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Skeleton } from '@/components/ui/skeleton';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { useToast } from '@/hooks/useToast';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { getEnabledFeedKinds } from '@/lib/extraKinds';
@@ -119,41 +120,6 @@ function eventLink(item: MediaItem): string {
return `/${nip19.neventEncode({ id: item.eventId, author: item.authorPubkey })}`;
}
/** Try multiple favicon paths: /favicon.ico, then /favicon.svg. */
function Favicon({ url }: { url: string }) {
const candidates = useMemo(() => {
try {
const origin = new URL(url).origin;
return [`${origin}/favicon.ico`, `${origin}/favicon.svg`];
} catch {
return [];
}
}, [url]);
const [index, setIndex] = useState(0);
const [failed, setFailed] = useState(false);
if (candidates.length === 0 || failed) return null;
const src = candidates[index];
return (
<img
src={src}
alt=""
className="size-4 shrink-0"
loading="lazy"
onError={() => {
if (index < candidates.length - 1) {
setIndex(index + 1);
} else {
setFailed(true);
}
}}
/>
);
}
/** Bitcoin QR code modal */
function BitcoinQRModal({ address }: { address: string }) {
const [qrUrl, setQrUrl] = useState('');
@@ -282,7 +248,7 @@ function ProfileFieldRow({ field }: { field: ProfileField }) {
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-primary hover:underline truncate mt-0.5"
>
<Favicon url={field.value} />
<ExternalFavicon url={field.value} size={16} className="shrink-0" />
<span className="truncate">{field.value.replace(/^https?:\/\//, '')}</span>
</a>
) : (
+2 -2
View File
@@ -83,8 +83,8 @@ export interface AppConfig {
blossomServers: string[];
/** Default comment attached to zaps */
defaultZapComment: string;
/** Favicon provider URI template. Use {href} as placeholder for the page URL. */
faviconProvider: string;
/** Favicon URI template. Supports RFC 6570 variables: {href}, {origin}, {hostname}, etc. */
faviconUrl: string;
/** CORS proxy URI template. Use {href} as placeholder for the target URL (URL-encoded). */
corsProxy: string;
/** How to handle NIP-36 content-warning events (blur, hide, or show). Default: "blur". */
+31
View File
@@ -0,0 +1,31 @@
import UriTemplate from 'uri-templates';
export interface FaviconUrlOpts {
template: string;
url: string | URL;
}
/**
* Generate a favicon URL from a template and input URL
* @param opts - Options object
* @param opts.template - URL template with placeholders like {hostname}, {origin}, etc.
* @param opts.url - The URL to extract parts from
* @returns The hydrated favicon URL
*/
export function faviconUrl(opts: FaviconUrlOpts): string {
const u = new URL(opts.url);
return UriTemplate(opts.template).fill({
href: u.href,
origin: u.origin,
protocol: u.protocol,
username: u.username,
password: u.password,
host: u.host,
hostname: u.hostname,
port: u.port,
pathname: u.pathname,
hash: u.hash,
search: u.search,
});
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { NoteCard } from '@/components/NoteCard';
import { DomainFavicon } from '@/components/DomainFavicon';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { Skeleton } from '@/components/ui/skeleton';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useAppContext } from '@/hooks/useAppContext';
@@ -104,7 +104,7 @@ export function DomainFeedPage() {
<ArrowLeft className="size-5" />
</button>
<div className="flex items-center gap-2 min-w-0">
<DomainFavicon domain={domain ?? ''} size={20} />
<ExternalFavicon url={domain ? `https://${domain}` : undefined} size={20} />
<div className="min-w-0">
<h1 className="text-lg font-bold truncate leading-tight">{domain}</h1>
{pubkeys && pubkeys.length > 0 && (
+2 -2
View File
@@ -14,7 +14,7 @@ import { useLayoutOptions } from '@/contexts/LayoutContext';
import { ProfileRightSidebar } from '@/components/ProfileRightSidebar';
import { NoteCard } from '@/components/NoteCard';
import { ZapDialog } from '@/components/ZapDialog';
import { DomainFavicon } from '@/components/DomainFavicon';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { Nip05Badge } from '@/components/Nip05Badge';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -426,7 +426,7 @@ function ProfileFieldInline({ field }: { field: { label: string; value: string }
if (isUrl) {
return (
<div className="flex items-center gap-1.5 min-w-0">
<DomainFavicon domain={field.value} size={16} />
<ExternalFavicon url={field.value} size={16} className="shrink-0" />
<span className="text-sm text-muted-foreground shrink-0">{field.label}</span>
<a
href={field.value}
+1 -1
View File
@@ -57,7 +57,7 @@ export function TestApp({ children }: TestAppProps) {
nip85OnlyMode: false,
blossomServers: ['https://blossom.primal.net/'],
defaultZapComment: 'Zapped with Mew!',
faviconProvider: 'https://favicon.shakespeare.diy/?url={href}',
faviconUrl: 'https://favicon.shakespeare.diy/?url={href}',
corsProxy: 'https://proxy.shakespeare.diy/?url={href}',
contentWarningPolicy: 'blur',
};
+7
View File
@@ -0,0 +1,7 @@
declare module 'uri-templates' {
interface UriTemplate {
fill(vars: Record<string, string>): string;
}
function UriTemplate(template: string): UriTemplate;
export default UriTemplate;
}