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 = () => {
|
const GlobalLayout = () => {
|
||||||
useTheme();
|
useTheme({ applyDocumentEffects: true });
|
||||||
useSuspendOffscreenMediaPlayback();
|
useSuspendOffscreenMediaPlayback();
|
||||||
|
|
||||||
const {
|
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 act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||||
|
|
||||||
const testState = vi.hoisted(() => ({
|
const testState = vi.hoisted(() => ({
|
||||||
boardIdentifier: 'trash',
|
boardIdentifier: 'trash' as string | undefined,
|
||||||
directories: [] as Array<{ address: string; nsfw?: boolean }>,
|
directories: [] as Array<{ address: string; nsfw?: boolean }>,
|
||||||
isSpecialThemeEnabled: false as boolean | null,
|
isSpecialThemeEnabled: false as boolean | null,
|
||||||
locationPathname: '/trash',
|
locationPathname: '/trash',
|
||||||
resolvedAddress: 'off-topic.bso' as string | undefined,
|
resolvedAddress: 'off-topic.bso' as string | undefined,
|
||||||
setIsEnabledMock: vi.fn(),
|
setIsEnabledMock: vi.fn(),
|
||||||
setThemeMock: vi.fn().mockResolvedValue(undefined),
|
setThemeMock: vi.fn().mockResolvedValue(undefined),
|
||||||
|
updateFaviconMock: vi.fn(),
|
||||||
themes: {
|
themes: {
|
||||||
nsfw: 'tomorrow',
|
nsfw: 'tomorrow',
|
||||||
sfw: 'yotsuba-b',
|
sfw: 'yotsuba-b',
|
||||||
@@ -60,7 +61,7 @@ vi.mock('../../stores/use-theme-store', () => ({
|
|||||||
|
|
||||||
vi.mock('../../lib/update-favicon', () => ({
|
vi.mock('../../lib/update-favicon', () => ({
|
||||||
isSfwBoard: () => false,
|
isSfwBoard: () => false,
|
||||||
updateFavicon: vi.fn(),
|
updateFavicon: testState.updateFaviconMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../lib/utils/time-utils', () => ({
|
vi.mock('../../lib/utils/time-utils', () => ({
|
||||||
@@ -73,7 +74,7 @@ let container: HTMLDivElement;
|
|||||||
let root: Root;
|
let root: Root;
|
||||||
|
|
||||||
const HookHarness = () => {
|
const HookHarness = () => {
|
||||||
latestValue = useTheme();
|
latestValue = useTheme({ applyDocumentEffects: true });
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,4 +120,18 @@ describe('useTheme', () => {
|
|||||||
|
|
||||||
expect(testState.setThemeMock).toHaveBeenCalledWith('nsfw', 'photon');
|
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'];
|
const themeClasses = ['yotsuba', 'yotsuba-b', 'futaba', 'burichan', 'tomorrow', 'photon', 'spooky'];
|
||||||
|
|
||||||
|
type UseThemeOptions = {
|
||||||
|
applyDocumentEffects?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const updateThemeClass = (newTheme: string) => {
|
const updateThemeClass = (newTheme: string) => {
|
||||||
document.body.classList.remove(...themeClasses);
|
document.body.classList.remove(...themeClasses);
|
||||||
if (newTheme) {
|
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 location = useLocation();
|
||||||
const params = useParams<{ accountCommentIndex?: string; boardIdentifier?: string; commentCid?: string }>();
|
const params = useParams<{ accountCommentIndex?: string; boardIdentifier?: string; commentCid?: string }>();
|
||||||
const pendingPost = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) });
|
const pendingPost = useAccountComment({ commentIndex: normalizeAccountCommentIndex(params?.accountCommentIndex) });
|
||||||
@@ -90,12 +94,15 @@ const useTheme = (): [string, (theme: string) => void] => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!applyDocumentEffects) return;
|
||||||
updateThemeClass(currentTheme);
|
updateThemeClass(currentTheme);
|
||||||
}, [currentTheme]);
|
}, [applyDocumentEffects, currentTheme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateFavicon(isInNotFoundView ? 'not-found' : sfw);
|
if (!applyDocumentEffects) return;
|
||||||
}, [isInNotFoundView, sfw]);
|
const faviconVariant = isInNotFoundView ? 'not-found' : sfw ? 'sfw' : 'default';
|
||||||
|
updateFavicon(faviconVariant);
|
||||||
|
}, [applyDocumentEffects, isInNotFoundView, sfw]);
|
||||||
|
|
||||||
const setCommunityTheme = useCallback(
|
const setCommunityTheme = useCallback(
|
||||||
async (newTheme: string) => {
|
async (newTheme: string) => {
|
||||||
|
|||||||
@@ -13,17 +13,17 @@ describe('update-favicon', () => {
|
|||||||
document.head.innerHTML =
|
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">';
|
'<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.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="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="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="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');
|
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);
|
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.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="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');
|
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="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="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');
|
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 () => {
|
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_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(', ');
|
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 }> = {
|
const FAVICONS: Record<FaviconVariant, { href: string; type: string }> = {
|
||||||
default: { href: DEFAULT_FAVICON, type: 'image/png' },
|
default: { href: DEFAULT_FAVICON, type: 'image/png' },
|
||||||
@@ -29,17 +29,12 @@ const createFaviconLink = (rel: (typeof FAVICON_RELS)[number], favicon: (typeof
|
|||||||
return link;
|
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.
|
* 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.
|
* Uses remove-and-recreate plus cache-busted URLs to bypass sticky favicon caching.
|
||||||
*/
|
*/
|
||||||
export const updateFavicon = (variant: boolean | FaviconVariant): void => {
|
export const updateFavicon = (variant: FaviconVariant): void => {
|
||||||
const favicon = FAVICONS[getFaviconVariant(variant)];
|
const favicon = FAVICONS[variant];
|
||||||
const { href } = favicon;
|
const { href } = favicon;
|
||||||
if (href === currentHref && hasExpectedFaviconLinks(href)) return;
|
if (href === currentHref && hasExpectedFaviconLinks(href)) return;
|
||||||
currentHref = href;
|
currentHref = href;
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ describe('view-utils', () => {
|
|||||||
expect(isNotFoundView('/faq/missing', {})).toBe(true);
|
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', () => {
|
it('detects board, catalog, post, and settings routes using board params', () => {
|
||||||
const params = {
|
const params = {
|
||||||
boardIdentifier: 'music.eth',
|
boardIdentifier: 'music.eth',
|
||||||
@@ -58,7 +70,16 @@ describe('view-utils', () => {
|
|||||||
expect(isNotFoundView('/definitely-not-a-route', params)).toBe(true);
|
expect(isNotFoundView('/definitely-not-a-route', params)).toBe(true);
|
||||||
expect(isNotFoundView('/emoji-%F0%9F%8E%B5.eth/thread/cid-123', params)).toBe(false);
|
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', { 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(isBoardView('/music.eth/archive', { boardIdentifier: 'music.eth' })).toBe(false);
|
||||||
expect(isNotFoundView('/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 = {
|
type ParamsType = {
|
||||||
accountCommentIndex?: string;
|
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 isStaticAppRoute = (pathname: string): boolean => STATIC_APP_ROUTES.has(pathname);
|
||||||
|
|
||||||
|
const normalizeViewPathname = (pathname: string): string => pathname.replace(/\/+$/, '') || '/';
|
||||||
|
|
||||||
export const isAllView = (pathname: string): boolean => {
|
export const isAllView = (pathname: string): boolean => {
|
||||||
return pathname.startsWith('/all');
|
return pathname.startsWith('/all');
|
||||||
};
|
};
|
||||||
@@ -89,23 +91,27 @@ export const isSubscriptionsView = (pathname: string, _params: ParamsType): bool
|
|||||||
export const isArchiveView = (pathname: string, params: ParamsType): boolean => {
|
export const isArchiveView = (pathname: string, params: ParamsType): boolean => {
|
||||||
const { boardIdentifier } = params;
|
const { boardIdentifier } = params;
|
||||||
const identifier = boardIdentifier;
|
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 => {
|
export const isNotFoundView = (pathname: string, params: ParamsType): boolean => {
|
||||||
|
const normalizedPathname = normalizeViewPathname(pathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
!isAllView(pathname) &&
|
!isAllView(normalizedPathname) &&
|
||||||
!isBoardView(pathname, params) &&
|
!isBoardView(normalizedPathname, params) &&
|
||||||
!isArchiveView(pathname, params) &&
|
!isArchiveView(normalizedPathname, params) &&
|
||||||
!isCatalogView(pathname, params) &&
|
!isCatalogView(normalizedPathname, params) &&
|
||||||
!isHomeView(pathname) &&
|
!isHomeView(normalizedPathname) &&
|
||||||
!isStaticAppRoute(pathname) &&
|
!isStaticAppRoute(normalizedPathname) &&
|
||||||
!isPendingPostView(pathname, params) &&
|
!isPendingPostView(normalizedPathname, params) &&
|
||||||
!isPostPageView(pathname, params) &&
|
!isPostPageView(normalizedPathname, params) &&
|
||||||
!isSettingsView(pathname, params) &&
|
!isSettingsView(normalizedPathname, params) &&
|
||||||
!isSubscriptionsView(pathname, params) &&
|
!isSubscriptionsView(normalizedPathname, params) &&
|
||||||
!isModQueueView(pathname)
|
!isValidModRoute(normalizedPathname) &&
|
||||||
|
!isModQueueView(normalizedPathname)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user