From 099fdd6f3e3506984e473e957aac81e6645294c3 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Thu, 2 Jul 2026 21:22:19 +0700 Subject: [PATCH] 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 --- .../__tests__/board-header.test.tsx | 12 ++ src/components/board-header/board-header.tsx | 4 +- .../__tests__/post-transfer-modal.test.tsx | 55 ++++++++ .../post-transfer-modal.tsx | 31 +++-- src/hooks/__tests__/use-directories.test.tsx | 35 +++++ src/hooks/__tests__/use-theme.test.tsx | 122 ++++++++++++++++++ src/hooks/use-directories.ts | 9 +- src/hooks/use-theme.ts | 7 +- src/lib/__tests__/comment-transfer.test.ts | 3 + src/lib/__tests__/update-favicon.test.ts | 13 ++ src/lib/comment-transfer.ts | 3 +- src/lib/special-boards.ts | 34 +++++ src/lib/update-favicon.ts | 5 + .../directory-list-lookup-utils.test.ts | 62 +++++++++ src/lib/utils/__tests__/route-utils.test.ts | 11 ++ src/lib/utils/directory-list-lookup-utils.ts | 5 +- src/lib/utils/route-utils.ts | 7 + src/views/board/__tests__/board.test.tsx | 16 +++ src/views/board/board.tsx | 4 +- .../mod-queue/__tests__/mod-queue.test.tsx | 3 +- 20 files changed, 422 insertions(+), 19 deletions(-) create mode 100644 src/hooks/__tests__/use-theme.test.tsx create mode 100644 src/lib/special-boards.ts create mode 100644 src/lib/utils/__tests__/directory-list-lookup-utils.test.ts diff --git a/src/components/board-header/__tests__/board-header.test.tsx b/src/components/board-header/__tests__/board-header.test.tsx index 7411f616..7529a614 100644 --- a/src/components/board-header/__tests__/board-header.test.tsx +++ b/src/components/board-header/__tests__/board-header.test.tsx @@ -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 | Promise }).act as (cb: () => void | Promise) => void | Promise; @@ -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, diff --git a/src/components/board-header/board-header.tsx b/src/components/board-header/board-header.tsx index 48afd7d4..7d4a1c03 100644 --- a/src/components/board-header/board-header.tsx +++ b/src/components/board-header/board-header.tsx @@ -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 diff --git a/src/components/post-transfer-modal/__tests__/post-transfer-modal.test.tsx b/src/components/post-transfer-modal/__tests__/post-transfer-modal.test.tsx index 825b87c1..20107f84 100644 --- a/src/components/post-transfer-modal/__tests__/post-transfer-modal.test.tsx +++ b/src/components/post-transfer-modal/__tests__/post-transfer-modal.test.tsx @@ -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 | Promise }).act as (cb: () => void | Promise) => void | Promise; @@ -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('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('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('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]; + await act(async () => { + await (publishOptions.onChallengeVerification as (verification: unknown, comment: unknown) => Promise)( + { 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('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(); diff --git a/src/components/post-transfer-modal/post-transfer-modal.tsx b/src/components/post-transfer-modal/post-transfer-modal.tsx index dacd1cf7..f6d4f1cc 100644 --- a/src/components/post-transfer-modal/post-transfer-modal.tsx +++ b/src/components/post-transfer-modal/post-transfer-modal.tsx @@ -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]); diff --git a/src/hooks/__tests__/use-directories.test.tsx b/src/hooks/__tests__/use-directories.test.tsx index 29cffa82..99379fc0 100644 --- a/src/hooks/__tests__/use-directories.test.tsx +++ b/src/hooks/__tests__/use-directories.test.tsx @@ -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 | Promise }).act as (cb: () => void | Promise) => void | Promise; @@ -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)) { diff --git a/src/hooks/__tests__/use-theme.test.tsx b/src/hooks/__tests__/use-theme.test.tsx new file mode 100644 index 00000000..b7555306 --- /dev/null +++ b/src/hooks/__tests__/use-theme.test.tsx @@ -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 | Promise }).act as (cb: () => void | Promise) => void | Promise; + +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('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: (selector: (state: { setTheme: (category: 'nsfw' | 'sfw', theme: string) => Promise; 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] | 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'); + }); +}); diff --git a/src/hooks/use-directories.ts b/src/hooks/use-directories.ts index 9daa8770..0531e207 100644 --- a/src/hooks/use-directories.ts +++ b/src/hooks/use-directories.ts @@ -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): DirectoryCommunity[] => { if (!Array.isArray(value.directories)) { return []; @@ -199,7 +206,7 @@ const normalizeDirectoriesData = (value: unknown): DirectoriesData | null => { } const adapters: Array<(raw: Record) => 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; diff --git a/src/hooks/use-theme.ts b/src/hooks/use-theme.ts index 9f442d3a..19f46d65 100644 --- a/src/hooks/use-theme.ts +++ b/src/hooks/use-theme.ts @@ -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); diff --git a/src/lib/__tests__/comment-transfer.test.ts b/src/lib/__tests__/comment-transfer.test.ts index 233cdb33..90a0fb6a 100644 --- a/src/lib/__tests__/comment-transfer.test.ts +++ b/src/lib/__tests__/comment-transfer.test.ts @@ -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'); }); diff --git a/src/lib/__tests__/update-favicon.test.ts b/src/lib/__tests__/update-favicon.test.ts index 1443f443..8a59c8e0 100644 --- a/src/lib/__tests__/update-favicon.test.ts +++ b/src/lib/__tests__/update-favicon.test.ts @@ -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); }); }); diff --git a/src/lib/comment-transfer.ts b/src/lib/comment-transfer.ts index 035121b3..281430c5 100644 --- a/src/lib/comment-transfer.ts +++ b/src/lib/comment-transfer.ts @@ -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 = ( diff --git a/src/lib/special-boards.ts b/src/lib/special-boards.ts new file mode 100644 index 00000000..c85cfab7 --- /dev/null +++ b/src/lib/special-boards.ts @@ -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)); diff --git a/src/lib/update-favicon.ts b/src/lib/update-favicon.ts index c4213305..934ba3fc 100644 --- a/src/lib/update-favicon.ts +++ b/src/lib/update-favicon.ts @@ -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; }; diff --git a/src/lib/utils/__tests__/directory-list-lookup-utils.test.ts b/src/lib/utils/__tests__/directory-list-lookup-utils.test.ts new file mode 100644 index 00000000..66858e9c --- /dev/null +++ b/src/lib/utils/__tests__/directory-list-lookup-utils.test.ts @@ -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']); + }); +}); diff --git a/src/lib/utils/__tests__/route-utils.test.ts b/src/lib/utils/__tests__/route-utils.test.ts index bbd0a02c..cb6fae75 100644 --- a/src/lib/utils/__tests__/route-utils.test.ts +++ b/src/lib/utils/__tests__/route-utils.test.ts @@ -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); diff --git a/src/lib/utils/directory-list-lookup-utils.ts b/src/lib/utils/directory-list-lookup-utils.ts index 2999b949..160b5605 100644 --- a/src/lib/utils/directory-list-lookup-utils.ts +++ b/src/lib/utils/directory-list-lookup-utils.ts @@ -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; }; diff --git a/src/lib/utils/route-utils.ts b/src/lib/utils/route-utils.ts index 5286ee48..bf32492c 100644 --- a/src/lib/utils/route-utils.ts +++ b/src/lib/utils/route-utils.ts @@ -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 { + 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 diff --git a/src/views/board/__tests__/board.test.tsx b/src/views/board/__tests__/board.test.tsx index b7438e65..da686931 100644 --- a/src/views/board/__tests__/board.test.tsx +++ b/src/views/board/__tests__/board.test.tsx @@ -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 = [ diff --git a/src/views/board/board.tsx b/src/views/board/board.tsx index 801b35fe..18f37f3b 100644 --- a/src/views/board/board.tsx +++ b/src/views/board/board.tsx @@ -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 diff --git a/src/views/mod-queue/__tests__/mod-queue.test.tsx b/src/views/mod-queue/__tests__/mod-queue.test.tsx index 514989a4..1d2428b0 100644 --- a/src/views/mod-queue/__tests__/mod-queue.test.tsx +++ b/src/views/mod-queue/__tests__/mod-queue.test.tsx @@ -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 | Promise }).act as (callback: () => void | Promise) => void | Promise; @@ -618,7 +619,7 @@ describe('ModQueueView', () => { expect(dialog?.textContent).toContain('modQueue.transferTemporaryAccountNotice'); expect(dialog?.textContent).not.toContain('modQueue.transferAccount'); const targetSelect = dialog?.querySelector('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('button') ?? []).find((button) => button.type === 'submit'); expect(submitButton?.disabled).toBe(true);