fix(routing): avoid nested hashes in shared links (#1185)

This commit is contained in:
Tommaso Casaburi
2026-07-11 00:09:30 +07:00
committed by GitHub
parent 1e9cdab9a6
commit e5e46907d6
23 changed files with 184 additions and 96 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ import { copyPath, createTempWorkspace, findFirstMatchingPath, logStep, repoRoot
const OLD_VERSION = '0.7.1'; const OLD_VERSION = '0.7.1';
const NEW_VERSION = '0.7.3'; 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 MAC_ARCH = process.arch === 'x64' ? 'x64' : 'arm64';
const findPackagedMacApp = async () => { const findPackagedMacApp = async () => {
+11 -11
View File
@@ -447,18 +447,18 @@ describe('App', () => {
expect(container.querySelector('[data-testid="not-found-view"]')).toBeNull(); 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'); await renderApp('/mu/test');
expect(latestLocation).toBe('/mu/catalog'); expect(latestLocation).toBe('/mu/catalog?s=test');
expect(latestHash).toBe('#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'); await renderApp('/mu/test/settings');
expect(latestLocation).toBe('/mu/catalog/settings'); expect(latestLocation).toBe('/mu/catalog/settings?s=test');
expect(latestHash).toBe('#s=test'); expect(latestHash).toBe('');
expect(container.querySelector('[data-testid="settings-modal"]')).toBeTruthy(); expect(container.querySelector('[data-testid="settings-modal"]')).toBeTruthy();
}); });
@@ -505,11 +505,11 @@ describe('App', () => {
expect(container.querySelector('[data-testid="post-view"]')).toBeTruthy(); expect(container.querySelector('[data-testid="post-view"]')).toBeTruthy();
}); });
it('canonicalizes board address catalog routes while preserving search hashes', async () => { it('canonicalizes board address catalog routes while preserving search params', async () => {
await renderApp('/music-posting.eth/catalog#s=test'); await renderApp('/music-posting.eth/catalog?s=test');
expect(latestLocation).toBe('/mu/catalog'); expect(latestLocation).toBe('/mu/catalog?s=test');
expect(latestHash).toBe('#s=test'); expect(latestHash).toBe('');
}); });
it('canonicalizes a direct route for the current resolved directory board', async () => { 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('not_mod_of_any_board');
expect(container.textContent).toContain('go_to_settings_to_import_mod_account'); expect(container.textContent).toContain('go_to_settings_to_import_mod_account');
expect(container.querySelector('output')?.className).toContain('modEmptyState'); 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(); expect(container.querySelector('[data-testid="not-allowed-view"]')).toBeNull();
}); });
@@ -184,7 +184,7 @@ describe('BoardHeader', () => {
clickableSubtitle?.dispatchEvent(new MouseEvent('click', { bubbles: true })); 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 () => { it('renders the board title, address subtitle, and offline indicator for board routes', async () => {
+2 -2
View File
@@ -119,10 +119,10 @@ const BoardHeader = () => {
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); 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} {subtitle}
</button> </button>
@@ -82,7 +82,7 @@ const BoardsBarEditModalForm = ({
<span className={styles.editSubscriptionsWrapper}> <span className={styles.editSubscriptionsWrapper}>
( (
<Link <Link
to={location.pathname.replace(/\/$/, '') + '/settings#subscriptions-settings'} to={location.pathname.replace(/\/$/, '') + '/settings?section=subscriptions-settings'}
className={styles.editSubscriptionsLink} className={styles.editSubscriptionsLink}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -110,7 +110,7 @@ describe('CatalogSearch', () => {
container.remove(); 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 = { testState.location = {
hash: '#s=linux', hash: '#s=linux',
pathname: '/mu/catalog', pathname: '/mu/catalog',
@@ -120,11 +120,12 @@ describe('CatalogSearch', () => {
await renderSearch(); await renderSearch();
expect(testState.setSearchFilterMock).toHaveBeenCalledWith('linux'); expect(testState.setSearchFilterMock).toHaveBeenCalledWith('linux');
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog?s=linux', { replace: true });
expect(queryInput()).toBeTruthy(); expect(queryInput()).toBeTruthy();
expect(queryInput()?.getAttribute('value')).toBe('linux'); 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 = { testState.location = {
hash: '', hash: '',
pathname: '/mu/catalog', pathname: '/mu/catalog',
@@ -134,27 +135,27 @@ describe('CatalogSearch', () => {
await renderSearch(); await renderSearch();
expect(testState.setSearchFilterMock).toHaveBeenCalledWith('linux'); 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 = { testState.location = {
hash: '#s=hash-value', hash: '#s=hash-value',
pathname: '/mu/catalog', pathname: '/mu/catalog',
search: '?t=1w&q=query-value', search: '?t=1w&s=query-value&q=legacy-value',
}; };
await renderSearch(); await renderSearch();
expect(testState.setSearchFilterMock).toHaveBeenCalledWith('hash-value'); expect(testState.setSearchFilterMock).toHaveBeenCalledWith('query-value');
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog?t=1w#s=hash-value', { replace: true }); 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 = { testState.location = {
hash: '#s=linux', hash: '',
pathname: '/mu/catalog', pathname: '/mu/catalog',
search: '', search: '?s=linux',
}; };
await renderSearch(); await renderSearch();
@@ -185,7 +186,7 @@ describe('CatalogSearch', () => {
await dispatchInput(input, 'web3'); await dispatchInput(input, 'web3');
expect(testState.setSearchFilterMock).toHaveBeenCalledWith('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 () => { await act(async () => {
input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' })); input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }));
@@ -5,7 +5,7 @@ import styles from './catalog-search.module.css';
import useIsMobile from '../../hooks/use-is-mobile'; import useIsMobile from '../../hooks/use-is-mobile';
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store'; import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { getCatalogSearchHash } from '../../lib/utils/route-utils'; import { getCatalogSearchPath } from '../../lib/utils/route-utils';
const CatalogSearch = () => { const CatalogSearch = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -13,18 +13,17 @@ const CatalogSearch = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchState, setSearchState] = useState({ open: false, value: '' }); const [searchState, setSearchState] = useState({ open: false, value: '' });
const { setSearchFilter, clearSearchFilter } = useCatalogFiltersStore(); 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 hashSearchParam = new URLSearchParams(hash.replace(/^#/, '')).get('s') ?? '';
const catalogSearchParam = hashSearchParam || legacyQueryParam; const catalogSearchParam = querySearchParam || hashSearchParam || legacyQueryParam;
const openSearch = !!catalogSearchParam || searchState.open; const openSearch = !!catalogSearchParam || searchState.open;
const inputValue = searchState.open || searchState.value ? searchState.value : catalogSearchParam; const inputValue = searchState.open || searchState.value ? searchState.value : catalogSearchParam;
useEffect(() => { useEffect(() => {
if (legacyQueryParam) { if (hashSearchParam || legacyQueryParam) {
const urlParams = new URLSearchParams(search); navigate(getCatalogSearchPath(pathname, catalogSearchParam, search), { replace: true });
urlParams.delete('q');
const newSearch = urlParams.toString();
navigate(`${pathname}${newSearch ? `?${newSearch}` : ''}${getCatalogSearchHash(catalogSearchParam)}`, { replace: true });
} }
if (catalogSearchParam) { if (catalogSearchParam) {
@@ -33,20 +32,11 @@ const CatalogSearch = () => {
} }
clearSearchFilter(); clearSearchFilter();
}, [catalogSearchParam, clearSearchFilter, legacyQueryParam, navigate, pathname, search, setSearchFilter]); }, [catalogSearchParam, clearSearchFilter, hashSearchParam, legacyQueryParam, navigate, pathname, search, setSearchFilter]);
const updateURL = useCallback( const updateURL = useCallback(
(searchText: string) => { (searchText: string) => {
const urlParams = new URLSearchParams(search); navigate(getCatalogSearchPath(pathname, searchText, search), { replace: true });
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 });
}, },
[pathname, search, navigate], [pathname, search, navigate],
); );
@@ -365,10 +365,10 @@ describe('Markdown', () => {
}); });
it.each([ it.each([
['>>>/biz/test', '/biz/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'],
['>>>/biz/test_term', '/biz/catalog#s=test_term'], ['>>>/biz/test_term', '/biz/catalog?s=test_term'],
['>>>/board.eth/test', '/board.eth/catalog#s=test'], ['>>>/board.eth/test', '/board.eth/catalog?s=test'],
])('renders board search path %s as a catalog search hash link', async (quoteLink, href) => { ])('renders board search path %s as a catalog search hash link', async (quoteLink, href) => {
await renderMarkdown({ await renderMarkdown({
content: `see ${quoteLink}.`, content: `see ${quoteLink}.`,
@@ -3,7 +3,7 @@ import { Trans, useTranslation } from 'react-i18next';
import styles from './mod-empty-state.module.css'; import styles from './mod-empty-state.module.css';
const MOD_ACCOUNT_SETTINGS_LINK_COMPONENTS = { const MOD_ACCOUNT_SETTINGS_LINK_COMPONENTS = {
1: <Link to='/mod/settings#account-settings' />, 1: <Link to='/mod/settings?section=account-settings' />,
}; };
const ModEmptyState = () => { const ModEmptyState = () => {
@@ -70,7 +70,7 @@ vi.mock('../p2p-stats-settings/p2p-stats-settings', () => ({
const LocationProbe = () => { const LocationProbe = () => {
const location = useLocation(); const location = useLocation();
return <div data-testid='location'>{location.pathname + location.hash}</div>; return <div data-testid='location'>{location.pathname + location.search + location.hash}</div>;
}; };
let root: Root; let root: Root;
@@ -131,16 +131,16 @@ describe('SettingsModal', () => {
expect(container.querySelector('[data-testid="crypto-wallets-setting"]')).not.toBeNull(); expect(container.querySelector('[data-testid="crypto-wallets-setting"]')).not.toBeNull();
}); });
it('updates the hash when sections open and close', async () => { it('updates the section query when sections open and close', async () => {
render('/all/settings#account-settings'); render('/all/settings?section=account-settings');
expect(getLocationText()).toBe('/all/settings#account-settings'); expect(getLocationText()).toBe('/all/settings?section=account-settings');
await act(async () => { await act(async () => {
getSectionToggleByText('interface').click(); 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="interface-settings-panel"]')).not.toBeNull();
expect(container.querySelector('[data-testid="account-settings"]')).not.toBeNull(); expect(container.querySelector('[data-testid="account-settings"]')).not.toBeNull();
@@ -148,7 +148,7 @@ describe('SettingsModal', () => {
getSectionToggleByText('interface').click(); 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(); expect(container.querySelector('[data-testid="interface-settings-panel"]')).toBeNull();
await act(async () => { await act(async () => {
@@ -233,8 +233,8 @@ describe('SettingsModal', () => {
expect(useSettingsUpgradeReviewStore.getState().reviewRequestId).toBe(1); expect(useSettingsUpgradeReviewStore.getState().reviewRequestId).toBe(1);
}); });
it('opens the p2p stats section from its hash when browser pure p2p is enabled', () => { it.each(['/all/settings?section=p2p-stats-settings', '/all/settings#p2p-stats-settings'])('opens the p2p stats section from route %s', (route) => {
render('/all/settings#p2p-stats-settings'); render(route);
expect(container.querySelector('[data-testid="p2p-stats-settings-panel"]')).not.toBeNull(); expect(container.querySelector('[data-testid="p2p-stats-settings-panel"]')).not.toBeNull();
}); });
@@ -53,7 +53,7 @@ class MockFileReader {
const LocationProbe = () => { const LocationProbe = () => {
const location = useLocation(); const location = useLocation();
return <div data-testid='location'>{location.pathname + location.hash}</div>; return <div data-testid='location'>{location.pathname + location.search + location.hash}</div>;
}; };
let root: Root; let root: Root;
@@ -349,7 +349,7 @@ describe('AccountSettings', () => {
expect(localStorage.getItem('importedAccountAddress')).toBe('0x999'); expect(localStorage.getItem('importedAccountAddress')).toBe('0x999');
expect(hookMocks.setActiveAccount).toHaveBeenCalledWith('Imported'); expect(hookMocks.setActiveAccount).toHaveBeenCalledWith('Imported');
expect(alertSpy).toHaveBeenCalledWith('Imported 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 () => { it('preserves explicit HTTP routers when importing an account backup', async () => {
@@ -2,6 +2,7 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { deleteAccount, exportAccount, importAccount, setActiveAccount, useAccount, useAccounts } from '@bitsocial/bitsocial-react-hooks'; import { deleteAccount, exportAccount, importAccount, setActiveAccount, useAccount, useAccounts } from '@bitsocial/bitsocial-react-hooks';
import styles from './account-settings.module.css'; import styles from './account-settings.module.css';
import { getSettingsSectionPath } from '../../../lib/utils/route-utils';
import { Capacitor } from '@capacitor/core'; import { Capacitor } from '@capacitor/core';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { getLegacyDefaultBrowserHttpRoutersOptions } from '../../../lib/p2p-browser-config'; import { getLegacyDefaultBrowserHttpRoutersOptions } from '../../../lib/p2p-browser-config';
@@ -213,9 +214,8 @@ const AccountSettingsEditor = ({
if (result === undefined) return; if (result === undefined) return;
alert(`Imported ${accountData.account?.name}`); alert(`Imported ${accountData.account?.name}`);
const currentPath = location.pathname; if (new URLSearchParams(location.search).get('section') !== 'account-settings') {
if (!currentPath.includes('/settings#account-settings')) { navigate(getSettingsSectionPath(location.pathname, 'account-settings', location.search), { replace: true });
navigate(`${currentPath}#account-settings`, { replace: true });
} }
window.location.reload(); window.location.reload();
}; };
@@ -243,7 +243,7 @@ const AccountSettingsEditor = ({
<select value={account?.name} onChange={(e) => setActiveAccount(e.target.value)}> <select value={account?.name} onChange={(e) => setActiveAccount(e.target.value)}>
{accountsOptions} {accountsOptions}
</select>{' '} </select>{' '}
<button type='button' onClick={() => navigate('/settings/account-data', { state: { returnTo: location.pathname + location.hash } })}> <button type='button' onClick={() => navigate('/settings/account-data', { state: { returnTo: location.pathname + location.search + location.hash } })}>
{t('edit')} {t('edit')}
</button>{' '} </button>{' '}
<button type='button' onClick={handleExportAccount}> <button type='button' onClick={handleExportAccount}>
@@ -15,6 +15,7 @@ import P2PStatsSettings from './p2p-stats-settings/p2p-stats-settings';
import { P2P_STATS_SECTION_ID, shouldShowP2PSettingsSection } from '../../lib/p2p-runtime'; import { P2P_STATS_SECTION_ID, shouldShowP2PSettingsSection } from '../../lib/p2p-runtime';
import { getReviewableSettingsUpgrades, getSettingsUpgradeKey, type SettingsUpgradeAccount } from '../../lib/settings-upgrades'; import { getReviewableSettingsUpgrades, getSettingsUpgradeKey, type SettingsUpgradeAccount } from '../../lib/settings-upgrades';
import useSettingsUpgradeReviewStore from '../../stores/use-settings-upgrade-review-store'; import useSettingsUpgradeReviewStore from '../../stores/use-settings-upgrade-review-store';
import { getSettingsSectionPath } from '../../lib/utils/route-utils';
const allSectionIds = [ const allSectionIds = [
'interface-settings', 'interface-settings',
@@ -35,13 +36,14 @@ const SettingsModal = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const account = useAccount(); const account = useAccount();
const pkcRpc = usePkcRpcSettings(); const pkcRpc = usePkcRpcSettings();
const { hash: locationHash, pathname } = useLocation(); const { hash: locationHash, pathname, search } = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const hiddenReviewUpgradeKeys = useSettingsUpgradeReviewStore((state) => state.hiddenReviewUpgradeKeys); const hiddenReviewUpgradeKeys = useSettingsUpgradeReviewStore((state) => state.hiddenReviewUpgradeKeys);
const reviewUpgradeKeys = useSettingsUpgradeReviewStore((state) => state.reviewUpgradeKeys); const reviewUpgradeKeys = useSettingsUpgradeReviewStore((state) => state.reviewUpgradeKeys);
const hash = locationHash.slice(1); const legacyHashSection = locationHash.slice(1);
const querySection = new URLSearchParams(search).get('section') ?? '';
const sectionIds = useMemo(() => (shouldShowP2PSettingsSection(account) ? [...allSectionIds, P2P_STATS_SECTION_ID] : allSectionIds), [account]); const sectionIds = useMemo(() => (shouldShowP2PSettingsSection(account) ? [...allSectionIds, P2P_STATS_SECTION_ID] : allSectionIds), [account]);
const hashSection = hashToSection(hash, sectionIds); const routeSection = hashToSection(querySection || legacyHashSection, sectionIds);
const settingsUpgradeKeys = useMemo(() => { const settingsUpgradeKeys = useMemo(() => {
if (!account || pkcRpc?.state === 'connected') return []; if (!account || pkcRpc?.state === 'connected') return [];
@@ -70,15 +72,15 @@ const SettingsModal = () => {
}, [closeModal]); }, [closeModal]);
const [expandedSections, setExpandedSections] = useState<Set<string>>(() => { const [expandedSections, setExpandedSections] = useState<Set<string>>(() => {
return hashSection ? new Set([hashSection]) : new Set(); return routeSection ? new Set([routeSection]) : new Set();
}); });
const visibleExpandedSections = useMemo(() => { const visibleExpandedSections = useMemo(() => {
if (!hashSection || expandedSections.has(hashSection)) return expandedSections; if (!routeSection || expandedSections.has(routeSection)) return expandedSections;
const nextSections = new Set(expandedSections); const nextSections = new Set(expandedSections);
nextSections.add(hashSection); nextSections.add(routeSection);
return nextSections; return nextSections;
}, [expandedSections, hashSection]); }, [expandedSections, routeSection]);
const showInterfaceSettings = visibleExpandedSections.has('interface-settings'); const showInterfaceSettings = visibleExpandedSections.has('interface-settings');
const showMediaHostingSettings = visibleExpandedSections.has('media-hosting-settings'); const showMediaHostingSettings = visibleExpandedSections.has('media-hosting-settings');
@@ -90,8 +92,6 @@ const SettingsModal = () => {
const allExpanded = useMemo(() => sectionIds.every((id) => visibleExpandedSections.has(id)), [sectionIds, visibleExpandedSections]); const allExpanded = useMemo(() => sectionIds.every((id) => visibleExpandedSections.has(id)), [sectionIds, visibleExpandedSections]);
const basePath = pathname;
const handleCategoryClick = (categoryId: string) => { const handleCategoryClick = (categoryId: string) => {
const isOpening = !visibleExpandedSections.has(categoryId); const isOpening = !visibleExpandedSections.has(categoryId);
const next = new Set(visibleExpandedSections); const next = new Set(visibleExpandedSections);
@@ -103,22 +103,22 @@ const SettingsModal = () => {
setExpandedSections(next); setExpandedSections(next);
if (isOpening) { if (isOpening) {
navigate(`${basePath}#${categoryId}`, { replace: true }); navigate(getSettingsSectionPath(pathname, categoryId, search), { replace: true });
} else if (next.size === 1) { } else if (next.size === 1) {
const remaining = next.values().next().value; const remaining = next.values().next().value;
navigate(`${basePath}#${remaining}`, { replace: true }); navigate(getSettingsSectionPath(pathname, remaining ?? null, search), { replace: true });
} else { } else {
navigate(basePath, { replace: true }); navigate(getSettingsSectionPath(pathname, null, search), { replace: true });
} }
}; };
const handleExpandAll = () => { const handleExpandAll = () => {
if (allExpanded) { if (allExpanded) {
setExpandedSections(new Set()); setExpandedSections(new Set());
navigate(basePath, { replace: true }); navigate(getSettingsSectionPath(pathname, null, search), { replace: true });
} else { } else {
setExpandedSections(new Set(sectionIds)); setExpandedSections(new Set(sectionIds));
navigate(basePath, { replace: true }); navigate(getSettingsSectionPath(pathname, null, search), { replace: true });
} }
}; };
+8
View File
@@ -9,6 +9,7 @@ import './themes.css';
import AppUpdateRegistration from './components/app-update-registration'; import AppUpdateRegistration from './components/app-update-registration';
import { App as CapacitorApp } from '@capacitor/app'; import { App as CapacitorApp } from '@capacitor/app';
import { configureP2PBrowserPkcOptions } from './lib/p2p-browser-config'; import { configureP2PBrowserPkcOptions } from './lib/p2p-browser-config';
import { canonicalizeNestedHashRoute } from './lib/utils/hash-route-utils';
// Only enable analytics on 5chan.app (Vercel deployment) // Only enable analytics on 5chan.app (Vercel deployment)
// Exclude Electron (file:// or localhost), Capacitor/APK (capacitor:// or localhost), and IPFS (ipfs:// or different domain) // Exclude Electron (file:// or localhost), Capacitor/APK (capacitor:// or localhost), and IPFS (ipfs:// or different domain)
@@ -18,6 +19,13 @@ const shouldLoadAnalytics = import.meta.env.VITE_APP_DISTRIBUTION !== 'fdroid' &
const e2eStartHash = import.meta.env.VITE_E2E_START_HASH?.trim(); const e2eStartHash = import.meta.env.VITE_E2E_START_HASH?.trim();
const requestedE2EHarness = import.meta.env.DEV && typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('e2e') : null; const requestedE2EHarness = import.meta.env.DEV && typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('e2e') : null;
if (typeof window !== 'undefined') {
const canonicalHash = canonicalizeNestedHashRoute(window.location.hash);
if (canonicalHash !== window.location.hash) {
window.history.replaceState(window.history.state, '', `${window.location.pathname}${window.location.search}${canonicalHash}`);
}
}
if (typeof window !== 'undefined' && e2eStartHash && window.location.hash.length === 0) { if (typeof window !== 'undefined' && e2eStartHash && window.location.hash.length === 0) {
window.location.hash = e2eStartHash.startsWith('#') ? e2eStartHash : `#${e2eStartHash}`; window.location.hash = e2eStartHash.startsWith('#') ? e2eStartHash : `#${e2eStartHash}`;
} }
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { canonicalizeNestedHashRoute } from '../hash-route-utils';
describe('canonicalizeNestedHashRoute', () => {
it.each([
['#/biz/settings#p2p-stats-settings', '#/biz/settings?section=p2p-stats-settings'],
['#/biz/settings%23p2p-stats-settings', '#/biz/settings?section=p2p-stats-settings'],
['#/biz/settings?focus=1#account-settings', '#/biz/settings?focus=1&section=account-settings'],
['#/biz/catalog#s=test', '#/biz/catalog?s=test'],
['#/biz/catalog%23s=settings%23p2p-stats-settings', '#/biz/catalog?s=settings%23p2p-stats-settings'],
['#/biz/catalog?t=1w#s=cats%20and%20dogs', '#/biz/catalog?t=1w&s=cats+and+dogs'],
])('converts legacy route %s to %s', (input, expected) => {
expect(canonicalizeNestedHashRoute(input)).toBe(expected);
});
it.each(['', '#/biz', '#/biz/settings?section=p2p-stats-settings', '#/biz/catalog?s=hash%23tag', '#/faq#sage'])('leaves unrelated route %s unchanged', (input) => {
expect(canonicalizeNestedHashRoute(input)).toBe(input);
});
});
+14 -7
View File
@@ -3,8 +3,9 @@ import {
areSameBoardAddress, areSameBoardAddress,
extractDirectoryFromTitle, extractDirectoryFromTitle,
getBoardPath, getBoardPath,
getCatalogSearchHash, getCatalogSearchPath,
getCatalogSearchRoute, getCatalogSearchRoute,
getSettingsSectionPath,
getFeedCacheKey, getFeedCacheKey,
getFeedType, getFeedType,
getPageFromFeedPath, getPageFromFeedPath,
@@ -223,14 +224,20 @@ describe('feed pagination helpers', () => {
}); });
describe('catalog search route helpers', () => { describe('catalog search route helpers', () => {
it('formats catalog search routes with 4chan-style hash params', () => { it('formats catalog search routes with portable query params', () => {
expect(getCatalogSearchHash('test')).toBe('#s=test'); expect(getCatalogSearchPath('/biz/catalog', 'test')).toBe('/biz/catalog?s=test');
expect(getCatalogSearchRoute('biz', 'test')).toBe('/biz/catalog#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', '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(getCatalogSearchRoute('biz', 'cats and dogs', '?t=1w')).toBe('/biz/catalog?t=1w&s=cats+and+dogs');
expect(getCatalogSearchHash(' ')).toBe(''); expect(getCatalogSearchPath('/biz/catalog', ' ', '?t=1w&s=old&q=legacy')).toBe('/biz/catalog?t=1w');
expect(getCatalogSearchRoute('biz', '')).toBe('/biz/catalog'); expect(getCatalogSearchRoute('biz', '')).toBe('/biz/catalog');
}); });
it('formats settings section routes without a nested fragment', () => {
expect(getSettingsSectionPath('/biz/settings', 'p2p-stats-settings')).toBe('/biz/settings?section=p2p-stats-settings');
expect(getSettingsSectionPath('/biz/settings', 'account-settings', '?focus=1')).toBe('/biz/settings?focus=1&section=account-settings');
expect(getSettingsSectionPath('/biz/settings', null, '?focus=1&section=account-settings')).toBe('/biz/settings?focus=1');
});
}); });
describe('feed cache helpers', () => { describe('feed cache helpers', () => {
+44
View File
@@ -0,0 +1,44 @@
const splitPathAndSearch = (route: string): [string, string] => {
const searchIndex = route.indexOf('?');
return searchIndex === -1 ? [route, ''] : [route.slice(0, searchIndex), route.slice(searchIndex + 1)];
};
const findNestedHashSeparator = (route: string): { index: number; length: number } | null => {
const literalIndex = route.indexOf('#');
if (literalIndex !== -1) return { index: literalIndex, length: 1 };
const encodedIndex = route.toLowerCase().indexOf('%23');
return encodedIndex === -1 ? null : { index: encodedIndex, length: 3 };
};
/**
* Converts legacy nested-fragment routes into portable one-fragment routes.
* Telegram percent-encodes the inner hash before opening links, so both raw
* `#` and encoded `%23` separators are accepted here.
*/
export const canonicalizeNestedHashRoute = (hash: string): string => {
if (!hash.startsWith('#/')) return hash;
const route = hash.slice(1);
const separator = findNestedHashSeparator(route);
if (!separator) return hash;
const routeBeforeNestedHash = route.slice(0, separator.index);
const nestedFragment = route.slice(separator.index + separator.length);
const [pathname, search] = splitPathAndSearch(routeBeforeNestedHash);
const searchParams = new URLSearchParams(search);
if (pathname.endsWith('/settings') && /^[a-z0-9-]+-settings$/i.test(nestedFragment)) {
searchParams.set('section', nestedFragment);
} else if (/\/catalog(?:\/settings)?$/.test(pathname)) {
const legacyCatalogParams = new URLSearchParams(nestedFragment);
const catalogSearch = legacyCatalogParams.get('s');
if (catalogSearch === null) return hash;
searchParams.set('s', catalogSearch);
} else {
return hash;
}
const nextSearch = searchParams.toString();
return `#${pathname}${nextSearch ? `?${nextSearch}` : ''}`;
};
+22 -3
View File
@@ -227,13 +227,32 @@ const BOARD_PAGE_REGEX = /^([1-9]|10)$/;
export 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 => { export const getCatalogSearchPath = (pathname: string, searchText: string, search = ''): string => {
const searchParams = new URLSearchParams(search);
const trimmedSearchText = searchText.trim(); const trimmedSearchText = searchText.trim();
return trimmedSearchText ? `#s=${encodeURIComponent(trimmedSearchText)}` : ''; if (trimmedSearchText) {
searchParams.set('s', trimmedSearchText);
} else {
searchParams.delete('s');
}
searchParams.delete('q');
const nextSearch = searchParams.toString();
return `${pathname}${nextSearch ? `?${nextSearch}` : ''}`;
}; };
export const getCatalogSearchRoute = (boardIdentifier: string, searchText: string, search = '', options?: { settings?: boolean }): string => export const getCatalogSearchRoute = (boardIdentifier: string, searchText: string, search = '', options?: { settings?: boolean }): string =>
`/${boardIdentifier}/catalog${options?.settings ? '/settings' : ''}${search}${getCatalogSearchHash(searchText)}`; getCatalogSearchPath(`/${boardIdentifier}/catalog${options?.settings ? '/settings' : ''}`, searchText, search);
export const getSettingsSectionPath = (pathname: string, sectionId: string | null, search = ''): string => {
const searchParams = new URLSearchParams(search);
if (sectionId) {
searchParams.set('section', sectionId);
} else {
searchParams.delete('section');
}
const nextSearch = searchParams.toString();
return `${pathname}${nextSearch ? `?${nextSearch}` : ''}`;
};
/** Internal: check if segment is a multiboard root (all, subs, mod) */ /** Internal: check if segment is a multiboard root (all, subs, mod) */
function isMultiboardRoot(segment: string): boolean { function isMultiboardRoot(segment: string): boolean {
@@ -199,7 +199,7 @@ describe('AccountDataEditor', () => {
await clickButton('go_back'); await clickButton('go_back');
expect(testState.navigateMock).toHaveBeenCalledWith('/subs/settings#account-settings'); expect(testState.navigateMock).toHaveBeenCalledWith('/subs/settings?section=account-settings');
}); });
it('loads the editor after continue and respects custom return routes', async () => { it('loads the editor after continue and respects custom return routes', async () => {
@@ -258,7 +258,7 @@ describe('AccountDataEditor', () => {
expect(testState.buildSavePayloadMock).toHaveBeenCalledWith({ account: { name: 'changed' } }, 'test-id'); expect(testState.buildSavePayloadMock).toHaveBeenCalledWith({ account: { name: 'changed' } }, 'test-id');
expect(testState.setAccountMock).toHaveBeenCalledWith({ id: 'test-id', name: 'changed' }); expect(testState.setAccountMock).toHaveBeenCalledWith({ id: 'test-id', name: 'changed' });
expect(testState.navigateMock).toHaveBeenCalledWith('/subs/settings#account-settings'); expect(testState.navigateMock).toHaveBeenCalledWith('/subs/settings?section=account-settings');
}); });
it('surfaces save errors from setAccount', async () => { it('surfaces save errors from setAccount', async () => {
@@ -5,7 +5,7 @@ import { setAccount, useAccount } from '@bitsocial/bitsocial-react-hooks';
import { buildEditableAccountJson, safeParseAccountJson, buildSavePayload } from '../../lib/utils/account-editor-utils'; import { buildEditableAccountJson, safeParseAccountJson, buildSavePayload } from '../../lib/utils/account-editor-utils';
import styles from './account-data-editor.module.css'; import styles from './account-data-editor.module.css';
const DEFAULT_RETURN_TO = '/subs/settings#account-settings'; const DEFAULT_RETURN_TO = '/subs/settings?section=account-settings';
type AceModuleLoadResult = { type AceModuleLoadResult = {
Editor: React.ComponentType<any>; Editor: React.ComponentType<any>;
+1 -1
View File
@@ -1212,7 +1212,7 @@ describe('Board', () => {
expect(container.textContent).toContain('not_mod_of_any_board'); expect(container.textContent).toContain('not_mod_of_any_board');
expect(container.textContent).toContain('go_to_settings_to_import_mod_account'); expect(container.textContent).toContain('go_to_settings_to_import_mod_account');
expect(container.querySelector('output')?.className).toContain('modEmptyState'); expect(container.querySelector('output')?.className).toContain('modEmptyState');
expect(container.querySelector('a')?.getAttribute('href')).toBe('/mod/settings#account-settings'); expect(container.querySelector('a')?.getAttribute('href')).toBe('/mod/settings?section=account-settings');
}); });
it('passes multiboard time filters to useFeed and honors cached overrides', async () => { it('passes multiboard time filters to useFeed and honors cached overrides', async () => {
+2 -2
View File
@@ -922,7 +922,7 @@ describe('Catalog', () => {
expect(container.textContent).toContain('not_mod_of_any_board'); expect(container.textContent).toContain('not_mod_of_any_board');
expect(container.textContent).toContain('go_to_settings_to_import_mod_account'); expect(container.textContent).toContain('go_to_settings_to_import_mod_account');
expect(container.querySelector('output')?.className).toContain('modEmptyState'); expect(container.querySelector('output')?.className).toContain('modEmptyState');
expect(container.querySelector('a')?.getAttribute('href')).toBe('/mod/settings#account-settings'); expect(container.querySelector('a')?.getAttribute('href')).toBe('/mod/settings?section=account-settings');
expect(container.querySelectorAll('[data-testid="catalog-row"]')).toHaveLength(0); expect(container.querySelectorAll('[data-testid="catalog-row"]')).toHaveLength(0);
}); });
@@ -931,7 +931,7 @@ describe('Catalog', () => {
testState.searchText = 'asddasd'; testState.searchText = 'asddasd';
testState.hasMore = false; testState.hasMore = false;
await renderCatalog({ initialEntry: '/mu/catalog#s=asddasd', routePath: '/:boardIdentifier/catalog' }); await renderCatalog({ initialEntry: '/mu/catalog?s=asddasd', routePath: '/:boardIdentifier/catalog' });
expect(container.textContent).toContain('nothing_found'); expect(container.textContent).toContain('nothing_found');
expect(container.textContent).not.toContain('no_threads'); expect(container.textContent).not.toContain('no_threads');
+1 -1
View File
@@ -625,7 +625,7 @@ const FAQ_SECTIONS: FAQSection[] = [
Helia Helia
</a>{' '} </a>{' '}
IPFS node in the browser, so it can load boards peer-to-peer without centralized IPFS RPC gateways. You can turn pure browser P2P on in advanced settings and IPFS node in the browser, so it can load boards peer-to-peer without centralized IPFS RPC gateways. You can turn pure browser P2P on in advanced settings and
inspect the connection in <Link to='/all/settings#p2p-stats-settings'>P2P stats</Link>. Browser nodes still have restrictions, such as limited inbound inspect the connection in <Link to='/all/settings?section=p2p-stats-settings'>P2P stats</Link>. Browser nodes still have restrictions, such as limited inbound
connectivity, so desktop apps remain the best way to host board data because they run a full Bitsocial node with IPFS Kubo. connectivity, so desktop apps remain the best way to host board data because they run a full Bitsocial node with IPFS Kubo.
</> </>
), ),