From f5ab901187ede92b8b1d9cb2abdf5b99d3695294 Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 27 Feb 2025 16:38:14 +0800 Subject: [PATCH] fix: favicon can't load error (#2426) * feat: Implement robust favicon loading with fallback mechanisms * refactor: Improve favicon loading state and use Promise Method * refactor: Extract FallbackFavicon into a separate component * feat: Add Splitbee favicon service to fallback favicon URLs --- .../src/components/Icons/FallbackFavicon.tsx | 134 ++++++++++++++++++ .../pages/home/Messages/MessageContent.tsx | 10 +- 2 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/Icons/FallbackFavicon.tsx diff --git a/src/renderer/src/components/Icons/FallbackFavicon.tsx b/src/renderer/src/components/Icons/FallbackFavicon.tsx new file mode 100644 index 0000000000..0322d8faf2 --- /dev/null +++ b/src/renderer/src/components/Icons/FallbackFavicon.tsx @@ -0,0 +1,134 @@ +import { useEffect, useState } from 'react' +import styled from 'styled-components' + +// FallbackFavicon component that tries multiple favicon sources +interface FallbackFaviconProps { + hostname: string + alt: string +} + +const FallbackFavicon: React.FC = ({ hostname, alt }) => { + type FaviconState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'failed' } + | { status: 'loaded'; src: string } + + const [faviconState, setFaviconState] = useState({ status: 'idle' }) + + useEffect(() => { + // Reset state when hostname changes + setFaviconState({ status: 'loading' }) + + // Generate all possible favicon URLs + const faviconUrls = [ + `https://favicon.splitbee.io/?url=${hostname}`, + `https://${hostname}/favicon.ico`, + `https://icon.horse/icon/${hostname}`, + `https://favicon.cccyun.cc/${hostname}`, + `https://favicon.im/${hostname}`, + `https://www.google.com/s2/favicons?domain=${hostname}` + ] + + // Main controller to abort all requests when needed + const controller = new AbortController() + const { signal } = controller + + // Create a promise for each favicon URL + const faviconPromises = faviconUrls.map((url) => + fetch(url, { + method: 'HEAD', + signal, + credentials: 'omit' + }) + .then((response) => { + if (response.ok) { + return url + } + throw new Error(`Failed to fetch ${url}`) + }) + .catch((error) => { + // Rethrow aborted errors but silence other failures + if (error.name === 'AbortError') { + throw error + } + console.debug(`Failed to fetch favicon from ${url}:`, error) + return null // Return null for failed requests + }) + ) + + // Create a timeout promise + const timeoutPromise = new Promise((resolve) => { + const timer = setTimeout(() => { + resolve(faviconUrls[0]) // Default to first URL after timeout + }, 2000) + + // Clear timeout if signal is aborted + signal.addEventListener('abort', () => clearTimeout(timer)) + }) + + // Use Promise.race to get the first successful result + Promise.race([ + // Filter out failed requests (null results) + Promise.any(faviconPromises) + .then((result) => result || faviconUrls[0]) // Ensure we always have a string, not null + .catch(() => faviconUrls[0]), + timeoutPromise + ]) + .then((url) => { + setFaviconState({ status: 'loaded', src: url }) + }) + .catch((error) => { + console.debug('All favicon requests failed:', error) + setFaviconState({ status: 'loaded', src: faviconUrls[0] }) + }) + + // Cleanup function + return () => { + controller.abort() + } + }, [hostname]) // Only depend on hostname + + const handleError = () => { + setFaviconState({ status: 'failed' }) + } + + // Render based on current state + if (faviconState.status === 'failed') { + return {hostname.charAt(0).toUpperCase()} + } + + if (faviconState.status === 'loaded') { + return + } + + return +} + +const FaviconLoading = styled.div` + width: 16px; + height: 16px; + border-radius: 4px; + background-color: var(--color-background-mute); +` + +const FaviconPlaceholder = styled.div` + width: 16px; + height: 16px; + border-radius: 4px; + background-color: var(--color-primary-1); + color: var(--color-primary-6); + font-size: 10px; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; +` +const Favicon = styled.img` + width: 16px; + height: 16px; + border-radius: 4px; + background-color: var(--color-background-mute); +` + +export default FallbackFavicon diff --git a/src/renderer/src/pages/home/Messages/MessageContent.tsx b/src/renderer/src/pages/home/Messages/MessageContent.tsx index 0ae4054984..908ab06a72 100644 --- a/src/renderer/src/pages/home/Messages/MessageContent.tsx +++ b/src/renderer/src/pages/home/Messages/MessageContent.tsx @@ -1,4 +1,5 @@ import { InfoCircleOutlined, SearchOutlined, SyncOutlined, TranslationOutlined } from '@ant-design/icons' +import Favicon from '@renderer/components/Icons/FallbackFavicon' import { HStack } from '@renderer/components/Layout' import { getModelUniqId } from '@renderer/services/ModelService' import { Message, Model } from '@renderer/types' @@ -135,7 +136,7 @@ const MessageContent: React.FC = ({ message: _message, model }) => { {message.metadata.tavily.results.map((result, index) => ( {index + 1}. - + {result.title} @@ -214,11 +215,4 @@ const SearchingText = styled.div` color: var(--color-text-1); ` -const Favicon = styled.img` - width: 16px; - height: 16px; - border-radius: 4px; - background-color: var(--color-background-mute); -` - export default React.memo(MessageContent)