mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(routing): avoid nested hashes in shared links (#1185)
This commit is contained in:
@@ -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
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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§ion=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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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§ion=account-settings');
|
||||||
|
expect(getSettingsSectionPath('/biz/settings', null, '?focus=1§ion=account-settings')).toBe('/biz/settings?focus=1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('feed cache helpers', () => {
|
describe('feed cache helpers', () => {
|
||||||
|
|||||||
@@ -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}` : ''}`;
|
||||||
|
};
|
||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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.
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user