diff --git a/src/__tests__/app.test.tsx b/src/__tests__/app.test.tsx index eecefbe1..add84446 100644 --- a/src/__tests__/app.test.tsx +++ b/src/__tests__/app.test.tsx @@ -444,6 +444,22 @@ describe('App', () => { 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 () => { await renderApp('/music-posting.eth/thread/comment-1?focus=1'); diff --git a/src/app.tsx b/src/app.tsx index 05c74f46..2774d70f 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -292,7 +292,8 @@ const App = () => { } /> } /> } /> - } /> + } /> + } /> } /> ({ }), })); -vi.mock('../../comment-media', () => ({ +vi.mock('../../comment-media/comment-media', () => ({ default: ({ commentMediaInfo, 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), })); -vi.mock('../../reply-quote-preview', () => ({ +vi.mock('../../reply-quote-preview/reply-quote-preview', () => ({ default: ({ isOP, isQuotelinkUnavailable, @@ -365,14 +365,14 @@ describe('Markdown', () => { it('renders inline Markdown links for app routes and inert empty hrefs', async () => { 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')); 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(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 () => { diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx index 7890cefc..2aea3a85 100644 --- a/src/components/markdown/markdown.tsx +++ b/src/components/markdown/markdown.tsx @@ -5,18 +5,18 @@ import { useDismiss, useFloating, useFocus, useHover, useInteractions, offset, s import { getLinkMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils'; import { isCatalogView } from '../../lib/utils/view-utils'; import useIsMobile from '../../hooks/use-is-mobile'; -import CommentMedia from '../comment-media'; -import CodeBlock from '../code-block'; +import CommentMedia from '../comment-media/comment-media'; +import CodeBlock from '../code-block/code-block'; import styles from './markdown.module.css'; 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 { CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX, type ExternalQuoteReference } from '../../lib/utils/external-quote-utils'; import { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils'; import usePostNumberStore, { getCidForPostNumber } from '../../stores/use-post-number-store'; import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages'; 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 { findDirectoryByAddress, useDirectories, type DirectoryCommunity } from '../../hooks/use-directories'; 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 RULES_HASH_ROUTE_REGEX = /^\/rules#[A-Za-z0-9_-]+$/; const isGreentextLine = (line: string): boolean => { if (line === '>') return true; @@ -233,6 +234,10 @@ function normalizeInternalRouteHref(href: string): string { 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 } { let href = rawHref; let trailingText = ''; @@ -475,6 +480,9 @@ const AnchorLink = ({ href, text }: { href: string; text: string }) => { const internalPath = transform5chanLinkToInternal(href); if (internalPath) { const internalRoute = normalizeInternalRouteHref(internalPath); + if (isUnsupportedRulesRouteHref(internalRoute)) { + return {text}; + } let displayText: React.ReactNode = text; 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 {text}; + } + if ( href.startsWith('#/') || href.startsWith('/#/') || - href.startsWith('/p/') || - href.match(/^\/p\/[^/]+(\/c\/[^/]+)?$/) || - href.match(/^\/rules\/[^/]+$/) || - href.match(/^\/[^/]+(\/thread\/[^/]+)?$/) || - href.match(/^\/[^/]+\/(catalog|description|rules)(\/settings)?$/) + normalizedHref.startsWith('/p/') || + normalizedHref.match(/^\/p\/[^/]+(\/c\/[^/]+)?$/) || + normalizedHref.match(RULES_HASH_ROUTE_REGEX) || + normalizedHref.match(/^\/[^/#]+(\/thread\/[^/]+)?$/) || + normalizedHref.match(/^\/[^/]+\/(catalog|description|rules)(\/settings)?$/) ) { - return {text}; + return {text}; } return ( diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx index a00529f1..38e46a89 100644 --- a/src/components/post-form/__tests__/post-form.test.tsx +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -1389,7 +1389,20 @@ describe('PostForm', () => { expect(promptRow?.className).toBe('rules'); expect(promptRow?.querySelector('ul')?.className).toBe('rules'); 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 () => { diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index 2db00c36..b35b444b 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -30,7 +30,7 @@ import { truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils'; import { isValidPublishURL, isValidURL } from '../../lib/utils/url-utils'; import { getModerationPostingRoleLabel } from '../../lib/utils/author-display-utils'; 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 { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory, type CommentFlagSelectOption } from '../../lib/comment-flag-selection'; 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 directories = useDirectories(); const directoryEntry = useDirectoryEntry(effectiveBoardAddress, params?.boardIdentifier); - const pendingPostBoardPath = effectiveBoardAddress ? getBoardPath(effectiveBoardAddress, directories) : undefined; - const rulesPath = effectiveBoardAddress ? `/rules/${getBoardPath(effectiveBoardAddress, directories)}` : '/rules'; + const effectiveBoardPath = effectiveBoardAddress ? getBoardPath(effectiveBoardAddress, directories) : undefined; + const pendingPostBoardPath = effectiveBoardPath; + const rulesPath = effectiveBoardPath && isDirectoryRoute(effectiveBoardPath, directories) ? `/rules#${effectiveBoardPath}` : '/rules'; const showSpoilerForPost = directoryEntry?.features?.noSpoilers !== true; const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true; const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname); diff --git a/src/views/rules/__tests__/rules.test.tsx b/src/views/rules/__tests__/rules.test.tsx index 70eb84f3..9d513b32 100644 --- a/src/views/rules/__tests__/rules.test.tsx +++ b/src/views/rules/__tests__/rules.test.tsx @@ -9,7 +9,6 @@ import Rules from '../rules'; const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; const testState = vi.hoisted(() => ({ - boardIdentifier: undefined as string | undefined, communities: {} as Record, directories: [ { 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('react-router-dom'); - return { - ...actual, - useParams: () => ({ - boardIdentifier: testState.boardIdentifier, - }), - }; -}); - vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ useClientsStates: () => ({ 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'), 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), })); @@ -81,9 +70,9 @@ let container: HTMLDivElement; let root: Root; let scrollIntoViewMock: ReturnType; -const renderRules = async () => { +const renderRules = async (initialEntry = '/rules') => { 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', () => { beforeEach(() => { vi.clearAllMocks(); - testState.boardIdentifier = undefined; testState.communities = {}; testState.directories = [ { 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 () => { await renderRules(); - // Quick-jump nav links use the directory name (like 4chan's board list) and point at the per-directory route. - expect(container.querySelector('a[href="/rules/a"]')?.textContent).toBe('Anime & Manga'); - expect(container.querySelector('a[href="/rules/b"]')?.textContent).toBe('Random'); + // 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#b"]')?.textContent).toBe('Random'); // One anchored rules section per directory. expect(container.querySelector('#a')).toBeTruthy(); @@ -162,14 +150,28 @@ describe('Rules', () => { expect(container.textContent).not.toContain('Rules for:'); }); - it('insta-scrolls to a directory section when deep-linked via /rules/:code', async () => { - testState.boardIdentifier = 'a'; - - await renderRules(); + it('insta-scrolls to a directory section when deep-linked via /rules#code', async () => { + await renderRules('/rules#a'); 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 () => { testState.communities = { 'custom-board.eth': { @@ -186,7 +188,7 @@ describe('Rules', () => { 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 = { 'custom-board.eth': { rules: ['No spamming.'], @@ -199,8 +201,10 @@ describe('Rules', () => { await submitBoardAddress('custom-board.eth'); expect(container.textContent).toContain('Rules for: custom-board.eth'); - testState.boardIdentifier = 'a'; - await renderRules(); + const link = container.querySelector('a[href="/rules#a"]') as HTMLAnchorElement; + await act(async () => { + link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + }); expect(container.textContent).not.toContain('Rules for: custom-board.eth'); expect(scrollIntoViewMock).toHaveBeenCalled(); diff --git a/src/views/rules/rules.tsx b/src/views/rules/rules.tsx index 72d87a45..ccecc0de 100644 --- a/src/views/rules/rules.tsx +++ b/src/views/rules/rules.tsx @@ -1,12 +1,12 @@ 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 { Footer, HomeLogo } from '../home'; +import { Footer, HomeLogo } from '../home/home'; import { useDirectories, useDirectoryDefaults, DirectoryCommunity, DirectoryDefaultsData } from '../../hooks/use-directories'; import { useCommunityIdentifier } from '../../hooks/use-community-identifiers'; -import { getCommunityAddress, getBoardPath, isDirectoryRoute } from '../../lib/utils/route-utils'; -import Markdown from '../../components/markdown'; -import LoadingEllipsis from '../../components/loading-ellipsis'; +import { getCommunityAddress, isDirectoryRoute } from '../../lib/utils/route-utils'; +import Markdown from '../../components/markdown/markdown'; +import LoadingEllipsis from '../../components/loading-ellipsis/loading-ellipsis'; import useStateString from '../../hooks/use-state-string'; import styles from './rules.module.css'; import { useTranslation } from 'react-i18next'; @@ -59,13 +59,16 @@ const groupDirectoriesByCategory = (directories: DirectoryCommunity[], defaults: })) .filter((group) => group.communities.length > 0); -// Resolve a /rules/:boardIdentifier segment (directory code or board address) to a directory code. -const resolveDirectoryCode = (identifier: string, directories: DirectoryCommunity[]): string | null => { - if (isDirectoryRoute(identifier, directories)) { - return identifier; +const getRulesHashCode = (hash: string): string => { + if (!hash) { + return ''; + } + 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. @@ -109,7 +112,7 @@ const CategoryRulesBox = ({ group, defaults }: { group: CategoryGroup; defaults: ); -// 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[] }) => (
@@ -129,7 +132,7 @@ const DirectoryNav = ({ groups }: { groups: CategoryGroup[] }) => ( const code = getDirectoryCode(community); return (
  • - {getBoardName(community.title) || getDirectoryDisplayTitle(community)} + {getBoardName(community.title) || getDirectoryDisplayTitle(community)}
  • ); })} @@ -233,11 +236,12 @@ const LoadBoardRules = ({ onLoad, onClear, isLoaded }: { onLoad: (address: strin }; const Rules = () => { - const { boardIdentifier } = useParams(); + const { hash } = useLocation(); const directories = useDirectories(); const directoryDefaults = useDirectoryDefaults(); const [loadedAddress, setLoadedAddress] = useState(''); const scrolledForRef = useRef(null); + const hashCode = getRulesHashCode(hash); // 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))); @@ -253,24 +257,23 @@ const Rules = () => { useEffect(() => { setLoadedAddress(''); - if (!boardIdentifier) { - scrolledForRef.current = null; + scrolledForRef.current = null; + if (!hashCode) { 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(() => { - if (!boardIdentifier || scrolledForRef.current === boardIdentifier) { + if (!hashCode || scrolledForRef.current === hashCode || !isDirectoryRoute(hashCode, directories)) { return; } - const code = resolveDirectoryCode(boardIdentifier, directories); - const element = code ? document.getElementById(code) : null; + const element = document.getElementById(hashCode); if (element) { element.scrollIntoView(); - scrolledForRef.current = boardIdentifier; + scrolledForRef.current = hashCode; } - }, [boardIdentifier, directories]); + }, [hashCode, directories]); return (