mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Merge branch 'master' of github.com:bitsocialnet/5chan
This commit is contained in:
@@ -278,6 +278,7 @@ vi.mock('../components/reply-modal', () => ({
|
||||
default: ({ parentCid, postCid }: { parentCid: string; postCid: string }) => createElement('div', { 'data-testid': 'reply-modal' }, `${parentCid}:${postCid}`),
|
||||
}));
|
||||
|
||||
let latestHash = '';
|
||||
let latestLocation = '';
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
@@ -287,7 +288,8 @@ const LocationProbe = () => {
|
||||
const location = useLocation();
|
||||
React.useLayoutEffect(() => {
|
||||
latestLocation = `${location.pathname}${location.search}`;
|
||||
}, [location.pathname, location.search]);
|
||||
latestHash = location.hash;
|
||||
}, [location.hash, location.pathname, location.search]);
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -304,6 +306,7 @@ const renderApp = async (initialEntry: string) => {
|
||||
App = (await import('../app')).default;
|
||||
}
|
||||
|
||||
latestHash = '';
|
||||
latestLocation = initialEntry;
|
||||
act(() => {
|
||||
root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, createElement(App!), createElement(LocationProbe)));
|
||||
@@ -350,6 +353,7 @@ describe('App', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
latestHash = '';
|
||||
latestLocation = '';
|
||||
testState.account = { author: { address: '0x123' } };
|
||||
testState.accountComments = {};
|
||||
@@ -443,6 +447,21 @@ describe('App', () => {
|
||||
expect(container.querySelector('[data-testid="not-found-view"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('redirects unknown board subpaths to catalog search hashes', async () => {
|
||||
await renderApp('/mu/test');
|
||||
|
||||
expect(latestLocation).toBe('/mu/catalog');
|
||||
expect(latestHash).toBe('#s=test');
|
||||
});
|
||||
|
||||
it('redirects unknown board settings subpaths to catalog search settings hashes', async () => {
|
||||
await renderApp('/mu/test/settings');
|
||||
|
||||
expect(latestLocation).toBe('/mu/catalog/settings');
|
||||
expect(latestHash).toBe('#s=test');
|
||||
expect(container.querySelector('[data-testid="settings-modal"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('redirects flash board catalog routes to not-found', async () => {
|
||||
testState.directories = [
|
||||
{ address: 'music-posting.eth', title: '/mu/ - Music', nsfw: false },
|
||||
@@ -486,6 +505,13 @@ describe('App', () => {
|
||||
expect(container.querySelector('[data-testid="post-view"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('canonicalizes board address catalog routes while preserving search hashes', async () => {
|
||||
await renderApp('/music-posting.eth/catalog#s=test');
|
||||
|
||||
expect(latestLocation).toBe('/mu/catalog');
|
||||
expect(latestHash).toBe('#s=test');
|
||||
});
|
||||
|
||||
it('canonicalizes a direct route for the current resolved directory board', async () => {
|
||||
testState.resolvedDirectoryBoardPath = 'biz';
|
||||
testState.isDirectoryCandidate = true;
|
||||
|
||||
+7
-1
@@ -32,6 +32,8 @@ import {
|
||||
isValidBoardModRoute,
|
||||
isValidModRoute,
|
||||
isFlashBoardRoute,
|
||||
isBoardFeedPageNumber,
|
||||
getCatalogSearchRoute,
|
||||
} from './lib/utils/route-utils';
|
||||
import styles from './app.module.css';
|
||||
import { DesktopBoardButtons, MobileAllFeedFilter, MobileBoardButtons } from './components/board-buttons/board-buttons';
|
||||
@@ -119,6 +121,10 @@ const BoardLayout = () => {
|
||||
return <Navigate to={{ pathname: getPageOneCanonicalPath(boardIdentifier, pathname), search, hash }} replace />;
|
||||
}
|
||||
|
||||
if (boardIdentifier && pageNumber && !isBoardFeedPageNumber(pageNumber)) {
|
||||
return <Navigate to={getCatalogSearchRoute(boardIdentifier, pageNumber, search, { settings: pathname.endsWith('/settings') })} replace />;
|
||||
}
|
||||
|
||||
if (isCatalogView(pathname, params) && isFlashBoardRoute(boardIdentifier, directories)) {
|
||||
return <Navigate to='/not-found' replace />;
|
||||
}
|
||||
@@ -142,7 +148,7 @@ const BoardLayout = () => {
|
||||
const canonicalBoardIdentifier = resolvedDirectoryBoardPath ?? (isDirectoryCandidate ? boardIdentifier : getBoardPath(boardIdentifier, directories));
|
||||
if (canonicalBoardIdentifier !== boardIdentifier) {
|
||||
const canonicalPath = pathname.replace(`/${boardIdentifier}`, `/${canonicalBoardIdentifier}`);
|
||||
return <Navigate to={canonicalPath + (search || '')} replace />;
|
||||
return <Navigate to={canonicalPath + (search || '') + (hash || '')} replace />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const testState = vi.hoisted(() => ({
|
||||
debounceCancelMock: vi.fn(),
|
||||
isMobile: false,
|
||||
location: {
|
||||
hash: '',
|
||||
pathname: '/mu/catalog',
|
||||
search: '',
|
||||
},
|
||||
@@ -92,6 +93,7 @@ describe('CatalogSearch', () => {
|
||||
testState.debounceCancelMock.mockReset();
|
||||
testState.isMobile = false;
|
||||
testState.location = {
|
||||
hash: '',
|
||||
pathname: '/mu/catalog',
|
||||
search: '',
|
||||
};
|
||||
@@ -108,10 +110,11 @@ describe('CatalogSearch', () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('opens from the query param and seeds the catalog search filter', async () => {
|
||||
it('opens from the search hash and seeds the catalog search filter', async () => {
|
||||
testState.location = {
|
||||
hash: '#s=linux',
|
||||
pathname: '/mu/catalog',
|
||||
search: '?q=linux',
|
||||
search: '',
|
||||
};
|
||||
|
||||
await renderSearch();
|
||||
@@ -121,15 +124,43 @@ describe('CatalogSearch', () => {
|
||||
expect(queryInput()?.getAttribute('value')).toBe('linux');
|
||||
});
|
||||
|
||||
it('clears the catalog search filter when navigation removes the query param', async () => {
|
||||
it('migrates the legacy query param to the search hash', async () => {
|
||||
testState.location = {
|
||||
hash: '',
|
||||
pathname: '/mu/catalog',
|
||||
search: '?q=linux',
|
||||
};
|
||||
|
||||
await renderSearch();
|
||||
|
||||
expect(testState.setSearchFilterMock).toHaveBeenCalledWith('linux');
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog#s=linux', { replace: true });
|
||||
});
|
||||
|
||||
it('prefers the search hash when stripping a legacy query param', async () => {
|
||||
testState.location = {
|
||||
hash: '#s=hash-value',
|
||||
pathname: '/mu/catalog',
|
||||
search: '?t=1w&q=query-value',
|
||||
};
|
||||
|
||||
await renderSearch();
|
||||
|
||||
expect(testState.setSearchFilterMock).toHaveBeenCalledWith('hash-value');
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog?t=1w#s=hash-value', { replace: true });
|
||||
});
|
||||
|
||||
it('clears the catalog search filter when navigation removes the search hash', async () => {
|
||||
testState.location = {
|
||||
hash: '#s=linux',
|
||||
pathname: '/mu/catalog',
|
||||
search: '',
|
||||
};
|
||||
|
||||
await renderSearch();
|
||||
|
||||
testState.location = {
|
||||
hash: '',
|
||||
pathname: '/mu/catalog',
|
||||
search: '',
|
||||
};
|
||||
@@ -154,7 +185,7 @@ describe('CatalogSearch', () => {
|
||||
await dispatchInput(input, 'web3');
|
||||
|
||||
expect(testState.setSearchFilterMock).toHaveBeenCalledWith('web3');
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog?q=web3', { replace: true });
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog#s=web3', { replace: true });
|
||||
|
||||
await act(async () => {
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }));
|
||||
|
||||
@@ -5,33 +5,44 @@ import styles from './catalog-search.module.css';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { getCatalogSearchHash } from '../../lib/utils/route-utils';
|
||||
|
||||
const CatalogSearch = () => {
|
||||
const { t } = useTranslation();
|
||||
const { pathname, search } = useLocation();
|
||||
const { pathname, search, hash } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchState, setSearchState] = useState({ open: false, value: '' });
|
||||
const { setSearchFilter, clearSearchFilter } = useCatalogFiltersStore();
|
||||
const queryParam = new URLSearchParams(search).get('q') ?? '';
|
||||
const openSearch = !!queryParam || searchState.open;
|
||||
const inputValue = searchState.open || searchState.value ? searchState.value : queryParam;
|
||||
const legacyQueryParam = new URLSearchParams(search).get('q') ?? '';
|
||||
const hashSearchParam = new URLSearchParams(hash.replace(/^#/, '')).get('s') ?? '';
|
||||
const catalogSearchParam = hashSearchParam || legacyQueryParam;
|
||||
const openSearch = !!catalogSearchParam || searchState.open;
|
||||
const inputValue = searchState.open || searchState.value ? searchState.value : catalogSearchParam;
|
||||
|
||||
useEffect(() => {
|
||||
if (queryParam) {
|
||||
setSearchFilter(queryParam);
|
||||
if (legacyQueryParam) {
|
||||
const urlParams = new URLSearchParams(search);
|
||||
urlParams.delete('q');
|
||||
const newSearch = urlParams.toString();
|
||||
navigate(`${pathname}${newSearch ? `?${newSearch}` : ''}${getCatalogSearchHash(catalogSearchParam)}`, { replace: true });
|
||||
}
|
||||
|
||||
if (catalogSearchParam) {
|
||||
setSearchFilter(catalogSearchParam);
|
||||
return;
|
||||
}
|
||||
|
||||
clearSearchFilter();
|
||||
}, [queryParam, setSearchFilter, clearSearchFilter]);
|
||||
}, [catalogSearchParam, clearSearchFilter, legacyQueryParam, navigate, pathname, search, setSearchFilter]);
|
||||
|
||||
const updateURL = useCallback(
|
||||
(searchText: string) => {
|
||||
const urlParams = new URLSearchParams(search);
|
||||
urlParams.delete('q');
|
||||
if (searchText.trim()) {
|
||||
urlParams.set('q', searchText);
|
||||
} else {
|
||||
urlParams.delete('q');
|
||||
const newSearch = urlParams.toString();
|
||||
navigate(`${pathname}${newSearch ? `?${newSearch}` : ''}${getCatalogSearchHash(searchText)}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
const newSearch = urlParams.toString();
|
||||
const newPath = pathname + (newSearch ? `?${newSearch}` : '');
|
||||
|
||||
@@ -349,6 +349,22 @@ describe('Markdown', () => {
|
||||
expect(container.textContent).toBe('see >>>/fit/, next');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['>>>/biz/test', '/biz/catalog#s=test'],
|
||||
['>>>/biz/test-term', '/biz/catalog#s=test-term'],
|
||||
['>>>/biz/test_term', '/biz/catalog#s=test_term'],
|
||||
['>>>/board.eth/test', '/board.eth/catalog#s=test'],
|
||||
])('renders board search path %s as a catalog search hash link', async (quoteLink, href) => {
|
||||
await renderMarkdown({
|
||||
content: `see ${quoteLink}.`,
|
||||
});
|
||||
|
||||
const link = container.querySelector('a');
|
||||
expect(link?.getAttribute('href')).toBe(href);
|
||||
expect(link?.textContent).toBe(quoteLink);
|
||||
expect(container.textContent).toBe(`see ${quoteLink}.`);
|
||||
});
|
||||
|
||||
it('normalizes hash-routed 5chan links before passing them to React Router', async () => {
|
||||
testState.internalPathByHref = {
|
||||
'https://5chan.local/#/mu': '#/mu',
|
||||
|
||||
@@ -21,6 +21,7 @@ import ReplyQuotePreview from '../reply-quote-preview/reply-quote-preview';
|
||||
import ExternalNumberQuoteLink from './external-number-quote-link';
|
||||
import { findDirectoryByAddress, useDirectories, type DirectoryCommunity } from '../../hooks/use-directories';
|
||||
import { getDirectoryCodeForBoardAddress } from '../../lib/utils/directory-list-lookup-utils';
|
||||
import { getCatalogSearchRoute } from '../../lib/utils/route-utils';
|
||||
import {
|
||||
createDiceRollMarkupRegex,
|
||||
createFortuneBbcodeRegex,
|
||||
@@ -163,7 +164,7 @@ type Token =
|
||||
|
||||
const SPOILER_REGEX = /\[[sS][pP][oO][iI][lL][eE][rR]\]([\s\S]*?)\[\/[sS][pP][oO][iI][lL][eE][rR]\]/;
|
||||
const MARKDOWN_LINK_REGEX = /(?<!!)\[([^\]\n]+)\]\(\s*([^\n)]*?)\s*\)/;
|
||||
const CROSSBOARD_REGEX = />>>\/((?:[a-zA-Z0-9]{1,10}\/(?:[a-zA-Z0-9]{46})?|[a-zA-Z0-9\-.]+(?:\/[a-zA-Z0-9]{46})?))[.,:;!?]*/;
|
||||
const CROSSBOARD_REGEX = />>>\/((?:[a-zA-Z0-9]{1,10}\/(?:[a-zA-Z0-9]{46}|[a-zA-Z0-9_-]+)?|[a-zA-Z0-9\-.]+(?:\/(?:[a-zA-Z0-9]{46}|[a-zA-Z0-9_-]+))?))[.,:;!?]*/;
|
||||
const QUOTE_LINK_REGEX = /(?<![>/\w])>>(\d+)(?![\d/])/;
|
||||
const URL_REGEX = /https?:\/\/[^\s<>[\]]+/;
|
||||
type QstBbcodeTag = 'b' | 'i' | 'red' | 'green' | 'blue';
|
||||
@@ -283,10 +284,18 @@ function getCrossboardRoute(fullPattern: string): string | null {
|
||||
const [code, cid] = pathPart.split('/');
|
||||
return `/${code}/thread/${cid}`;
|
||||
}
|
||||
if (/^[a-zA-Z0-9]{1,10}\/[a-zA-Z0-9_-]+$/.test(pathPart)) {
|
||||
const [code, searchText] = pathPart.split('/');
|
||||
return getCatalogSearchRoute(code, searchText);
|
||||
}
|
||||
if (/^[^/]+\/[a-zA-Z0-9]{46}$/.test(pathPart)) {
|
||||
const [address, cid] = pathPart.split('/');
|
||||
return `/${address}/thread/${cid}`;
|
||||
}
|
||||
if (/^[^/]+\/[a-zA-Z0-9_-]+$/.test(pathPart)) {
|
||||
const [address, searchText] = pathPart.split('/');
|
||||
return getCatalogSearchRoute(address, searchText);
|
||||
}
|
||||
return `/${pathPart}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
areSameBoardAddress,
|
||||
extractDirectoryFromTitle,
|
||||
getBoardPath,
|
||||
getCatalogSearchHash,
|
||||
getCatalogSearchRoute,
|
||||
getFeedCacheKey,
|
||||
getFeedType,
|
||||
getPageFromFeedPath,
|
||||
@@ -209,6 +211,17 @@ describe('feed pagination helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog search route helpers', () => {
|
||||
it('formats catalog search routes with 4chan-style hash params', () => {
|
||||
expect(getCatalogSearchHash('test')).toBe('#s=test');
|
||||
expect(getCatalogSearchRoute('biz', 'test')).toBe('/biz/catalog#s=test');
|
||||
expect(getCatalogSearchRoute('biz', 'test', '', { settings: true })).toBe('/biz/catalog/settings#s=test');
|
||||
expect(getCatalogSearchRoute('biz', 'cats and dogs', '?t=1w')).toBe('/biz/catalog?t=1w#s=cats%20and%20dogs');
|
||||
expect(getCatalogSearchHash(' ')).toBe('');
|
||||
expect(getCatalogSearchRoute('biz', '')).toBe('/biz/catalog');
|
||||
});
|
||||
});
|
||||
|
||||
describe('feed cache helpers', () => {
|
||||
it('derives cache keys for feeds and threads', () => {
|
||||
expect(getFeedCacheKey('/biz')).toBe('/biz');
|
||||
|
||||
@@ -107,10 +107,13 @@ describe('url-utils', () => {
|
||||
expect(isValidCrossboardPattern('>>>/biz/')).toBe(true);
|
||||
expect(isValidCrossboardPattern(`>>>/biz/${'a'.repeat(46)}`)).toBe(true);
|
||||
expect(isValidCrossboardPattern('>>>/biz/123')).toBe(true);
|
||||
expect(isValidCrossboardPattern('>>>/biz/test')).toBe(true);
|
||||
expect(isValidCrossboardPattern(`>>>/board.eth/${'b'.repeat(46)}`)).toBe(true);
|
||||
expect(isValidCrossboardPattern('>>>/board.eth/123')).toBe(true);
|
||||
expect(isValidCrossboardPattern('>>>/board.eth/test')).toBe(true);
|
||||
expect(isValidCrossboardPattern(`>>>/${ipnsKey}`)).toBe(true);
|
||||
expect(isValidCrossboardPattern('>>>/invalid/thread-with-short-cid')).toBe(false);
|
||||
expect(isValidCrossboardPattern(`>>>/${ipnsKey}/test`)).toBe(true);
|
||||
expect(isValidCrossboardPattern('>>>/invalid/thread/extra')).toBe(false);
|
||||
expect(isValidCrossboardPattern('>>/biz/')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -215,10 +215,18 @@ export const isValidBoardModRoute = (pathname: string): boolean => {
|
||||
return VALID_BOARD_MOD_SUBPATHS.includes(subpath);
|
||||
};
|
||||
|
||||
/** Page numbers 1–10 for board feed pagination */
|
||||
/** Page numbers 1-10 for board feed pagination */
|
||||
const BOARD_PAGE_REGEX = /^([1-9]|10)$/;
|
||||
|
||||
const isBoardFeedPageNumber = (segment: string): boolean => BOARD_PAGE_REGEX.test(segment);
|
||||
export const isBoardFeedPageNumber = (segment: string): boolean => BOARD_PAGE_REGEX.test(segment);
|
||||
|
||||
export const getCatalogSearchHash = (searchText: string): string => {
|
||||
const trimmedSearchText = searchText.trim();
|
||||
return trimmedSearchText ? `#s=${encodeURIComponent(trimmedSearchText)}` : '';
|
||||
};
|
||||
|
||||
export const getCatalogSearchRoute = (boardIdentifier: string, searchText: string, search = '', options?: { settings?: boolean }): string =>
|
||||
`/${boardIdentifier}/catalog${options?.settings ? '/settings' : ''}${search}${getCatalogSearchHash(searchText)}`;
|
||||
|
||||
/** Internal: check if segment is a multiboard root (all, subs, mod) */
|
||||
function isMultiboardRoot(segment: string): boolean {
|
||||
|
||||
@@ -296,6 +296,12 @@ export const isValidCrossboardPattern = (pattern: string): boolean => {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's a directory + catalog search pattern: >>>/biz/test
|
||||
const directoryCatalogSearchMatch = pathPart.match(/^([a-zA-Z0-9]{1,10})\/([a-zA-Z0-9_-]+)$/);
|
||||
if (directoryCatalogSearchMatch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's a full address + thread pattern: >>>/board.eth/fullCid
|
||||
const addressThreadMatch = pathPart.match(/^([^/]+)\/([a-zA-Z0-9]{46})$/);
|
||||
if (addressThreadMatch) {
|
||||
@@ -311,6 +317,13 @@ export const isValidCrossboardPattern = (pattern: string): boolean => {
|
||||
return isValidDomain(address) || isValidIPNSKey(address);
|
||||
}
|
||||
|
||||
// Check if it's a full address + catalog search pattern: >>>/board.eth/test
|
||||
const addressCatalogSearchMatch = pathPart.match(/^([^/]+)\/([a-zA-Z0-9_-]+)$/);
|
||||
if (addressCatalogSearchMatch) {
|
||||
const [, address] = addressCatalogSearchMatch;
|
||||
return isValidDomain(address) || isValidIPNSKey(address);
|
||||
}
|
||||
|
||||
// Check if it's just a full address pattern: >>>/board.eth
|
||||
return isValidDomain(pathPart) || isValidIPNSKey(pathPart);
|
||||
};
|
||||
|
||||
@@ -931,7 +931,7 @@ describe('Catalog', () => {
|
||||
testState.searchText = 'asddasd';
|
||||
testState.hasMore = false;
|
||||
|
||||
await renderCatalog({ initialEntry: '/mu/catalog?q=asddasd', routePath: '/:boardIdentifier/catalog' });
|
||||
await renderCatalog({ initialEntry: '/mu/catalog#s=asddasd', routePath: '/:boardIdentifier/catalog' });
|
||||
|
||||
expect(container.textContent).toContain('nothing_found');
|
||||
expect(container.textContent).not.toContain('no_threads');
|
||||
|
||||
Reference in New Issue
Block a user