Merge branch 'master' of github.com:bitsocialnet/5chan

This commit is contained in:
Tommaso Casaburi
2026-06-27 17:47:01 +07:00
11 changed files with 157 additions and 21 deletions
+27 -1
View File
@@ -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
View File
@@ -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',
+10 -1
View File
@@ -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');
+4 -1
View File
@@ -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);
});
});
+10 -2
View File
@@ -215,10 +215,18 @@ export const isValidBoardModRoute = (pathname: string): boolean => {
return VALID_BOARD_MOD_SUBPATHS.includes(subpath);
};
/** Page numbers 110 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 {
+13
View File
@@ -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);
};
+1 -1
View File
@@ -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');