Add hidden trash board (#1183)

* feat(trash-board): add hidden trash board

* fix(trash-board): resolve special board transfer lookups

* fix(trash-board): exclude source aliases from transfers

* fix(trash-board): avoid hidden rules hash
This commit is contained in:
Tommaso Casaburi
2026-07-02 21:22:19 +07:00
committed by GitHub
parent 19530d1d99
commit 099fdd6f3e
20 changed files with 422 additions and 19 deletions
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import BoardHeader from '../board-header';
import { TRASH_BOARD_ADDRESS, TRASH_BOARD_TITLE } from '../../../lib/special-boards';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -202,6 +203,17 @@ describe('BoardHeader', () => {
expect(container.querySelector('img')?.getAttribute('src')).toBe('banner-a.png');
});
it('renders hidden special board metadata without a directory entry', async () => {
testState.directories = [];
testState.resolvedAddress = TRASH_BOARD_ADDRESS;
testState.stableCommunity = undefined;
await renderHeader('/trash');
expect(container.textContent).toContain(TRASH_BOARD_TITLE);
expect(container.textContent).toContain(TRASH_BOARD_ADDRESS);
});
it('renders the board loading indicator while online status is still loading', async () => {
testState.useIsCommunityOfflineValue = {
isOffline: false,
+3 -1
View File
@@ -8,6 +8,7 @@ import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
import { useStableCommunity } from '../../hooks/use-stable-community';
import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils';
import { isArchiveRoute, isDirectoryListRoute } from '../../lib/utils/route-utils';
import { getSpecialBoardByAddress } from '../../lib/special-boards';
import styles from './board-header.module.css';
import { useDirectories } from '../../hooks/use-directories';
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
@@ -66,6 +67,7 @@ const BoardHeader = () => {
// Find matching community from default list to get its title
const defaultCommunity = communityAddress ? directories.find((s) => s.address === communityAddress) : null;
const specialBoard = getSpecialBoardByAddress(communityAddress);
// Use accounts store with selector to only subscribe to subscriptions count
const subscriptionsCount = useAccountsStore((state) => {
@@ -81,7 +83,7 @@ const BoardHeader = () => {
? '/subs/ - Subscriptions'
: isInModView
? '/mod/ - Boards You Moderate'
: defaultCommunity?.title || stableCommunity?.title;
: defaultCommunity?.title || specialBoard?.title || stableCommunity?.title;
const subtitle = isInAllView
? t('all_subtitle')
: isInSubscriptionsView
@@ -3,6 +3,7 @@ import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import PostTransferModal from '../post-transfer-modal';
import { TRASH_BOARD_ADDRESS, TRASH_BOARD_PUBLIC_KEY, TRASH_BOARD_TITLE } from '../../../lib/special-boards';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -153,6 +154,60 @@ describe('PostTransferModal', () => {
expect(modal?.style.transform).toBe('');
});
it('offers the hidden trash board as a transfer target', async () => {
await renderTransferModal();
const options = Array.from(document.body.querySelectorAll<HTMLOptionElement>('select option')).map((option) => ({
text: option.textContent,
value: option.value,
}));
expect(options).toContainEqual({ text: TRASH_BOARD_TITLE, value: TRASH_BOARD_ADDRESS });
});
it('resolves the hidden trash board when it is the transfer source', async () => {
await renderTransferModal({ ...baseComment, communityAddress: TRASH_BOARD_ADDRESS });
expect(document.body.textContent).toContain(TRASH_BOARD_TITLE);
const select = document.body.querySelector<HTMLSelectElement>('select');
expect(select).not.toBeNull();
await act(async () => {
select!.value = 'random-nsfw.bso';
select!.dispatchEvent(new Event('change', { bubbles: true }));
});
const form = document.body.querySelector<HTMLFormElement>('form');
expect(form).not.toBeNull();
await act(async () => {
form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
});
const [publishOptions] = testState.publishCommentMock.mock.calls[0] as [Record<string, unknown>, string];
await act(async () => {
await (publishOptions.onChallengeVerification as (verification: unknown, comment: unknown) => Promise<void>)(
{ challengeSuccess: true, commentUpdate: { cid: 'target-comment' } },
{ cid: 'challenge-comment' },
);
});
const [sourceModerationOptions] = testState.publishCommentModerationMock.mock.calls[1] as [{ commentModeration: { reason: string } }, string];
expect(sourceModerationOptions.commentModeration.reason).toContain('/trash/');
expect(sourceModerationOptions.commentModeration.reason).toContain('(the rules)');
});
it('does not offer the hidden trash board target when the source uses its public key alias', async () => {
await renderTransferModal({ ...baseComment, communityAddress: TRASH_BOARD_PUBLIC_KEY });
const options = Array.from(document.body.querySelectorAll<HTMLOptionElement>('select option')).map((option) => ({
text: option.textContent,
value: option.value,
}));
expect(document.body.textContent).toContain(TRASH_BOARD_TITLE);
expect(options).not.toContainEqual({ text: TRASH_BOARD_TITLE, value: TRASH_BOARD_ADDRESS });
});
it('reopens desktop transfer modals at the last dragged session position', async () => {
await renderTransferModal();
@@ -12,6 +12,7 @@ import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-util
import { areSameBoardAddress } from '../../lib/utils/route-utils';
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
import capitalize from 'lodash/capitalize';
import { getSpecialBoardByAddress, SPECIAL_BOARDS } from '../../lib/special-boards';
import styles from './post-transfer-modal.module.css';
import {
getAvailableTransferFields,
@@ -98,6 +99,15 @@ const transferModalReducer = (state: TransferModalState, action: TransferModalAc
const getTransferBoardLabel = (community: { address?: string; directoryCode?: string; title?: string }): string =>
community.title || community.directoryCode || community.address || '';
const isSameTransferBoard = (community: { address?: string; aliases?: string[]; publicKey?: string }, sourceCommunityAddress: string | undefined): boolean => {
if (!sourceCommunityAddress) return false;
return (
areSameBoardAddress(community.address, sourceCommunityAddress) ||
community.publicKey === sourceCommunityAddress ||
Boolean(community.aliases?.includes(sourceCommunityAddress))
);
};
const getTransferFieldLabel = (field: PostTransferField, t: (key: string) => string): string => {
if (field === 'displayName') return capitalize(t('name'));
if (field === 'title') return capitalize(t('subject'));
@@ -182,23 +192,22 @@ const PostTransferModal = ({ comment, onClose, onTransferStateChange, onTransfer
const deleteAccount = useAccountsStore((state) => state.accountsActions.deleteAccount) as DeleteAccountAction;
const sourceCommunityAddress = getCommentCommunityAddress(comment);
const sourceCommentCid = comment.cid;
const boardOptions = useMemo(
() =>
directories
.filter((community) => community.address && (!sourceCommunityAddress || !areSameBoardAddress(community.address, sourceCommunityAddress)))
.sort((left, right) => getTransferBoardLabel(left).localeCompare(getTransferBoardLabel(right), undefined, { sensitivity: 'base' })),
[directories, sourceCommunityAddress],
);
const boardOptions = useMemo(() => {
const transferTargets = [...directories, ...SPECIAL_BOARDS];
return transferTargets
.filter((community) => community.address && !isSameTransferBoard(community, sourceCommunityAddress))
.sort((left, right) => getTransferBoardLabel(left).localeCompare(getTransferBoardLabel(right), undefined, { sensitivity: 'base' }));
}, [directories, sourceCommunityAddress]);
const [modalState, dispatchModalState] = useReducer(transferModalReducer, comment, getInitialTransferModalState);
const { targetBoardAddress, selectedFields, transferState, transferError } = modalState;
const resolvedTargetBoardAddress = targetBoardAddress;
const resolvedTargetBoard = boardOptions.find((community) => community.address === targetBoardAddress);
const availableFields = useMemo(() => getAvailableTransferFields(comment), [comment]);
const sourceBoard = useMemo(
() => (sourceCommunityAddress ? directories.find((community) => areSameBoardAddress(community.address, sourceCommunityAddress)) : undefined),
[directories, sourceCommunityAddress],
);
const sourceBoard = useMemo(() => {
if (!sourceCommunityAddress) return undefined;
return getSpecialBoardByAddress(sourceCommunityAddress) ?? directories.find((community) => isSameTransferBoard(community, sourceCommunityAddress));
}, [directories, sourceCommunityAddress]);
const sourceBoardLabel = useMemo(() => {
return sourceBoard ? getTransferBoardLabel(sourceBoard) : sourceCommunityAddress || 'N/A';
}, [sourceBoard, sourceCommunityAddress]);
@@ -15,6 +15,7 @@ import {
type DirectoriesData,
type DirectoryDefaultsData,
} from '../use-directories';
import { TRASH_BOARD_ADDRESS, TRASH_BOARD_CODE, TRASH_BOARD_PUBLIC_KEY, TRASH_BOARD_TITLE } from '../../lib/special-boards';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -317,6 +318,40 @@ describe('use-directories', () => {
expect(secondSnapshot.metadata?.title).toBe('5chan directories');
});
it('filters hidden special boards from remote directory data', async () => {
const remoteCodes = [...REMOTE_DIRECTORY_CODES, TRASH_BOARD_CODE];
fetchMock.mockImplementation((url: unknown) => {
if (isDefaultsUrl(url)) {
return Promise.resolve(
createFetchResponse(
createRemoteDefaults(remoteCodes, {
[TRASH_BOARD_CODE]: {
address: TRASH_BOARD_ADDRESS,
publicKey: TRASH_BOARD_PUBLIC_KEY,
title: TRASH_BOARD_TITLE,
features: { safeForWork: false },
},
}),
),
);
}
const code = getDirectoryCodeFromUrl(url);
const override =
code === TRASH_BOARD_CODE
? { address: TRASH_BOARD_ADDRESS, publicKey: TRASH_BOARD_PUBLIC_KEY, title: TRASH_BOARD_TITLE, features: { safeForWork: false } }
: undefined;
return Promise.resolve(createFetchResponse(createRemoteDirectoryList(code, override)));
});
renderHarness(TRASH_BOARD_ADDRESS);
await flushEffects(12);
const snapshot = expectLatestSnapshot();
expect(snapshot.directories.map((directory) => directory.directoryCode)).not.toContain(TRASH_BOARD_CODE);
expect(snapshot.addresses).not.toContain(TRASH_BOARD_ADDRESS);
expect(snapshot.directory).toBeUndefined();
});
it('keeps vendored non-default directories when remote defaults omit them and one list fetch fails', async () => {
fetchMock.mockImplementation((url: unknown) => {
if (isDefaultsUrl(url)) {
+122
View File
@@ -0,0 +1,122 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import useTheme from '../use-theme';
import { TRASH_BOARD_ADDRESS, TRASH_BOARD_CODE } from '../../lib/special-boards';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
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(() => ({
boardIdentifier: 'trash',
directories: [] as Array<{ address: string; nsfw?: boolean }>,
isSpecialThemeEnabled: false as boolean | null,
locationPathname: '/trash',
resolvedAddress: 'off-topic.bso' as string | undefined,
setIsEnabledMock: vi.fn(),
setThemeMock: vi.fn().mockResolvedValue(undefined),
themes: {
nsfw: 'tomorrow',
sfw: 'yotsuba-b',
},
}));
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useLocation: () => ({ pathname: testState.locationPathname }),
useParams: () => ({ boardIdentifier: testState.boardIdentifier }),
};
});
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useAccountComment: () => undefined,
}));
vi.mock('../use-directories', () => ({
useDirectories: () => testState.directories,
}));
vi.mock('../use-resolved-community-address', () => ({
useResolvedCommunityAddress: () => testState.resolvedAddress,
}));
vi.mock('../../stores/use-special-theme-store', () => ({
default: () => ({
isEnabled: testState.isSpecialThemeEnabled,
setIsEnabled: testState.setIsEnabledMock,
}),
}));
vi.mock('../../stores/use-theme-store', () => ({
default: <T,>(selector: (state: { setTheme: (category: 'nsfw' | 'sfw', theme: string) => Promise<void>; themes: typeof testState.themes }) => T) =>
selector({
setTheme: testState.setThemeMock,
themes: testState.themes,
}),
}));
vi.mock('../../lib/update-favicon', () => ({
isSfwBoard: () => false,
updateFavicon: vi.fn(),
}));
vi.mock('../../lib/utils/time-utils', () => ({
getActiveSpecialTheme: () => undefined,
getSpecialThemeClass: () => 'spooky',
}));
let latestValue: [string, (theme: string) => void | Promise<void>] | undefined;
let container: HTMLDivElement;
let root: Root;
const HookHarness = () => {
latestValue = useTheme();
return null;
};
const renderHook = async () => {
await act(async () => {
root.render(createElement(HookHarness));
});
};
describe('useTheme', () => {
beforeEach(() => {
vi.clearAllMocks();
latestValue = undefined;
testState.boardIdentifier = TRASH_BOARD_CODE;
testState.directories = [];
testState.isSpecialThemeEnabled = false;
testState.locationPathname = `/${TRASH_BOARD_CODE}`;
testState.resolvedAddress = TRASH_BOARD_ADDRESS;
testState.themes = {
nsfw: 'tomorrow',
sfw: 'yotsuba-b',
};
document.body.className = '';
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.body.className = '';
});
it('uses the nsfw theme bucket for hidden special boards', async () => {
await renderHook();
expect(latestValue?.[0]).toBe('tomorrow');
await act(async () => {
await latestValue?.[1]('photon');
});
expect(testState.setThemeMock).toHaveBeenCalledWith('nsfw', 'photon');
});
});
+8 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { vendoredDirectoryLists as directoryListsData, vendoredDirectoryDefaults as directoryDefaultsData } from '../data/vendored-directory-lists';
import { isSpecialBoardAddress, isSpecialBoardCode } from '../lib/special-boards';
import {
directoryListToCommunity,
isRecord,
@@ -78,6 +79,12 @@ const dedupeCommunities = (entries: DirectoryCommunity[]): DirectoryCommunity[]
return normalizedEntries;
};
const isVisibleDirectoryCommunity = (community: DirectoryCommunity): boolean =>
!isSpecialBoardCode(community.directoryCode) &&
!isSpecialBoardAddress(community.address) &&
!isSpecialBoardAddress(community.name) &&
!isSpecialBoardAddress(community.publicKey);
const adaptDirectoryLists = (value: Record<string, unknown>): DirectoryCommunity[] => {
if (!Array.isArray(value.directories)) {
return [];
@@ -199,7 +206,7 @@ const normalizeDirectoriesData = (value: unknown): DirectoriesData | null => {
}
const adapters: Array<(raw: Record<string, unknown>) => DirectoryCommunity[]> = [adaptDirectoryLists, adaptV2Directories, adaptV1Communities];
const communities = adapters.map((adapter) => adapter(value)).find((normalized) => normalized.length > 0) ?? [];
const communities = (adapters.map((adapter) => adapter(value)).find((normalized) => normalized.length > 0) ?? []).filter(isVisibleDirectoryCommunity);
if (communities.length === 0) {
return null;
+5 -2
View File
@@ -7,6 +7,7 @@ import { useDirectories } from './use-directories';
import { useResolvedCommunityAddress } from './use-resolved-community-address';
import useSpecialThemeStore from '../stores/use-special-theme-store';
import { getActiveSpecialTheme, getSpecialThemeClass } from '../lib/utils/time-utils';
import { getSpecialBoardByAddress } from '../lib/special-boards';
import { isSfwBoard, updateFavicon } from '../lib/update-favicon';
import { getCommentCommunityAddress } from '../lib/utils/comment-utils';
import { normalizeAccountCommentIndex } from '../lib/utils/account-comment-index-utils';
@@ -67,7 +68,8 @@ const useTheme = (): [string, (theme: string) => void] => {
storedTheme = themes.nsfw;
} else if (communityAddress) {
const community = directories.find((entry) => entry.address === communityAddress);
if (community?.nsfw) {
const specialBoard = getSpecialBoardByAddress(communityAddress);
if (community?.nsfw || specialBoard?.nsfw) {
storedTheme = themes.nsfw;
} else {
storedTheme = themes.sfw;
@@ -101,7 +103,8 @@ const useTheme = (): [string, (theme: string) => void] => {
await setThemeStore('nsfw', newTheme);
} else if (communityAddress) {
const community = directories.find((entry) => entry.address === communityAddress);
if (community?.nsfw) {
const specialBoard = getSpecialBoardByAddress(communityAddress);
if (community?.nsfw || specialBoard?.nsfw) {
await setThemeStore('nsfw', newTheme);
} else {
await setThemeStore('sfw', newTheme);
@@ -9,6 +9,7 @@ import {
hasTransferredCommentMarker,
TRANSFERRED_COMMENT_FLAIR,
} from '../comment-transfer';
import { TRASH_BOARD_ADDRESS, TRASH_BOARD_CODE, TRASH_BOARD_TITLE } from '../special-boards';
describe('comment-transfer', () => {
const selectedFields = {
@@ -112,6 +113,8 @@ describe('comment-transfer', () => {
it('formats source board references and rules links from directory metadata', () => {
expect(getTransferSourceBoardReference({ address: 'japanese-culture.eth', directoryCode: 'jp', title: '/jp/ - Otaku Culture' }, 'fallback.eth')).toBe('/jp/');
expect(getTransferSourceBoardRulesLink({ address: 'japanese-culture.eth', directoryCode: 'jp', title: '/jp/ - Otaku Culture' })).toBe('[rules](/rules#jp)');
expect(getTransferSourceBoardReference({ address: TRASH_BOARD_ADDRESS, directoryCode: TRASH_BOARD_CODE, title: TRASH_BOARD_TITLE }, 'fallback.eth')).toBe('/trash/');
expect(getTransferSourceBoardRulesLink({ address: TRASH_BOARD_ADDRESS, directoryCode: TRASH_BOARD_CODE, title: TRASH_BOARD_TITLE })).toBe('the rules');
expect(getTransferSourceBoardReference(undefined, 'source-board.eth')).toBe('source-board.eth');
expect(getTransferSourceBoardRulesLink(undefined)).toBe('the rules');
});
+13
View File
@@ -50,6 +50,7 @@ describe('update-favicon', () => {
it('marks only non-special, non-routing aggregate sfw boards as sfw', async () => {
const { isSfwBoard } = await import('../update-favicon');
const { TRASH_BOARD_ADDRESS } = await import('../special-boards');
expect(
isSfwBoard({
@@ -89,5 +90,17 @@ describe('update-favicon', () => {
directories: [{ address: 'flash.eth', nsfw: true }],
}),
).toBe(false);
expect(
isSfwBoard({
pathname: '/trash',
isSpecialTheme: false,
isInAllView: false,
isInSubscriptionsView: false,
isInModView: false,
communityAddress: TRASH_BOARD_ADDRESS,
directories: [],
}),
).toBe(false);
});
});
+2 -1
View File
@@ -1,5 +1,6 @@
import type { Comment } from '@bitsocial/bitsocial-react-hooks';
import { isCommentFlagFlair } from './comment-flags';
import { isSpecialBoardCode } from './special-boards';
import { isCommentArchived } from './utils/comment-moderation-utils';
import { normalizePublishURL } from './utils/url-utils';
@@ -113,7 +114,7 @@ export const getTransferSourceBoardReference = (sourceBoard: TransferBoardLike |
export const getTransferSourceBoardRulesLink = (sourceBoard: TransferBoardLike | undefined): string => {
const directoryCode = getTextField(sourceBoard?.directoryCode);
return directoryCode ? `[rules](/rules#${directoryCode})` : 'the rules';
return directoryCode && !isSpecialBoardCode(directoryCode) ? `[rules](/rules#${directoryCode})` : 'the rules';
};
export const getTransferSourceModeration = (
+34
View File
@@ -0,0 +1,34 @@
export interface SpecialBoard {
address: string;
aliases?: string[];
directoryCode: string;
nsfw?: boolean;
title: string;
}
export const TRASH_BOARD_PUBLIC_KEY = '12D3KooWREyT7yBV8tA8uSpps8vX7XS1YM4385ty2MmHFyR9GKmf';
export const TRASH_BOARD_ADDRESS = 'off-topic.bso';
export const TRASH_BOARD_CODE = 'trash';
export const TRASH_BOARD_TITLE = '/trash/ - Off-topic';
export const SPECIAL_BOARDS: SpecialBoard[] = [
{
address: TRASH_BOARD_ADDRESS,
aliases: [TRASH_BOARD_PUBLIC_KEY, 'off-topic.eth'],
directoryCode: TRASH_BOARD_CODE,
nsfw: true,
title: TRASH_BOARD_TITLE,
},
];
const getSpecialBoardLookupAddresses = (board: SpecialBoard): string[] => [board.address, ...(board.aliases ?? [])];
export const getSpecialBoardByCode = (code: string | undefined): SpecialBoard | undefined =>
code ? SPECIAL_BOARDS.find((board) => board.directoryCode === code) : undefined;
export const getSpecialBoardByAddress = (address: string | undefined): SpecialBoard | undefined =>
address ? SPECIAL_BOARDS.find((board) => getSpecialBoardLookupAddresses(board).includes(address)) : undefined;
export const isSpecialBoardCode = (code: string | undefined): boolean => Boolean(getSpecialBoardByCode(code));
export const isSpecialBoardAddress = (address: string | undefined): boolean => Boolean(getSpecialBoardByAddress(address));
+5
View File
@@ -1,3 +1,5 @@
import { getSpecialBoardByAddress } from './special-boards';
const DEFAULT_FAVICON = '/favicon.ico?variant=nsfw';
const SFW_FAVICON = '/favicon2.ico?variant=sfw';
const NOT_FOUND_FAVICON = '/favicon-404.ico?variant=404';
@@ -77,5 +79,8 @@ export const isSfwBoard = ({
if (!communityAddress) return false;
const entry = directories.find((d) => d.address === communityAddress);
const specialBoard = getSpecialBoardByAddress(communityAddress);
if (specialBoard) return !specialBoard.nsfw;
return !entry?.nsfw;
};
@@ -0,0 +1,62 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TRASH_BOARD_ADDRESS, TRASH_BOARD_CODE, TRASH_BOARD_PUBLIC_KEY } from '../../special-boards';
const importLookupUtilsWithDirectoryLists = async (directories: unknown[]) => {
vi.resetModules();
vi.doMock('../../../data/vendored-directory-lists', () => ({
vendoredDirectoryLists: {
directories,
},
}));
return import('../directory-list-lookup-utils');
};
describe('directory-list-lookup-utils', () => {
afterEach(() => {
vi.doUnmock('../../../data/vendored-directory-lists');
vi.resetModules();
});
it('does not expose hidden special boards as vendored directory candidates', async () => {
const { getDirectoryCandidateBoardByAddress, getDirectoryCodeForBoardAddress } = await importLookupUtilsWithDirectoryLists([
{
directoryCode: 'b',
boards: [
{
address: TRASH_BOARD_ADDRESS,
publicKey: TRASH_BOARD_PUBLIC_KEY,
},
],
},
]);
expect(getDirectoryCodeForBoardAddress(TRASH_BOARD_ADDRESS)).toBeUndefined();
expect(getDirectoryCodeForBoardAddress(TRASH_BOARD_PUBLIC_KEY)).toBeUndefined();
expect(getDirectoryCandidateBoardByAddress(TRASH_BOARD_ADDRESS)).toBeUndefined();
expect(getDirectoryCandidateBoardByAddress(TRASH_BOARD_PUBLIC_KEY)).toBeUndefined();
});
it('excludes entire vendored directory lists that use a special board code', async () => {
const { getVendoredDirectoryLists } = await importLookupUtilsWithDirectoryLists([
{
directoryCode: TRASH_BOARD_CODE,
boards: [
{
address: TRASH_BOARD_ADDRESS,
publicKey: TRASH_BOARD_PUBLIC_KEY,
},
],
},
{
directoryCode: 'b',
boards: [
{
address: 'random-nsfw.bso',
},
],
},
]);
expect(getVendoredDirectoryLists().map((directory) => directory.directoryCode)).toEqual(['b']);
});
});
@@ -25,6 +25,7 @@ import {
normalizeMultiboardFeedPath,
stripPageFromFeedPath,
} from '../route-utils';
import { TRASH_BOARD_ADDRESS, TRASH_BOARD_CODE, TRASH_BOARD_PUBLIC_KEY } from '../../special-boards';
import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY, touchLastVisitTimestamp } from '../time-filter-utils';
const communities = [
@@ -61,6 +62,16 @@ describe('directory mapping helpers', () => {
expect(getCommunityAddress('unknown.example', communities)).toBe('unknown.example');
});
it('maps hidden special boards without treating them as directory routes', () => {
expect(getBoardPath(TRASH_BOARD_ADDRESS, communities)).toBe(TRASH_BOARD_CODE);
expect(getBoardPath(TRASH_BOARD_PUBLIC_KEY, communities)).toBe(TRASH_BOARD_CODE);
expect(getCommunityAddress(TRASH_BOARD_CODE, communities)).toBe(TRASH_BOARD_ADDRESS);
expect(getCommunityAddress('off-topic.eth', communities)).toBe(TRASH_BOARD_ADDRESS);
expect(isDirectoryRoute(TRASH_BOARD_CODE, communities)).toBe(false);
expect(isDirectoryBoard(TRASH_BOARD_CODE, communities)).toBe(false);
});
it('compares aliases and directory identifiers correctly', () => {
expect(areSameBoardAddress('music-posting.eth', 'music-posting.bso')).toBe(true);
expect(areSameBoardAddress('music-posting.eth', 'business.eth')).toBe(false);
+4 -1
View File
@@ -1,4 +1,5 @@
import { vendoredDirectoryLists as directoryListsData } from '../../data/vendored-directory-lists';
import { isSpecialBoardAddress, isSpecialBoardCode } from '../special-boards';
import { normalizeDirectoryList, type DirectoryList, type DirectoryListBoard } from './directory-list-utils';
const DIRECTORY_ALIAS_SUFFIXES = ['.bso', '.eth'] as const;
@@ -21,7 +22,7 @@ export const getVendoredDirectoryLists = (): DirectoryList[] => {
const directories = Array.isArray(directoryListsData.directories) ? directoryListsData.directories : [];
vendoredDirectoryListsCache = directories.flatMap((directory) => {
const directoryCode = typeof directory.directoryCode === 'string' ? directory.directoryCode : undefined;
if (!directoryCode) return [];
if (!directoryCode || isSpecialBoardCode(directoryCode)) return [];
const normalized = normalizeDirectoryList(directory, directoryCode);
return normalized ? [normalized] : [];
});
@@ -39,6 +40,7 @@ const findBoardInList = (list: DirectoryList, address: string): DirectoryListBoa
export const getDirectoryCandidateBoardByAddress = (address: string | undefined): DirectoryListBoard | undefined => {
if (!address) return undefined;
if (isSpecialBoardAddress(address)) return undefined;
for (const directory of getVendoredDirectoryLists()) {
const board = findBoardInList(directory, address);
@@ -50,6 +52,7 @@ export const getDirectoryCandidateBoardByAddress = (address: string | undefined)
export const getDirectoryCodeForBoardAddress = (address: string | undefined): string | undefined => {
if (!address) return undefined;
if (isSpecialBoardAddress(address)) return undefined;
return getVendoredDirectoryLists().find((directory) => findBoardInList(directory, address))?.directoryCode;
};
+7
View File
@@ -1,4 +1,5 @@
import { DirectoryCommunity, findDirectoryByAddress, normalizeBoardAddress } from '../../hooks/use-directories';
import { getSpecialBoardByAddress, getSpecialBoardByCode } from '../special-boards';
import { isFlashDirectory, isFlashDirectoryCode } from '../flash-tags';
import { getEffectiveTimeFilterName, getSearchWithTimeFilter } from './time-filter-utils';
@@ -81,6 +82,9 @@ const getAddressToDirectoryMap = (communities: DirectoryCommunity[]): Map<string
* Uses findDirectoryByAddress for alias resolution (.bso/.eth) so music-posting.eth maps to mu.
*/
export const getBoardPath = (communityAddress: string, communities: DirectoryCommunity[]): string => {
const specialBoard = getSpecialBoardByAddress(communityAddress) ?? getSpecialBoardByCode(communityAddress);
if (specialBoard) return specialBoard.directoryCode;
const addressToDirectory = getAddressToDirectoryMap(communities);
let directory = addressToDirectory.get(communityAddress);
if (!directory) {
@@ -94,6 +98,9 @@ export const getBoardPath = (communityAddress: string, communities: DirectoryCom
* Convert URL path (directory code or address) to community address
*/
export const getCommunityAddress = (boardIdentifier: string, communities: DirectoryCommunity[]): string => {
const specialBoard = getSpecialBoardByCode(boardIdentifier) ?? getSpecialBoardByAddress(boardIdentifier);
if (specialBoard) return specialBoard.address;
const directoryToAddress = getDirectoryToAddressMap(communities);
// Check if it's a directory code
+16
View File
@@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import communitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
import Board, { type BoardProps } from '../board';
import { TRASH_BOARD_ADDRESS, TRASH_BOARD_TITLE } from '../../../lib/special-boards';
import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY } from '../../../lib/utils/time-filter-utils';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -564,6 +565,21 @@ describe('Board', () => {
expect(container.textContent).not.toContain('board_address_unverified_warning');
});
it('uses hidden special board metadata for the page title', async () => {
testState.directories = [];
testState.directoryByAddress = {};
testState.resolvedCommunityAddress = TRASH_BOARD_ADDRESS;
testState.communitySnapshot = {};
await renderBoard({
boardProps: { boardIdentifier: 'trash', viewType: 'board' },
initialEntry: '/trash',
routePath: '/:boardIdentifier/*',
});
expect(document.title).toBe(`${TRASH_BOARD_TITLE} - 5chan`);
});
it('renders the current page feed, inserts recent account comments, and wires footer actions', async () => {
const currentTimestamp = Math.floor(Date.now() / 1000);
testState.feed = [
+3 -1
View File
@@ -25,6 +25,7 @@ import { useSuggestionFeedLoader } from '../../hooks/use-suggestion-feed-loader'
import useTimeFilter from '../../hooks/use-time-filter';
import { getPageSlice } from '../../lib/utils/board-feed-pagination';
import { getPageFromFeedPath, isDirectoryBoard, normalizeMultiboardFeedPath, stripPageFromFeedPath } from '../../lib/utils/route-utils';
import { getSpecialBoardByAddress } from '../../lib/special-boards';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { getNonokoPendingAccountCommentIndex } from '../../lib/utils/post-options-utils';
@@ -227,6 +228,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
const communityIdentifier = useCommunityIdentifier(communityAddress);
const communityDirectory = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : communityAddress);
const specialBoard = getSpecialBoardByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : communityAddress);
const requestedBoardIdentifier = boardIdentifierProp || params.boardIdentifier;
const shouldUseFlashTable = !isMultiboardView && (isFlashDirectoryCode(requestedBoardIdentifier) || isFlashDirectory(communityDirectory));
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
@@ -555,7 +557,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
const isKnownEmptySingleCommunityBoard = isSingleCommunityBoard && combinedFeed.length === 0 && isLoadedCommunityState && isRawBoardThreadStateEmpty && isFeedSucceeded;
const effectiveHasMore = isKnownEmptySingleCommunityBoard ? false : hasMore;
const title = isInAllView ? t('all') : isInSubscriptionsView ? t('subscriptions') : isInModView ? t('mod') : communityTitle;
const title = isInAllView ? t('all') : isInSubscriptionsView ? t('subscriptions') : isInModView ? t('mod') : specialBoard?.title || communityTitle;
// Memoize footer component to preserve identity across renders (Virtuoso optimization)
// Note: useFeedStateString is called inside BoardFooter to isolate re-renders from backend state changes
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { Link, MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ModQueueView from '../mod-queue';
import { TRASH_BOARD_ADDRESS } from '../../../lib/special-boards';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (callback: () => void | Promise<void>) => void | Promise<void> }).act as (callback: () => void | Promise<void>) => void | Promise<void>;
@@ -618,7 +619,7 @@ describe('ModQueueView', () => {
expect(dialog?.textContent).toContain('modQueue.transferTemporaryAccountNotice');
expect(dialog?.textContent).not.toContain('modQueue.transferAccount');
const targetSelect = dialog?.querySelector<HTMLSelectElement>('select');
expect(Array.from(targetSelect?.options ?? []).map((option) => option.value)).toEqual(['', 'anime-posting.eth', 'tech-posting.eth']);
expect(Array.from(targetSelect?.options ?? []).map((option) => option.value)).toEqual(['', 'anime-posting.eth', 'tech-posting.eth', TRASH_BOARD_ADDRESS]);
const submitButton = Array.from(dialog?.querySelectorAll<HTMLButtonElement>('button') ?? []).find((button) => button.type === 'submit');
expect(submitButton?.disabled).toBe(true);