mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Merge branch 'codex/fix/favicon-route-ownership'
This commit is contained in:
+1
-1
@@ -200,7 +200,7 @@ const BoardLayout = () => {
|
||||
};
|
||||
|
||||
const GlobalLayout = () => {
|
||||
useTheme();
|
||||
useTheme({ applyDocumentEffects: true });
|
||||
useSuspendOffscreenMediaPlayback();
|
||||
|
||||
const {
|
||||
|
||||
@@ -9,13 +9,14 @@ import { TRASH_BOARD_ADDRESS, TRASH_BOARD_CODE } from '../../lib/special-boards'
|
||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
+11
-4
@@ -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) => {
|
||||
|
||||
@@ -13,17 +13,17 @@ describe('update-favicon', () => {
|
||||
document.head.innerHTML =
|
||||
'<link rel="icon" sizes="16x16" href="/favicon.ico"><link rel="shortcut icon" sizes="16x16" href="/favicon.ico"><link rel="icon" sizes="192x192" href="/manifest-icon-192x192.png"><link rel="apple-touch-icon" sizes="256x256" href="/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);
|
||||
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 () => {
|
||||
|
||||
@@ -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<FaviconVariant, { href: string; type: string }> = {
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
+20
-14
@@ -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)
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user