diff --git a/src/app.tsx b/src/app.tsx index ba39bc86..e1282cdf 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -200,7 +200,7 @@ const BoardLayout = () => { }; const GlobalLayout = () => { - useTheme(); + useTheme({ applyDocumentEffects: true }); useSuspendOffscreenMediaPlayback(); const { diff --git a/src/hooks/__tests__/use-theme.test.tsx b/src/hooks/__tests__/use-theme.test.tsx index b7555306..a24b2c14 100644 --- a/src/hooks/__tests__/use-theme.test.tsx +++ b/src/hooks/__tests__/use-theme.test.tsx @@ -9,13 +9,14 @@ import { TRASH_BOARD_ADDRESS, TRASH_BOARD_CODE } from '../../lib/special-boards' const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; const testState = vi.hoisted(() => ({ - boardIdentifier: 'trash', + boardIdentifier: 'trash' as string | undefined, directories: [] as Array<{ address: string; nsfw?: boolean }>, isSpecialThemeEnabled: false as boolean | null, locationPathname: '/trash', resolvedAddress: 'off-topic.bso' as string | undefined, setIsEnabledMock: vi.fn(), setThemeMock: vi.fn().mockResolvedValue(undefined), + updateFaviconMock: vi.fn(), themes: { nsfw: 'tomorrow', sfw: 'yotsuba-b', @@ -60,7 +61,7 @@ vi.mock('../../stores/use-theme-store', () => ({ vi.mock('../../lib/update-favicon', () => ({ isSfwBoard: () => false, - updateFavicon: vi.fn(), + updateFavicon: testState.updateFaviconMock, })); vi.mock('../../lib/utils/time-utils', () => ({ @@ -73,7 +74,7 @@ let container: HTMLDivElement; let root: Root; const HookHarness = () => { - latestValue = useTheme(); + latestValue = useTheme({ applyDocumentEffects: true }); return null; }; @@ -119,4 +120,18 @@ describe('useTheme', () => { expect(testState.setThemeMock).toHaveBeenCalledWith('nsfw', 'photon'); }); + + it('restores the default favicon when navigating from not-found to mod', async () => { + testState.boardIdentifier = undefined; + testState.resolvedAddress = undefined; + testState.locationPathname = '/not-found'; + await renderHook(); + + expect(testState.updateFaviconMock).toHaveBeenLastCalledWith('not-found'); + + testState.locationPathname = '/mod'; + await renderHook(); + + expect(testState.updateFaviconMock).toHaveBeenLastCalledWith('default'); + }); }); diff --git a/src/hooks/use-theme.ts b/src/hooks/use-theme.ts index 19f46d65..c8fcc6ba 100644 --- a/src/hooks/use-theme.ts +++ b/src/hooks/use-theme.ts @@ -14,6 +14,10 @@ import { normalizeAccountCommentIndex } from '../lib/utils/account-comment-index const themeClasses = ['yotsuba', 'yotsuba-b', 'futaba', 'burichan', 'tomorrow', 'photon', 'spooky']; +type UseThemeOptions = { + applyDocumentEffects?: boolean; +}; + const updateThemeClass = (newTheme: string) => { document.body.classList.remove(...themeClasses); if (newTheme) { @@ -21,7 +25,7 @@ const updateThemeClass = (newTheme: string) => { } }; -const useTheme = (): [string, (theme: string) => void] => { +const useTheme = ({ applyDocumentEffects = false }: UseThemeOptions = {}): [string, (theme: string) => void] => { const location = useLocation(); const params = useParams<{ accountCommentIndex?: string; boardIdentifier?: string; commentCid?: string }>(); const pendingPost = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) }); @@ -90,12 +94,15 @@ const useTheme = (): [string, (theme: string) => void] => { }); useEffect(() => { + if (!applyDocumentEffects) return; updateThemeClass(currentTheme); - }, [currentTheme]); + }, [applyDocumentEffects, currentTheme]); useEffect(() => { - updateFavicon(isInNotFoundView ? 'not-found' : sfw); - }, [isInNotFoundView, sfw]); + if (!applyDocumentEffects) return; + const faviconVariant = isInNotFoundView ? 'not-found' : sfw ? 'sfw' : 'default'; + updateFavicon(faviconVariant); + }, [applyDocumentEffects, isInNotFoundView, sfw]); const setCommunityTheme = useCallback( async (newTheme: string) => { diff --git a/src/lib/__tests__/update-favicon.test.ts b/src/lib/__tests__/update-favicon.test.ts index 8a59c8e0..fd2d1246 100644 --- a/src/lib/__tests__/update-favicon.test.ts +++ b/src/lib/__tests__/update-favicon.test.ts @@ -13,17 +13,17 @@ describe('update-favicon', () => { document.head.innerHTML = ''; - updateFavicon(false); + updateFavicon('default'); expect(document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]')).toHaveLength(4); expect(document.querySelector('link[rel="icon"][sizes="16x16"]')?.getAttribute('href')).toBe('/favicon.ico?variant=nsfw'); expect(document.querySelector('link[rel="shortcut icon"][sizes="16x16"]')?.getAttribute('href')).toBe('/favicon.ico?variant=nsfw'); expect(document.querySelector('link[rel="icon"][sizes="192x192"]')?.getAttribute('href')).toBe('/manifest-icon-192x192.png'); expect(document.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href')).toBe('/apple-touch-icon.png'); - updateFavicon(false); + updateFavicon('default'); expect(document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]')).toHaveLength(4); - updateFavicon(true); + updateFavicon('sfw'); expect(document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]')).toHaveLength(4); expect(document.querySelector('link[rel="icon"][sizes="16x16"]')?.getAttribute('href')).toBe('/favicon2.ico?variant=sfw'); expect(document.querySelector('link[rel="shortcut icon"][sizes="16x16"]')?.getAttribute('href')).toBe('/favicon2.ico?variant=sfw'); @@ -46,6 +46,11 @@ describe('update-favicon', () => { expect(document.querySelector('link[rel="shortcut icon"][sizes="16x16"]')?.getAttribute('type')).toBe('image/x-icon'); expect(document.querySelector('link[rel="icon"][sizes="192x192"]')?.getAttribute('href')).toBe('/manifest-icon-192x192.png'); expect(document.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href')).toBe('/apple-touch-icon.png'); + + updateFavicon('default'); + expect(document.querySelector('link[rel="icon"][sizes="16x16"]')?.getAttribute('href')).toBe('/favicon.ico?variant=nsfw'); + expect(document.querySelector('link[rel="shortcut icon"][sizes="16x16"]')?.getAttribute('href')).toBe('/favicon.ico?variant=nsfw'); + expect(document.querySelector('link[rel="icon"][sizes="16x16"]')?.getAttribute('type')).toBe('image/png'); }); it('marks only non-special, non-routing aggregate sfw boards as sfw', async () => { diff --git a/src/lib/update-favicon.ts b/src/lib/update-favicon.ts index 934ba3fc..8554d6a2 100644 --- a/src/lib/update-favicon.ts +++ b/src/lib/update-favicon.ts @@ -6,7 +6,7 @@ const NOT_FOUND_FAVICON = '/favicon-404.ico?variant=404'; const FAVICON_RELS = ['icon', 'shortcut icon'] as const; const FAVICON_SELECTOR = ['link[data-fivechan-tab-favicon="true"]', ...FAVICON_RELS.map((rel) => `link[rel="${rel}"][sizes="16x16"]`)].join(', '); -type FaviconVariant = 'default' | 'sfw' | 'not-found'; +export type FaviconVariant = 'default' | 'sfw' | 'not-found'; const FAVICONS: Record = { default: { href: DEFAULT_FAVICON, type: 'image/png' }, @@ -29,17 +29,12 @@ const createFaviconLink = (rel: (typeof FAVICON_RELS)[number], favicon: (typeof return link; }; -const getFaviconVariant = (variant: boolean | FaviconVariant): FaviconVariant => { - if (typeof variant === 'boolean') return variant ? 'sfw' : 'default'; - return variant; -}; - /** * Swap the tab favicon between the default (NSFW/home), SFW, and 404 variants. * Uses remove-and-recreate plus cache-busted URLs to bypass sticky favicon caching. */ -export const updateFavicon = (variant: boolean | FaviconVariant): void => { - const favicon = FAVICONS[getFaviconVariant(variant)]; +export const updateFavicon = (variant: FaviconVariant): void => { + const favicon = FAVICONS[variant]; const { href } = favicon; if (href === currentHref && hasExpectedFaviconLinks(href)) return; currentHref = href; diff --git a/src/lib/utils/__tests__/view-utils.test.ts b/src/lib/utils/__tests__/view-utils.test.ts index b6a63bb1..57fa40d0 100644 --- a/src/lib/utils/__tests__/view-utils.test.ts +++ b/src/lib/utils/__tests__/view-utils.test.ts @@ -32,6 +32,18 @@ describe('view-utils', () => { expect(isNotFoundView('/faq/missing', {})).toBe(true); }); + it.each(['/mod', '/mod/settings', '/mod/catalog', '/mod/catalog/settings', '/mod/queue', '/mod/queue/settings', '/mod/'])( + 'keeps canonical mod route %s out of the not-found view', + (pathname) => { + expect(isNotFoundView(pathname, {})).toBe(false); + }, + ); + + it('keeps invalid mod routes in the not-found view', () => { + expect(isNotFoundView('/mod/asdf', {})).toBe(true); + expect(isNotFoundView('/mod/modqueue', {})).toBe(true); + }); + it('detects board, catalog, post, and settings routes using board params', () => { const params = { boardIdentifier: 'music.eth', @@ -58,7 +70,16 @@ describe('view-utils', () => { expect(isNotFoundView('/definitely-not-a-route', params)).toBe(true); expect(isNotFoundView('/emoji-%F0%9F%8E%B5.eth/thread/cid-123', params)).toBe(false); expect(isArchiveView('/music.eth/archive', { boardIdentifier: 'music.eth' })).toBe(true); + expect(isArchiveView('/music.eth/archive/settings', { boardIdentifier: 'music.eth' })).toBe(true); expect(isBoardView('/music.eth/archive', { boardIdentifier: 'music.eth' })).toBe(false); expect(isNotFoundView('/music.eth/archive', { boardIdentifier: 'music.eth' })).toBe(false); + expect(isNotFoundView('/music.eth/archive/settings/', { boardIdentifier: 'music.eth' })).toBe(false); }); + + it.each(['/faq/', '/pass/', '/rules/', '/blotter/', '/settings/account-data/', '/not-allowed/', '/subs/', '/subs/catalog/settings/'])( + 'normalizes the valid trailing-slash route %s', + (pathname) => { + expect(isNotFoundView(pathname, {})).toBe(false); + }, + ); }); diff --git a/src/lib/utils/view-utils.ts b/src/lib/utils/view-utils.ts index c31d1054..1b4e4249 100644 --- a/src/lib/utils/view-utils.ts +++ b/src/lib/utils/view-utils.ts @@ -1,4 +1,4 @@ -import { isArchiveRoute, isBoardModRoute, isModQueueRoute } from './route-utils'; +import { isArchiveRoute, isBoardModRoute, isModQueueRoute, isValidModRoute } from './route-utils'; type ParamsType = { accountCommentIndex?: string; @@ -10,6 +10,8 @@ const STATIC_APP_ROUTES = new Set(['/faq', '/pass', '/rules', '/blotter', '/sett const isStaticAppRoute = (pathname: string): boolean => STATIC_APP_ROUTES.has(pathname); +const normalizeViewPathname = (pathname: string): string => pathname.replace(/\/+$/, '') || '/'; + export const isAllView = (pathname: string): boolean => { return pathname.startsWith('/all'); }; @@ -89,23 +91,27 @@ export const isSubscriptionsView = (pathname: string, _params: ParamsType): bool export const isArchiveView = (pathname: string, params: ParamsType): boolean => { const { boardIdentifier } = params; const identifier = boardIdentifier; - const decodedPathname = decodeURIComponent(pathname); + const decodedPathname = decodeURIComponent(normalizeViewPathname(pathname)); + const archivePathname = decodedPathname.replace(/\/settings$/, ''); - return Boolean(identifier && isArchiveRoute(decodedPathname) && decodedPathname === `/${identifier}/archive`); + return Boolean(identifier && isArchiveRoute(decodedPathname) && archivePathname === `/${identifier}/archive`); }; export const isNotFoundView = (pathname: string, params: ParamsType): boolean => { + const normalizedPathname = normalizeViewPathname(pathname); + return ( - !isAllView(pathname) && - !isBoardView(pathname, params) && - !isArchiveView(pathname, params) && - !isCatalogView(pathname, params) && - !isHomeView(pathname) && - !isStaticAppRoute(pathname) && - !isPendingPostView(pathname, params) && - !isPostPageView(pathname, params) && - !isSettingsView(pathname, params) && - !isSubscriptionsView(pathname, params) && - !isModQueueView(pathname) + !isAllView(normalizedPathname) && + !isBoardView(normalizedPathname, params) && + !isArchiveView(normalizedPathname, params) && + !isCatalogView(normalizedPathname, params) && + !isHomeView(normalizedPathname) && + !isStaticAppRoute(normalizedPathname) && + !isPendingPostView(normalizedPathname, params) && + !isPostPageView(normalizedPathname, params) && + !isSettingsView(normalizedPathname, params) && + !isSubscriptionsView(normalizedPathname, params) && + !isValidModRoute(normalizedPathname) && + !isModQueueView(normalizedPathname) ); };