diff --git a/src/__tests__/app.test.tsx b/src/__tests__/app.test.tsx index 59365f4d..08c21ed3 100644 --- a/src/__tests__/app.test.tsx +++ b/src/__tests__/app.test.tsx @@ -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; diff --git a/src/app.tsx b/src/app.tsx index 1d925751..ba39bc86 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -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 ; } + if (boardIdentifier && pageNumber && !isBoardFeedPageNumber(pageNumber)) { + return ; + } + if (isCatalogView(pathname, params) && isFlashBoardRoute(boardIdentifier, directories)) { return ; } @@ -142,7 +148,7 @@ const BoardLayout = () => { const canonicalBoardIdentifier = resolvedDirectoryBoardPath ?? (isDirectoryCandidate ? boardIdentifier : getBoardPath(boardIdentifier, directories)); if (canonicalBoardIdentifier !== boardIdentifier) { const canonicalPath = pathname.replace(`/${boardIdentifier}`, `/${canonicalBoardIdentifier}`); - return ; + return ; } } diff --git a/src/components/catalog-search/__tests__/catalog-search.test.tsx b/src/components/catalog-search/__tests__/catalog-search.test.tsx index 65aa01b9..f9f25e05 100644 --- a/src/components/catalog-search/__tests__/catalog-search.test.tsx +++ b/src/components/catalog-search/__tests__/catalog-search.test.tsx @@ -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' })); diff --git a/src/components/catalog-search/catalog-search.tsx b/src/components/catalog-search/catalog-search.tsx index 07c4ff4c..03610bc8 100644 --- a/src/components/catalog-search/catalog-search.tsx +++ b/src/components/catalog-search/catalog-search.tsx @@ -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}` : ''); diff --git a/src/components/markdown/__tests__/markdown.test.tsx b/src/components/markdown/__tests__/markdown.test.tsx index 50613e7d..65fbe159 100644 --- a/src/components/markdown/__tests__/markdown.test.tsx +++ b/src/components/markdown/__tests__/markdown.test.tsx @@ -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', diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx index 98aa086b..5f918ea2 100644 --- a/src/components/markdown/markdown.tsx +++ b/src/components/markdown/markdown.tsx @@ -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 = /(?>>\/((?:[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}`; } diff --git a/src/lib/utils/__tests__/route-utils.test.ts b/src/lib/utils/__tests__/route-utils.test.ts index 3965997c..bbd0a02c 100644 --- a/src/lib/utils/__tests__/route-utils.test.ts +++ b/src/lib/utils/__tests__/route-utils.test.ts @@ -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'); diff --git a/src/lib/utils/__tests__/url-utils.test.ts b/src/lib/utils/__tests__/url-utils.test.ts index b41444bb..1c2d3375 100644 --- a/src/lib/utils/__tests__/url-utils.test.ts +++ b/src/lib/utils/__tests__/url-utils.test.ts @@ -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); }); }); diff --git a/src/lib/utils/route-utils.ts b/src/lib/utils/route-utils.ts index abbd9df9..5286ee48 100644 --- a/src/lib/utils/route-utils.ts +++ b/src/lib/utils/route-utils.ts @@ -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 { diff --git a/src/lib/utils/url-utils.ts b/src/lib/utils/url-utils.ts index 9bdb2b00..89dc4de6 100644 --- a/src/lib/utils/url-utils.ts +++ b/src/lib/utils/url-utils.ts @@ -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); }; diff --git a/src/views/catalog/__tests__/catalog.test.tsx b/src/views/catalog/__tests__/catalog.test.tsx index d8605166..4a4c4fcf 100644 --- a/src/views/catalog/__tests__/catalog.test.tsx +++ b/src/views/catalog/__tests__/catalog.test.tsx @@ -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');