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