diff --git a/scripts/run-electron-app-update-e2e.mjs b/scripts/run-electron-app-update-e2e.mjs index 5d38521a..dffdb617 100644 --- a/scripts/run-electron-app-update-e2e.mjs +++ b/scripts/run-electron-app-update-e2e.mjs @@ -5,7 +5,7 @@ import { copyPath, createTempWorkspace, findFirstMatchingPath, logStep, repoRoot const OLD_VERSION = '0.7.1'; const NEW_VERSION = '0.7.3'; -const SETTINGS_HASH = '#/all/settings#interface-settings'; +const SETTINGS_HASH = '#/all/settings?section=interface-settings'; const MAC_ARCH = process.arch === 'x64' ? 'x64' : 'arm64'; const findPackagedMacApp = async () => { diff --git a/src/__tests__/app.test.tsx b/src/__tests__/app.test.tsx index 08c21ed3..5e219b62 100644 --- a/src/__tests__/app.test.tsx +++ b/src/__tests__/app.test.tsx @@ -447,18 +447,18 @@ describe('App', () => { expect(container.querySelector('[data-testid="not-found-view"]')).toBeNull(); }); - it('redirects unknown board subpaths to catalog search hashes', async () => { + it('redirects unknown board subpaths to catalog search params', async () => { await renderApp('/mu/test'); - expect(latestLocation).toBe('/mu/catalog'); - expect(latestHash).toBe('#s=test'); + expect(latestLocation).toBe('/mu/catalog?s=test'); + expect(latestHash).toBe(''); }); - it('redirects unknown board settings subpaths to catalog search settings hashes', async () => { + it('redirects unknown board settings subpaths to catalog search settings params', async () => { await renderApp('/mu/test/settings'); - expect(latestLocation).toBe('/mu/catalog/settings'); - expect(latestHash).toBe('#s=test'); + expect(latestLocation).toBe('/mu/catalog/settings?s=test'); + expect(latestHash).toBe(''); expect(container.querySelector('[data-testid="settings-modal"]')).toBeTruthy(); }); @@ -505,11 +505,11 @@ 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'); + it('canonicalizes board address catalog routes while preserving search params', async () => { + await renderApp('/music-posting.eth/catalog?s=test'); - expect(latestLocation).toBe('/mu/catalog'); - expect(latestHash).toBe('#s=test'); + expect(latestLocation).toBe('/mu/catalog?s=test'); + expect(latestHash).toBe(''); }); it('canonicalizes a direct route for the current resolved directory board', async () => { @@ -558,7 +558,7 @@ describe('App', () => { expect(container.textContent).toContain('not_mod_of_any_board'); expect(container.textContent).toContain('go_to_settings_to_import_mod_account'); expect(container.querySelector('output')?.className).toContain('modEmptyState'); - expect(container.querySelector('output a')?.getAttribute('href')).toBe('/mod/settings#account-settings'); + expect(container.querySelector('output a')?.getAttribute('href')).toBe('/mod/settings?section=account-settings'); expect(container.querySelector('[data-testid="not-allowed-view"]')).toBeNull(); }); diff --git a/src/components/board-header/__tests__/board-header.test.tsx b/src/components/board-header/__tests__/board-header.test.tsx index e8f81dd5..590ab5c2 100644 --- a/src/components/board-header/__tests__/board-header.test.tsx +++ b/src/components/board-header/__tests__/board-header.test.tsx @@ -184,7 +184,7 @@ describe('BoardHeader', () => { clickableSubtitle?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); - expect(testState.navigateMock).toHaveBeenCalledWith('/subs/settings#subscriptions-settings'); + expect(testState.navigateMock).toHaveBeenCalledWith('/subs/settings?section=subscriptions-settings'); }); it('renders the board title, address subtitle, and offline indicator for board routes', async () => { diff --git a/src/components/board-header/board-header.tsx b/src/components/board-header/board-header.tsx index 373f106e..d7fb9b8f 100644 --- a/src/components/board-header/board-header.tsx +++ b/src/components/board-header/board-header.tsx @@ -119,10 +119,10 @@ const BoardHeader = () => { onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - navigate('/subs/settings#subscriptions-settings'); + navigate('/subs/settings?section=subscriptions-settings'); } }} - onClick={() => navigate('/subs/settings#subscriptions-settings')} + onClick={() => navigate('/subs/settings?section=subscriptions-settings')} > {subtitle} diff --git a/src/components/boards-bar-edit-modal/boards-bar-edit-modal.tsx b/src/components/boards-bar-edit-modal/boards-bar-edit-modal.tsx index b5c3028e..b9f0c527 100644 --- a/src/components/boards-bar-edit-modal/boards-bar-edit-modal.tsx +++ b/src/components/boards-bar-edit-modal/boards-bar-edit-modal.tsx @@ -82,7 +82,7 @@ const BoardsBarEditModalForm = ({ ( { e.stopPropagation(); diff --git a/src/components/catalog-search/__tests__/catalog-search.test.tsx b/src/components/catalog-search/__tests__/catalog-search.test.tsx index f9f25e05..9406130b 100644 --- a/src/components/catalog-search/__tests__/catalog-search.test.tsx +++ b/src/components/catalog-search/__tests__/catalog-search.test.tsx @@ -110,7 +110,7 @@ describe('CatalogSearch', () => { container.remove(); }); - it('opens from the search hash and seeds the catalog search filter', async () => { + it('opens from a legacy search hash, seeds the filter, and canonicalizes the URL', async () => { testState.location = { hash: '#s=linux', pathname: '/mu/catalog', @@ -120,11 +120,12 @@ describe('CatalogSearch', () => { await renderSearch(); expect(testState.setSearchFilterMock).toHaveBeenCalledWith('linux'); + expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog?s=linux', { replace: true }); expect(queryInput()).toBeTruthy(); expect(queryInput()?.getAttribute('value')).toBe('linux'); }); - it('migrates the legacy query param to the search hash', async () => { + it('migrates the legacy query param to the canonical search param', async () => { testState.location = { hash: '', pathname: '/mu/catalog', @@ -134,27 +135,27 @@ describe('CatalogSearch', () => { await renderSearch(); expect(testState.setSearchFilterMock).toHaveBeenCalledWith('linux'); - expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog#s=linux', { replace: true }); + expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog?s=linux', { replace: true }); }); - it('prefers the search hash when stripping a legacy query param', async () => { + it('prefers the canonical search param when stripping legacy params', async () => { testState.location = { hash: '#s=hash-value', pathname: '/mu/catalog', - search: '?t=1w&q=query-value', + search: '?t=1w&s=query-value&q=legacy-value', }; await renderSearch(); - expect(testState.setSearchFilterMock).toHaveBeenCalledWith('hash-value'); - expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog?t=1w#s=hash-value', { replace: true }); + expect(testState.setSearchFilterMock).toHaveBeenCalledWith('query-value'); + expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog?t=1w&s=query-value', { replace: true }); }); - it('clears the catalog search filter when navigation removes the search hash', async () => { + it('clears the catalog search filter when navigation removes the search param', async () => { testState.location = { - hash: '#s=linux', + hash: '', pathname: '/mu/catalog', - search: '', + search: '?s=linux', }; await renderSearch(); @@ -185,7 +186,7 @@ describe('CatalogSearch', () => { await dispatchInput(input, 'web3'); expect(testState.setSearchFilterMock).toHaveBeenCalledWith('web3'); - expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog#s=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 03610bc8..17e2c31c 100644 --- a/src/components/catalog-search/catalog-search.tsx +++ b/src/components/catalog-search/catalog-search.tsx @@ -5,7 +5,7 @@ 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'; +import { getCatalogSearchPath } from '../../lib/utils/route-utils'; const CatalogSearch = () => { const { t } = useTranslation(); @@ -13,18 +13,17 @@ const CatalogSearch = () => { const navigate = useNavigate(); const [searchState, setSearchState] = useState({ open: false, value: '' }); const { setSearchFilter, clearSearchFilter } = useCatalogFiltersStore(); - const legacyQueryParam = new URLSearchParams(search).get('q') ?? ''; + const searchParams = new URLSearchParams(search); + const querySearchParam = searchParams.get('s') ?? ''; + const legacyQueryParam = searchParams.get('q') ?? ''; const hashSearchParam = new URLSearchParams(hash.replace(/^#/, '')).get('s') ?? ''; - const catalogSearchParam = hashSearchParam || legacyQueryParam; + const catalogSearchParam = querySearchParam || hashSearchParam || legacyQueryParam; const openSearch = !!catalogSearchParam || searchState.open; const inputValue = searchState.open || searchState.value ? searchState.value : catalogSearchParam; useEffect(() => { - if (legacyQueryParam) { - const urlParams = new URLSearchParams(search); - urlParams.delete('q'); - const newSearch = urlParams.toString(); - navigate(`${pathname}${newSearch ? `?${newSearch}` : ''}${getCatalogSearchHash(catalogSearchParam)}`, { replace: true }); + if (hashSearchParam || legacyQueryParam) { + navigate(getCatalogSearchPath(pathname, catalogSearchParam, search), { replace: true }); } if (catalogSearchParam) { @@ -33,20 +32,11 @@ const CatalogSearch = () => { } clearSearchFilter(); - }, [catalogSearchParam, clearSearchFilter, legacyQueryParam, navigate, pathname, search, setSearchFilter]); + }, [catalogSearchParam, clearSearchFilter, hashSearchParam, legacyQueryParam, navigate, pathname, search, setSearchFilter]); const updateURL = useCallback( (searchText: string) => { - const urlParams = new URLSearchParams(search); - urlParams.delete('q'); - if (searchText.trim()) { - const newSearch = urlParams.toString(); - navigate(`${pathname}${newSearch ? `?${newSearch}` : ''}${getCatalogSearchHash(searchText)}`, { replace: true }); - return; - } - const newSearch = urlParams.toString(); - const newPath = pathname + (newSearch ? `?${newSearch}` : ''); - navigate(newPath, { replace: true }); + navigate(getCatalogSearchPath(pathname, searchText, search), { replace: true }); }, [pathname, search, navigate], ); diff --git a/src/components/markdown/__tests__/markdown.test.tsx b/src/components/markdown/__tests__/markdown.test.tsx index 63cbb9cb..37a5c959 100644 --- a/src/components/markdown/__tests__/markdown.test.tsx +++ b/src/components/markdown/__tests__/markdown.test.tsx @@ -365,10 +365,10 @@ describe('Markdown', () => { }); 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'], + ['>>>/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}.`, diff --git a/src/components/mod-empty-state/mod-empty-state.tsx b/src/components/mod-empty-state/mod-empty-state.tsx index ad4feb55..32645d04 100644 --- a/src/components/mod-empty-state/mod-empty-state.tsx +++ b/src/components/mod-empty-state/mod-empty-state.tsx @@ -3,7 +3,7 @@ import { Trans, useTranslation } from 'react-i18next'; import styles from './mod-empty-state.module.css'; const MOD_ACCOUNT_SETTINGS_LINK_COMPONENTS = { - 1: , + 1: , }; const ModEmptyState = () => { diff --git a/src/components/settings-modal/__tests__/settings-modal.test.tsx b/src/components/settings-modal/__tests__/settings-modal.test.tsx index 822e08ed..2958c354 100644 --- a/src/components/settings-modal/__tests__/settings-modal.test.tsx +++ b/src/components/settings-modal/__tests__/settings-modal.test.tsx @@ -70,7 +70,7 @@ vi.mock('../p2p-stats-settings/p2p-stats-settings', () => ({ const LocationProbe = () => { const location = useLocation(); - return
{location.pathname + location.hash}
; + return
{location.pathname + location.search + location.hash}
; }; let root: Root; @@ -131,16 +131,16 @@ describe('SettingsModal', () => { expect(container.querySelector('[data-testid="crypto-wallets-setting"]')).not.toBeNull(); }); - it('updates the hash when sections open and close', async () => { - render('/all/settings#account-settings'); + it('updates the section query when sections open and close', async () => { + render('/all/settings?section=account-settings'); - expect(getLocationText()).toBe('/all/settings#account-settings'); + expect(getLocationText()).toBe('/all/settings?section=account-settings'); await act(async () => { getSectionToggleByText('interface').click(); }); - expect(getLocationText()).toBe('/all/settings#interface-settings'); + expect(getLocationText()).toBe('/all/settings?section=interface-settings'); expect(container.querySelector('[data-testid="interface-settings-panel"]')).not.toBeNull(); expect(container.querySelector('[data-testid="account-settings"]')).not.toBeNull(); @@ -148,7 +148,7 @@ describe('SettingsModal', () => { getSectionToggleByText('interface').click(); }); - expect(getLocationText()).toBe('/all/settings#account-settings'); + expect(getLocationText()).toBe('/all/settings?section=account-settings'); expect(container.querySelector('[data-testid="interface-settings-panel"]')).toBeNull(); await act(async () => { @@ -233,8 +233,8 @@ describe('SettingsModal', () => { expect(useSettingsUpgradeReviewStore.getState().reviewRequestId).toBe(1); }); - it('opens the p2p stats section from its hash when browser pure p2p is enabled', () => { - render('/all/settings#p2p-stats-settings'); + it.each(['/all/settings?section=p2p-stats-settings', '/all/settings#p2p-stats-settings'])('opens the p2p stats section from route %s', (route) => { + render(route); expect(container.querySelector('[data-testid="p2p-stats-settings-panel"]')).not.toBeNull(); }); diff --git a/src/components/settings-modal/account-settings/__tests__/account-settings.test.tsx b/src/components/settings-modal/account-settings/__tests__/account-settings.test.tsx index 58885e14..c5736a8d 100644 --- a/src/components/settings-modal/account-settings/__tests__/account-settings.test.tsx +++ b/src/components/settings-modal/account-settings/__tests__/account-settings.test.tsx @@ -53,7 +53,7 @@ class MockFileReader { const LocationProbe = () => { const location = useLocation(); - return
{location.pathname + location.hash}
; + return
{location.pathname + location.search + location.hash}
; }; let root: Root; @@ -349,7 +349,7 @@ describe('AccountSettings', () => { expect(localStorage.getItem('importedAccountAddress')).toBe('0x999'); expect(hookMocks.setActiveAccount).toHaveBeenCalledWith('Imported'); expect(alertSpy).toHaveBeenCalledWith('Imported Imported'); - expect(getLocationText()).toBe('/subs/settings#account-settings'); + expect(getLocationText()).toBe('/subs/settings?section=account-settings'); }); it('preserves explicit HTTP routers when importing an account backup', async () => { diff --git a/src/components/settings-modal/account-settings/account-settings.tsx b/src/components/settings-modal/account-settings/account-settings.tsx index dce10155..d531bf99 100644 --- a/src/components/settings-modal/account-settings/account-settings.tsx +++ b/src/components/settings-modal/account-settings/account-settings.tsx @@ -2,6 +2,7 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { deleteAccount, exportAccount, importAccount, setActiveAccount, useAccount, useAccounts } from '@bitsocial/bitsocial-react-hooks'; import styles from './account-settings.module.css'; +import { getSettingsSectionPath } from '../../../lib/utils/route-utils'; import { Capacitor } from '@capacitor/core'; import { useLocation, useNavigate } from 'react-router-dom'; import { getLegacyDefaultBrowserHttpRoutersOptions } from '../../../lib/p2p-browser-config'; @@ -213,9 +214,8 @@ const AccountSettingsEditor = ({ if (result === undefined) return; alert(`Imported ${accountData.account?.name}`); - const currentPath = location.pathname; - if (!currentPath.includes('/settings#account-settings')) { - navigate(`${currentPath}#account-settings`, { replace: true }); + if (new URLSearchParams(location.search).get('section') !== 'account-settings') { + navigate(getSettingsSectionPath(location.pathname, 'account-settings', location.search), { replace: true }); } window.location.reload(); }; @@ -243,7 +243,7 @@ const AccountSettingsEditor = ({ {' '} - {' '}