mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user