diff --git a/public/favicon2.ico b/public/favicon2.ico new file mode 100644 index 00000000..8e2ce2c7 Binary files /dev/null and b/public/favicon2.ico differ diff --git a/src/hooks/use-theme.ts b/src/hooks/use-theme.ts index 6bd6dc0e..db6971e8 100644 --- a/src/hooks/use-theme.ts +++ b/src/hooks/use-theme.ts @@ -7,6 +7,7 @@ import { useResolvedSubplebbitAddress } from './use-resolved-subplebbit-address' import { useAccountComment } from '@bitsocialhq/pkc-react-hooks'; import useSpecialThemeStore from '../stores/use-special-theme-store'; import { isChristmas } from '../lib/utils/time-utils'; +import { updateFavicon, isSfwBoard } from '../lib/update-favicon'; const themeClasses = ['yotsuba', 'yotsuba-b', 'futaba', 'burichan', 'tomorrow', 'photon']; @@ -80,11 +81,26 @@ const useTheme = (): [string, (theme: string) => void] => { return storedTheme || 'yotsuba'; }, [location.pathname, isEnabled, isInAllView, isInSubscriptionsView, isInModView, subplebbitAddress, directories, themes]); + const sfw = isSfwBoard({ + pathname: location.pathname, + isSpecialTheme: !!isEnabled, + isInAllView, + isInSubscriptionsView, + isInModView, + subplebbitAddress, + directories, + }); + // Update DOM class when theme changes useEffect(() => { updateThemeClass(currentTheme); }, [currentTheme]); + // Update favicon when SFW status changes (separate effect for independent lifecycle) + useEffect(() => { + updateFavicon(sfw); + }, [sfw]); + const setSubplebbitTheme = useCallback( async (newTheme: string) => { if (isInAllView || isInSubscriptionsView || isInModView) { diff --git a/src/lib/update-favicon.ts b/src/lib/update-favicon.ts new file mode 100644 index 00000000..4a4caacb --- /dev/null +++ b/src/lib/update-favicon.ts @@ -0,0 +1,54 @@ +const DEFAULT_FAVICON = '/favicon.ico'; +const SFW_FAVICON = '/favicon2.ico'; + +let currentHref: string | null = null; + +/** + * Swap the tab favicon between the default (NSFW/home) and SFW variants. + * Uses remove-and-recreate to bypass aggressive browser favicon caching. + */ +export const updateFavicon = (isSfw: boolean): void => { + const href = isSfw ? SFW_FAVICON : DEFAULT_FAVICON; + if (href === currentHref) return; + currentHref = href; + + const existing = document.querySelector('link[rel="icon"]'); + if (existing) existing.remove(); + + const link = document.createElement('link'); + link.rel = 'icon'; + link.href = href; + document.head.appendChild(link); +}; + +/** + * Determine whether the current navigation context is a SFW board. + * + * Pure function — no hooks, no side-effects, fully testable. + */ +export const isSfwBoard = ({ + pathname, + isSpecialTheme, + isInAllView, + isInSubscriptionsView, + isInModView, + subplebbitAddress, + directories, +}: { + pathname: string; + isSpecialTheme: boolean; + isInAllView: boolean; + isInSubscriptionsView: boolean; + isInModView: boolean; + subplebbitAddress: string | undefined; + directories: { address: string; nsfw?: boolean }[]; +}): boolean => { + if (pathname === '/' || pathname.startsWith('/rules')) return false; + if (isSpecialTheme) return false; + if (isInAllView || isInSubscriptionsView || isInModView) return false; + + if (!subplebbitAddress) return false; + + const entry = directories.find((d) => d.address === subplebbitAddress); + return !entry?.nsfw; +}; diff --git a/vite.config.js b/vite.config.js index 2361a650..c21249de 100644 --- a/vite.config.js +++ b/vite.config.js @@ -39,7 +39,7 @@ export default defineConfig({ enabled: true, type: 'module', }, - includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'], + includeAssets: ['favicon.ico', 'favicon2.ico', 'robots.txt', 'apple-touch-icon.png'], manifest: { name: '5chan', short_name: '5chan',