fix(rules): use hash links for directory deep links

Switch rules navigation from /rules/:code paths to /rules#code hashes,
reject legacy subpaths, and ignore address hashes in markdown links.
This commit is contained in:
Tommaso Casaburi
2026-06-10 16:38:31 +07:00
parent f3d59c3345
commit e9c729a9e3
8 changed files with 121 additions and 70 deletions
+16
View File
@@ -444,6 +444,22 @@ describe('App', () => {
expect(container.querySelector('[data-testid="boards-bar"]')).toBeNull(); expect(container.querySelector('[data-testid="boards-bar"]')).toBeNull();
}); });
it('renders the rules route as a global static page', async () => {
await renderApp('/rules#mu');
expect(latestLocation).toBe('/rules');
expect(container.querySelector('[data-testid="rules-view"]')).toBeTruthy();
expect(container.querySelector('[data-testid="boards-bar"]')).toBeNull();
});
it('rejects legacy rules subpaths instead of treating them as board routes', async () => {
await renderApp('/rules/music-posting.eth');
expect(latestLocation).toBe('/not-found');
expect(container.querySelector('[data-testid="not-found-view"]')).toBeTruthy();
expect(container.querySelector('[data-testid="rules-view"]')).toBeNull();
});
it('canonicalizes board address routes to directory codes while preserving query strings', async () => { it('canonicalizes board address routes to directory codes while preserving query strings', async () => {
await renderApp('/music-posting.eth/thread/comment-1?focus=1'); await renderApp('/music-posting.eth/thread/comment-1?focus=1');
+2 -1
View File
@@ -292,7 +292,8 @@ const App = () => {
<Route path='/' element={<Home />} /> <Route path='/' element={<Home />} />
<Route path='/faq' element={<Faq />} /> <Route path='/faq' element={<Faq />} />
<Route path='/pass' element={<Pass />} /> <Route path='/pass' element={<Pass />} />
<Route path='/rules/:boardIdentifier?' element={<Rules />} /> <Route path='/rules' element={<Rules />} />
<Route path='/rules/*' element={<Navigate to='/not-found' replace />} />
<Route path='/blotter' element={<Blotter />} /> <Route path='/blotter' element={<Blotter />} />
<Route <Route
path='/settings/account-data' path='/settings/account-data'
@@ -158,7 +158,7 @@ vi.mock('../../../stores/use-post-number-store', () => ({
}), }),
})); }));
vi.mock('../../comment-media', () => ({ vi.mock('../../comment-media/comment-media', () => ({
default: ({ default: ({
commentMediaInfo, commentMediaInfo,
isFloatingEmbed, isFloatingEmbed,
@@ -183,11 +183,11 @@ vi.mock('../../comment-media', () => ({
}, },
})); }));
vi.mock('../../embed', () => ({ vi.mock('../../embed/embed-utils', () => ({
canEmbed: (parsedUrl: URL) => testState.embeddableHosts.has(parsedUrl.host), canEmbed: (parsedUrl: URL) => testState.embeddableHosts.has(parsedUrl.host),
})); }));
vi.mock('../../reply-quote-preview', () => ({ vi.mock('../../reply-quote-preview/reply-quote-preview', () => ({
default: ({ default: ({
isOP, isOP,
isQuotelinkUnavailable, isQuotelinkUnavailable,
@@ -365,14 +365,14 @@ describe('Markdown', () => {
it('renders inline Markdown links for app routes and inert empty hrefs', async () => { it('renders inline Markdown links for app routes and inert empty hrefs', async () => {
await renderMarkdown({ await renderMarkdown({
content: '[Rules](/rules/biz) [AI moderation]( )', content: '[Rules](/rules#biz) [Old rules](/rules/biz) [Address rules](/rules#custom-board.bso) [AI moderation]( )',
}); });
const links = Array.from(container.querySelectorAll('a')); const links = Array.from(container.querySelectorAll('a'));
expect(links).toHaveLength(1); expect(links).toHaveLength(1);
expect(links[0]?.getAttribute('href')).toBe('/rules/biz'); expect(links[0]?.getAttribute('href')).toBe('/rules#biz');
expect(links[0]?.textContent).toBe('Rules'); expect(links[0]?.textContent).toBe('Rules');
expect(container.textContent).toBe('Rules AI moderation'); expect(container.textContent).toBe('Rules Old rules Address rules AI moderation');
}); });
it('does not render unsafe inline Markdown hrefs', async () => { it('does not render unsafe inline Markdown hrefs', async () => {
+23 -10
View File
@@ -5,18 +5,18 @@ import { useDismiss, useFloating, useFocus, useHover, useInteractions, offset, s
import { getLinkMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils'; import { getLinkMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
import { isCatalogView } from '../../lib/utils/view-utils'; import { isCatalogView } from '../../lib/utils/view-utils';
import useIsMobile from '../../hooks/use-is-mobile'; import useIsMobile from '../../hooks/use-is-mobile';
import CommentMedia from '../comment-media'; import CommentMedia from '../comment-media/comment-media';
import CodeBlock from '../code-block'; import CodeBlock from '../code-block/code-block';
import styles from './markdown.module.css'; import styles from './markdown.module.css';
import { Link, useLocation, useParams } from 'react-router-dom'; import { Link, useLocation, useParams } from 'react-router-dom';
import { canEmbed } from '../embed'; import { canEmbed } from '../embed/embed-utils';
import { is5chanLink, transform5chanLinkToInternal, isValidCrossboardPattern } from '../../lib/utils/url-utils'; import { is5chanLink, transform5chanLinkToInternal, isValidCrossboardPattern } from '../../lib/utils/url-utils';
import { CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX, type ExternalQuoteReference } from '../../lib/utils/external-quote-utils'; import { CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX, type ExternalQuoteReference } from '../../lib/utils/external-quote-utils';
import { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils'; import { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils';
import usePostNumberStore, { getCidForPostNumber } from '../../stores/use-post-number-store'; import usePostNumberStore, { getCidForPostNumber } from '../../stores/use-post-number-store';
import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages'; import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
import { useComment } from '@bitsocial/bitsocial-react-hooks'; import { useComment } from '@bitsocial/bitsocial-react-hooks';
import ReplyQuotePreview from '../reply-quote-preview'; import ReplyQuotePreview from '../reply-quote-preview/reply-quote-preview';
import ExternalNumberQuoteLink from './external-number-quote-link'; import ExternalNumberQuoteLink from './external-number-quote-link';
import { findDirectoryByAddress, useDirectories, type DirectoryCommunity } from '../../hooks/use-directories'; import { findDirectoryByAddress, useDirectories, type DirectoryCommunity } from '../../hooks/use-directories';
import { getDirectoryCodeForBoardAddress } from '../../lib/utils/directory-list-lookup-utils'; import { getDirectoryCodeForBoardAddress } from '../../lib/utils/directory-list-lookup-utils';
@@ -216,6 +216,7 @@ const COMBINED_REGEX_WITHOUT_SPOILER = new RegExp(
); );
const makeTokenKey = (prefix: string, type: Token['type'], start: number, end: number): string => `${prefix}${type}:${start}:${end}`; const makeTokenKey = (prefix: string, type: Token['type'], start: number, end: number): string => `${prefix}${type}:${start}:${end}`;
const RULES_HASH_ROUTE_REGEX = /^\/rules#[A-Za-z0-9_-]+$/;
const isGreentextLine = (line: string): boolean => { const isGreentextLine = (line: string): boolean => {
if (line === '>') return true; if (line === '>') return true;
@@ -233,6 +234,10 @@ function normalizeInternalRouteHref(href: string): string {
return href; return href;
} }
function isUnsupportedRulesRouteHref(href: string): boolean {
return href.startsWith('/rules/') || (href.startsWith('/rules#') && !RULES_HASH_ROUTE_REGEX.test(href));
}
function splitUrlTrailingText(rawHref: string): { href: string; trailingText: string } { function splitUrlTrailingText(rawHref: string): { href: string; trailingText: string } {
let href = rawHref; let href = rawHref;
let trailingText = ''; let trailingText = '';
@@ -475,6 +480,9 @@ const AnchorLink = ({ href, text }: { href: string; text: string }) => {
const internalPath = transform5chanLinkToInternal(href); const internalPath = transform5chanLinkToInternal(href);
if (internalPath) { if (internalPath) {
const internalRoute = normalizeInternalRouteHref(internalPath); const internalRoute = normalizeInternalRouteHref(internalPath);
if (isUnsupportedRulesRouteHref(internalRoute)) {
return <span>{text}</span>;
}
let displayText: React.ReactNode = text; let displayText: React.ReactNode = text;
const isAutolinkedUrl = text.startsWith('http'); const isAutolinkedUrl = text.startsWith('http');
@@ -493,16 +501,21 @@ const AnchorLink = ({ href, text }: { href: string; text: string }) => {
} }
} }
const normalizedHref = normalizeInternalRouteHref(href);
if (isUnsupportedRulesRouteHref(normalizedHref)) {
return <span>{text}</span>;
}
if ( if (
href.startsWith('#/') || href.startsWith('#/') ||
href.startsWith('/#/') || href.startsWith('/#/') ||
href.startsWith('/p/') || normalizedHref.startsWith('/p/') ||
href.match(/^\/p\/[^/]+(\/c\/[^/]+)?$/) || normalizedHref.match(/^\/p\/[^/]+(\/c\/[^/]+)?$/) ||
href.match(/^\/rules\/[^/]+$/) || normalizedHref.match(RULES_HASH_ROUTE_REGEX) ||
href.match(/^\/[^/]+(\/thread\/[^/]+)?$/) || normalizedHref.match(/^\/[^/#]+(\/thread\/[^/]+)?$/) ||
href.match(/^\/[^/]+\/(catalog|description|rules)(\/settings)?$/) normalizedHref.match(/^\/[^/]+\/(catalog|description|rules)(\/settings)?$/)
) { ) {
return <Link to={normalizeInternalRouteHref(href)}>{text}</Link>; return <Link to={normalizedHref}>{text}</Link>;
} }
return ( return (
@@ -1389,7 +1389,20 @@ describe('PostForm', () => {
expect(promptRow?.className).toBe('rules'); expect(promptRow?.className).toBe('rules');
expect(promptRow?.querySelector('ul')?.className).toBe('rules'); expect(promptRow?.querySelector('ul')?.className).toBe('rules');
expect(links.map((link) => link.textContent)).toEqual(['Rules', 'FAQ']); expect(links.map((link) => link.textContent)).toEqual(['Rules', 'FAQ']);
expect(links.map((link) => link.getAttribute('href'))).toEqual(['/rules/mu', '/faq']); expect(links.map((link) => link.getAttribute('href'))).toEqual(['/rules#mu', '/faq']);
});
it('uses the plain rules page link for custom boards without a directory hash', async () => {
testState.resolvedCommunityAddress = 'custom-board.bso';
await renderPostForm('/custom-board.bso');
await clickByText(container, 'start_new_thread');
const promptRow = Array.from(container.querySelectorAll('tr')).find((row) => row.textContent === 'Please read the Rules and FAQ before posting.');
const links = Array.from(promptRow?.querySelectorAll('a') || []);
expect(links.map((link) => link.textContent)).toEqual(['Rules', 'FAQ']);
expect(links.map((link) => link.getAttribute('href'))).toEqual(['/rules', '/faq']);
}); });
it('shortens long pasted file-link filenames next to the upload button', async () => { it('shortens long pasted file-link filenames next to the upload button', async () => {
+4 -3
View File
@@ -30,7 +30,7 @@ import { truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils';
import { isValidPublishURL, isValidURL } from '../../lib/utils/url-utils'; import { isValidPublishURL, isValidURL } from '../../lib/utils/url-utils';
import { getModerationPostingRoleLabel } from '../../lib/utils/author-display-utils'; import { getModerationPostingRoleLabel } from '../../lib/utils/author-display-utils';
import { hasModQueueAccessRole } from '../../lib/utils/mod-access'; import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
import { getBoardPath } from '../../lib/utils/route-utils'; import { getBoardPath, isDirectoryRoute } from '../../lib/utils/route-utils';
import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory, type CommentFlagSelectOption } from '../../lib/comment-flag-selection'; import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory, type CommentFlagSelectOption } from '../../lib/comment-flag-selection';
import { FLASH_TAG_OPTIONS, getFlashTagPublishOptionsForDirectoryCode, isFlashDirectoryCode, type FlashTagOption } from '../../lib/flash-tags'; import { FLASH_TAG_OPTIONS, getFlashTagPublishOptionsForDirectoryCode, isFlashDirectoryCode, type FlashTagOption } from '../../lib/flash-tags';
@@ -545,8 +545,9 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const subscriptions = account?.subscriptions || []; const subscriptions = account?.subscriptions || [];
const directories = useDirectories(); const directories = useDirectories();
const directoryEntry = useDirectoryEntry(effectiveBoardAddress, params?.boardIdentifier); const directoryEntry = useDirectoryEntry(effectiveBoardAddress, params?.boardIdentifier);
const pendingPostBoardPath = effectiveBoardAddress ? getBoardPath(effectiveBoardAddress, directories) : undefined; const effectiveBoardPath = effectiveBoardAddress ? getBoardPath(effectiveBoardAddress, directories) : undefined;
const rulesPath = effectiveBoardAddress ? `/rules/${getBoardPath(effectiveBoardAddress, directories)}` : '/rules'; const pendingPostBoardPath = effectiveBoardPath;
const rulesPath = effectiveBoardPath && isDirectoryRoute(effectiveBoardPath, directories) ? `/rules#${effectiveBoardPath}` : '/rules';
const showSpoilerForPost = directoryEntry?.features?.noSpoilers !== true; const showSpoilerForPost = directoryEntry?.features?.noSpoilers !== true;
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true; const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname); const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
+30 -26
View File
@@ -9,7 +9,6 @@ import Rules from '../rules';
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>; const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({ const testState = vi.hoisted(() => ({
boardIdentifier: undefined as string | undefined,
communities: {} as Record<string, { rules?: string[]; shortAddress?: string; state?: string; title?: string }>, communities: {} as Record<string, { rules?: string[]; shortAddress?: string; state?: string; title?: string }>,
directories: [ directories: [
{ address: 'anime-posting.eth', title: '/a/ - Anime & Manga' }, { address: 'anime-posting.eth', title: '/a/ - Anime & Manga' },
@@ -31,16 +30,6 @@ vi.mock('react-i18next', () => ({
}), }),
})); }));
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useParams: () => ({
boardIdentifier: testState.boardIdentifier,
}),
};
});
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useClientsStates: () => ({ useClientsStates: () => ({
states: {}, states: {},
@@ -60,12 +49,12 @@ vi.mock('../../../hooks/use-directories', async () => {
}; };
}); });
vi.mock('../../home', () => ({ vi.mock('../../home/home', () => ({
Footer: () => createElement('div', { 'data-testid': 'footer' }, 'footer'), Footer: () => createElement('div', { 'data-testid': 'footer' }, 'footer'),
HomeLogo: () => createElement('div', { 'data-testid': 'home-logo' }, 'home-logo'), HomeLogo: () => createElement('div', { 'data-testid': 'home-logo' }, 'home-logo'),
})); }));
vi.mock('../../../components/markdown', () => ({ vi.mock('../../../components/markdown/markdown', () => ({
default: ({ content }: { content: string }) => createElement('div', { 'data-testid': 'markdown' }, content), default: ({ content }: { content: string }) => createElement('div', { 'data-testid': 'markdown' }, content),
})); }));
@@ -81,9 +70,9 @@ let container: HTMLDivElement;
let root: Root; let root: Root;
let scrollIntoViewMock: ReturnType<typeof vi.fn>; let scrollIntoViewMock: ReturnType<typeof vi.fn>;
const renderRules = async () => { const renderRules = async (initialEntry = '/rules') => {
await act(async () => { await act(async () => {
root.render(createElement(MemoryRouter, null, createElement(Rules))); root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, createElement(Rules)));
}); });
}; };
@@ -110,7 +99,6 @@ const submitBoardAddress = async (address: string) => {
describe('Rules', () => { describe('Rules', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
testState.boardIdentifier = undefined;
testState.communities = {}; testState.communities = {};
testState.directories = [ testState.directories = [
{ address: 'anime-posting.eth', title: '/a/ - Anime & Manga' }, { address: 'anime-posting.eth', title: '/a/ - Anime & Manga' },
@@ -141,9 +129,9 @@ describe('Rules', () => {
it('renders a quick-jump nav link and a rules section for every directory', async () => { it('renders a quick-jump nav link and a rules section for every directory', async () => {
await renderRules(); await renderRules();
// Quick-jump nav links use the directory name (like 4chan's board list) and point at the per-directory route. // Quick-jump nav links use the directory name (like 4chan's board list) and point at per-directory hash links.
expect(container.querySelector('a[href="/rules/a"]')?.textContent).toBe('Anime & Manga'); expect(container.querySelector('a[href="/rules#a"]')?.textContent).toBe('Anime & Manga');
expect(container.querySelector('a[href="/rules/b"]')?.textContent).toBe('Random'); expect(container.querySelector('a[href="/rules#b"]')?.textContent).toBe('Random');
// One anchored rules section per directory. // One anchored rules section per directory.
expect(container.querySelector('#a')).toBeTruthy(); expect(container.querySelector('#a')).toBeTruthy();
@@ -162,14 +150,28 @@ describe('Rules', () => {
expect(container.textContent).not.toContain('Rules for:'); expect(container.textContent).not.toContain('Rules for:');
}); });
it('insta-scrolls to a directory section when deep-linked via /rules/:code', async () => { it('insta-scrolls to a directory section when deep-linked via /rules#code', async () => {
testState.boardIdentifier = 'a'; await renderRules('/rules#a');
await renderRules();
expect(scrollIntoViewMock).toHaveBeenCalled(); expect(scrollIntoViewMock).toHaveBeenCalled();
}); });
it('ignores address hashes instead of resolving them to directory rules or P2P rules', async () => {
testState.communities = {
'anime-posting.eth': {
rules: ['P2P address rules should not render from a hash.'],
shortAddress: 'anime-posting.eth',
state: 'succeeded',
},
};
await renderRules('/rules#anime-posting.eth');
expect(scrollIntoViewMock).not.toHaveBeenCalled();
expect(container.textContent).not.toContain('Rules for: anime-posting.eth');
expect(container.textContent).not.toContain('P2P address rules should not render from a hash.');
});
it('loads a board over P2P when an address is submitted in the loader', async () => { it('loads a board over P2P when an address is submitted in the loader', async () => {
testState.communities = { testState.communities = {
'custom-board.eth': { 'custom-board.eth': {
@@ -186,7 +188,7 @@ describe('Rules', () => {
expect(container.textContent).toContain('No spamming.'); expect(container.textContent).toContain('No spamming.');
}); });
it('clears a loaded P2P rules box when navigating to a directory route', async () => { it('clears a loaded P2P rules box when navigating to a directory hash link', async () => {
testState.communities = { testState.communities = {
'custom-board.eth': { 'custom-board.eth': {
rules: ['No spamming.'], rules: ['No spamming.'],
@@ -199,8 +201,10 @@ describe('Rules', () => {
await submitBoardAddress('custom-board.eth'); await submitBoardAddress('custom-board.eth');
expect(container.textContent).toContain('Rules for: custom-board.eth'); expect(container.textContent).toContain('Rules for: custom-board.eth');
testState.boardIdentifier = 'a'; const link = container.querySelector('a[href="/rules#a"]') as HTMLAnchorElement;
await renderRules(); await act(async () => {
link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
});
expect(container.textContent).not.toContain('Rules for: custom-board.eth'); expect(container.textContent).not.toContain('Rules for: custom-board.eth');
expect(scrollIntoViewMock).toHaveBeenCalled(); expect(scrollIntoViewMock).toHaveBeenCalled();
+26 -23
View File
@@ -1,12 +1,12 @@
import { Fragment, useEffect, useRef, useState, FormEvent } from 'react'; import { Fragment, useEffect, useRef, useState, FormEvent } from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useCommunity } from '@bitsocial/bitsocial-react-hooks'; import { useCommunity } from '@bitsocial/bitsocial-react-hooks';
import { Footer, HomeLogo } from '../home'; import { Footer, HomeLogo } from '../home/home';
import { useDirectories, useDirectoryDefaults, DirectoryCommunity, DirectoryDefaultsData } from '../../hooks/use-directories'; import { useDirectories, useDirectoryDefaults, DirectoryCommunity, DirectoryDefaultsData } from '../../hooks/use-directories';
import { useCommunityIdentifier } from '../../hooks/use-community-identifiers'; import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
import { getCommunityAddress, getBoardPath, isDirectoryRoute } from '../../lib/utils/route-utils'; import { getCommunityAddress, isDirectoryRoute } from '../../lib/utils/route-utils';
import Markdown from '../../components/markdown'; import Markdown from '../../components/markdown/markdown';
import LoadingEllipsis from '../../components/loading-ellipsis'; import LoadingEllipsis from '../../components/loading-ellipsis/loading-ellipsis';
import useStateString from '../../hooks/use-state-string'; import useStateString from '../../hooks/use-state-string';
import styles from './rules.module.css'; import styles from './rules.module.css';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -59,13 +59,16 @@ const groupDirectoriesByCategory = (directories: DirectoryCommunity[], defaults:
})) }))
.filter((group) => group.communities.length > 0); .filter((group) => group.communities.length > 0);
// Resolve a /rules/:boardIdentifier segment (directory code or board address) to a directory code. const getRulesHashCode = (hash: string): string => {
const resolveDirectoryCode = (identifier: string, directories: DirectoryCommunity[]): string | null => { if (!hash) {
if (isDirectoryRoute(identifier, directories)) { return '';
return identifier; }
const rawHash = hash.startsWith('#') ? hash.slice(1) : hash;
try {
return decodeURIComponent(rawHash);
} catch {
return rawHash;
} }
const code = getBoardPath(getCommunityAddress(identifier, directories), directories);
return isDirectoryRoute(code, directories) ? code : null;
}; };
// A single directory's rules (h3 title + ordered rules), anchored by code for deep-link scrolling. // A single directory's rules (h3 title + ordered rules), anchored by code for deep-link scrolling.
@@ -109,7 +112,7 @@ const CategoryRulesBox = ({ group, defaults }: { group: CategoryGroup; defaults:
</div> </div>
); );
// Quick-jump nav (left column) grouped by category; clicking a directory insta-scrolls to its rules via /rules/:code. // Quick-jump nav (left column) grouped by category; clicking a directory insta-scrolls to its rules via /rules#code.
const DirectoryNav = ({ groups }: { groups: CategoryGroup[] }) => ( const DirectoryNav = ({ groups }: { groups: CategoryGroup[] }) => (
<div className={`${styles.box} ${styles.selectorBox}`}> <div className={`${styles.box} ${styles.selectorBox}`}>
<div className={styles.boxBar}> <div className={styles.boxBar}>
@@ -129,7 +132,7 @@ const DirectoryNav = ({ groups }: { groups: CategoryGroup[] }) => (
const code = getDirectoryCode(community); const code = getDirectoryCode(community);
return ( return (
<li key={community.address}> <li key={community.address}>
<Link to={`/rules/${code}`}>{getBoardName(community.title) || getDirectoryDisplayTitle(community)}</Link> <Link to={`/rules#${code}`}>{getBoardName(community.title) || getDirectoryDisplayTitle(community)}</Link>
</li> </li>
); );
})} })}
@@ -233,11 +236,12 @@ const LoadBoardRules = ({ onLoad, onClear, isLoaded }: { onLoad: (address: strin
}; };
const Rules = () => { const Rules = () => {
const { boardIdentifier } = useParams(); const { hash } = useLocation();
const directories = useDirectories(); const directories = useDirectories();
const directoryDefaults = useDirectoryDefaults(); const directoryDefaults = useDirectoryDefaults();
const [loadedAddress, setLoadedAddress] = useState(''); const [loadedAddress, setLoadedAddress] = useState('');
const scrolledForRef = useRef<string | null>(null); const scrolledForRef = useRef<string | null>(null);
const hashCode = getRulesHashCode(hash);
// Order directories alphabetically by directory code (e.g. /3/, /a/, /aco/...), like 4chan, not by title. // Order directories alphabetically by directory code (e.g. /3/, /a/, /aco/...), like 4chan, not by title.
const directoriesWithCode = directories.filter((community) => getDirectoryCode(community)).toSorted((a, b) => getDirectoryCode(a).localeCompare(getDirectoryCode(b))); const directoriesWithCode = directories.filter((community) => getDirectoryCode(community)).toSorted((a, b) => getDirectoryCode(a).localeCompare(getDirectoryCode(b)));
@@ -253,24 +257,23 @@ const Rules = () => {
useEffect(() => { useEffect(() => {
setLoadedAddress(''); setLoadedAddress('');
if (!boardIdentifier) { scrolledForRef.current = null;
scrolledForRef.current = null; if (!hashCode) {
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
}, [boardIdentifier]); }, [hashCode]);
// Deep-link: /rules/:code insta-scrolls to that directory's rules once the matching section is rendered. // Deep-link: /rules#code insta-scrolls to a known directory's rules once the matching section is rendered.
useEffect(() => { useEffect(() => {
if (!boardIdentifier || scrolledForRef.current === boardIdentifier) { if (!hashCode || scrolledForRef.current === hashCode || !isDirectoryRoute(hashCode, directories)) {
return; return;
} }
const code = resolveDirectoryCode(boardIdentifier, directories); const element = document.getElementById(hashCode);
const element = code ? document.getElementById(code) : null;
if (element) { if (element) {
element.scrollIntoView(); element.scrollIntoView();
scrolledForRef.current = boardIdentifier; scrolledForRef.current = hashCode;
} }
}, [boardIdentifier, directories]); }, [hashCode, directories]);
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>