From 4cc2bf4ea1b4296d88bc021d464e90cc3de431ef Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Tue, 2 Jun 2026 15:00:14 +0700 Subject: [PATCH] fix(flags): resolve comment flags from directory list candidates Use directory list board entries when the active address is not yet in the directories cache so /pol and similar boards get correct flag UI. --- .../post-form/__tests__/post-form.test.tsx | 59 ++++++++++++++ src/components/post-form/post-form.tsx | 5 +- .../__tests__/reply-modal.test.tsx | 54 +++++++++++++ src/components/reply-modal/reply-modal.tsx | 5 +- .../__tests__/use-directory-entry.test.ts | 33 ++++++++ src/hooks/use-directory-entry.ts | 80 +++++++++++++++++++ 6 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 src/hooks/__tests__/use-directory-entry.test.ts create mode 100644 src/hooks/use-directory-entry.ts diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx index c9d30055..02a4c60d 100644 --- a/src/components/post-form/__tests__/post-form.test.tsx +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -22,6 +22,15 @@ const testState = vi.hoisted(() => ({ { address: 'music-posting.eth', features: {}, title: '/mu/ - Music' }, { address: 'mod.eth', features: {}, title: '/mod/ - Moderation' }, ] as Array<{ address: string; directoryCode?: string; features?: Record; title?: string }>, + directoryListsByCode: {} as Record< + string, + { + boards: Array<{ address: string; features?: Record; publicKey?: string }>; + directoryCode: string; + features?: Record; + title?: string; + } + >, editedComment: undefined as { commentModeration?: { archived?: boolean }; deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean } | undefined, gifFrameStatus: 'idle' as 'idle' | 'ready', handleUploadMock: vi.fn(), @@ -132,6 +141,24 @@ vi.mock('../../../hooks/use-directories', () => ({ normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''), })); +vi.mock('../../../hooks/use-directory-entry', async () => { + const actual = await vi.importActual('../../../hooks/use-directory-entry'); + return { + ...actual, + useDirectoryEntry: (address: string | undefined, directoryCodeHint?: string) => { + const list = + testState.directoryListsByCode[directoryCodeHint ?? ''] ?? + Object.values(testState.directoryListsByCode).find((candidate) => candidate.boards.some((board) => board.address === address)); + return actual.getDirectoryEntryForAddress({ + address, + directories: testState.directories, + directoryCodeHint, + list, + }); + }, + }; +}); + vi.mock('../../../hooks/use-community-identifiers', () => ({ useCommunityIdentifier: (address?: string) => (address ? { name: address } : undefined), })); @@ -456,6 +483,7 @@ describe('PostForm', () => { { address: 'traditional-games.bso', features: {}, title: '/tg/ - Traditional Games' }, { address: 'mod.eth', features: {}, title: '/mod/ - Moderation' }, ]; + testState.directoryListsByCode = {}; testState.editedComment = undefined; testState.gifFrameStatus = 'idle'; testState.isOffline = false; @@ -700,6 +728,37 @@ describe('PostForm', () => { }); }); + it('shows the /pol/ flag field when a non-primary directory candidate is hosting /pol/', async () => { + testState.resolvedCommunityAddress = 'nothing-is-beyond-our-reach.bso'; + testState.directoryListsByCode.pol = { + directoryCode: 'pol', + features: { hasFlags: true }, + title: '/pol/ - Politically Incorrect', + boards: [{ address: 'politically-incorrect.bso' }, { address: 'nothing-is-beyond-our-reach.bso' }], + }; + + await renderPostForm('/pol'); + await clickByText(container, 'start_new_thread'); + + const table = container.querySelector('table'); + const flagSelect = table?.querySelector('select[aria-label="flag"]'); + const textarea = table?.querySelector('textarea'); + + expect(flagSelect).toBeTruthy(); + expect(flagSelect?.value).toBe('country:auto'); + + await dispatchInput(textarea as HTMLTextAreaElement, 'candidate board flag post'); + await clickByText(table as HTMLTableElement, 'post'); + + expect(testState.publishPostMock).toHaveBeenCalledWith({ + content: 'candidate board flag post', + challengeRequest: { + challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'], + }, + flairs: [{ type: 'country', code: 'auto', text: 'flag:country:auto' }], + }); + }); + it('publishes geographic location on /sp/ without showing a flag field', async () => { testState.resolvedCommunityAddress = 'sports-posting.bso'; diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index 770d5338..e5ee5541 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -28,7 +28,8 @@ import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, is import { getCommentFlagOptionsForDirectory, getCommentFlagPublishOptionsForDirectory, type CommentFlagSelectOption } from '../../lib/comment-flag-selection'; import { FLASH_TAG_OPTIONS, getFlashTagPublishOptionsForDirectoryCode, isFlashDirectoryCode, type FlashTagOption } from '../../lib/flash-tags'; import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses'; -import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories'; +import { useDirectories } from '../../hooks/use-directories'; +import { useDirectoryEntry } from '../../hooks/use-directory-entry'; import { useCommunityField } from '../../hooks/use-stable-community'; import useIsMobile from '../../hooks/use-is-mobile'; import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address'; @@ -519,7 +520,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams()); const subscriptions = account?.subscriptions || []; const directories = useDirectories(); - const directoryEntry = useDirectoryByAddress(effectiveBoardAddress); + const directoryEntry = useDirectoryEntry(effectiveBoardAddress, params?.boardIdentifier); const pendingPostBoardPath = effectiveBoardAddress ? getBoardPath(effectiveBoardAddress, directories) : undefined; const rulesPath = effectiveBoardAddress ? `/rules/${getBoardPath(effectiveBoardAddress, directories)}` : '/rules'; const showSpoilerForPost = directoryEntry?.features?.noSpoilers !== true; diff --git a/src/components/reply-modal/__tests__/reply-modal.test.tsx b/src/components/reply-modal/__tests__/reply-modal.test.tsx index 1131290d..c22d5729 100644 --- a/src/components/reply-modal/__tests__/reply-modal.test.tsx +++ b/src/components/reply-modal/__tests__/reply-modal.test.tsx @@ -20,6 +20,15 @@ const testState = vi.hoisted(() => ({ title: '/mu/ - Music', }, } as Record; title?: string }>, + directoryListsByCode: {} as Record< + string, + { + boards: Array<{ address: string; features?: Record; publicKey?: string }>; + directoryCode: string; + features?: Record; + title?: string; + } + >, handleUploadMock: vi.fn(), uploadFileMock: vi.fn(), isMobile: false, @@ -150,6 +159,24 @@ vi.mock('../../../hooks/use-directories', () => ({ normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''), })); +vi.mock('../../../hooks/use-directory-entry', async () => { + const actual = await vi.importActual('../../../hooks/use-directory-entry'); + return { + ...actual, + useDirectoryEntry: (address: string | undefined, directoryCodeHint?: string) => { + const list = + testState.directoryListsByCode[directoryCodeHint ?? ''] ?? + Object.values(testState.directoryListsByCode).find((candidate) => candidate.boards.some((board) => board.address === address)); + return actual.getDirectoryEntryForAddress({ + address, + directories: Object.values(testState.directoryByAddress), + directoryCodeHint, + list, + }); + }, + }; +}); + vi.mock('../../../hooks/use-community-identifiers', () => ({ useCommunityIdentifier: (address?: string) => (address ? { name: address } : undefined), })); @@ -368,6 +395,7 @@ describe('ReplyModal', () => { title: '/tg/ - Traditional Games', }, }; + testState.directoryListsByCode = {}; testState.handleUploadMock.mockReset(); testState.uploadFileMock.mockReset(); testState.isMobile = false; @@ -506,6 +534,32 @@ describe('ReplyModal', () => { }); }); + it('shows the /pol/ flag selector for replies on a non-primary directory candidate board', async () => { + testState.directoryListsByCode.pol = { + directoryCode: 'pol', + features: { hasFlags: true }, + title: '/pol/ - Politically Incorrect', + boards: [{ address: 'politically-incorrect.bso' }, { address: 'nothing-is-beyond-our-reach.bso' }], + }; + + await renderReplyModal('/pol/thread/post-1', 'nothing-is-beyond-our-reach.bso'); + + const flagSelect = container.querySelector('select[aria-label="flag"]'); + + expect(flagSelect).toBeTruthy(); + expect(flagSelect?.value).toBe('country:auto'); + + await clickButtonByText('post'); + + expect(testState.publishReplyMock).toHaveBeenCalledWith({ + content: '>>42\nselected text', + challengeRequest: { + challengeAnswers: ['bitsocial-flags:5chan:flag:country:auto'], + }, + flairs: [{ type: 'country', code: 'auto', text: 'flag:country:auto' }], + }); + }); + it.each([ { boardPath: '/bant/thread/post-1', communityAddress: 'international-nsfw.bso' }, { boardPath: '/int/thread/post-1', communityAddress: 'international-sfw.bso' }, diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx index f5ed67c4..d2d00657 100644 --- a/src/components/reply-modal/reply-modal.tsx +++ b/src/components/reply-modal/reply-modal.tsx @@ -24,7 +24,8 @@ import useSelectedTextStore from '../../stores/use-selected-text-store'; import useReplyModalStore from '../../stores/use-reply-modal-store'; import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls'; import useMediaHostingStore from '../../stores/use-media-hosting-store'; -import { findDirectoryByAddress, useDirectories } from '../../hooks/use-directories'; +import { useDirectories } from '../../hooks/use-directories'; +import { useDirectoryEntry } from '../../hooks/use-directory-entry'; import usePublishReply from '../../hooks/use-publish-reply'; import useIsMobile from '../../hooks/use-is-mobile'; import { useFileUpload } from '../../hooks/use-file-upload'; @@ -63,7 +64,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa const isInModView = isModView(location.pathname); const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); const directories = useDirectories(); - const directoryEntry = findDirectoryByAddress(directories, communityAddress); + const directoryEntry = useDirectoryEntry(communityAddress, params?.boardIdentifier); const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true; const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname); const showOekakiControls = postOptionsDirectoryCode === 'i' || directoryEntry?.directoryCode === 'i'; diff --git a/src/hooks/__tests__/use-directory-entry.test.ts b/src/hooks/__tests__/use-directory-entry.test.ts new file mode 100644 index 00000000..4cdea1b5 --- /dev/null +++ b/src/hooks/__tests__/use-directory-entry.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { getDirectoryEntryForAddress } from '../use-directory-entry'; + +describe('getDirectoryEntryForAddress', () => { + it('inherits directory metadata for a non-primary directory candidate board', () => { + const entry = getDirectoryEntryForAddress({ + address: 'nothing-is-beyond-our-reach.bso', + directoryCodeHint: 'pol', + directories: [ + { + address: 'politically-incorrect.bso', + directoryCode: 'pol', + features: { hasFlags: true, requirePostLink: true }, + title: '/pol/ - Politically Incorrect', + }, + ], + list: { + directoryCode: 'pol', + title: '/pol/ - Politically Incorrect', + features: { hasFlags: true, requirePostLink: true }, + boards: [{ address: 'politically-incorrect.bso' }, { address: 'nothing-is-beyond-our-reach.bso', publicKey: 'pol-candidate-key' }], + }, + }); + + expect(entry).toMatchObject({ + address: 'nothing-is-beyond-our-reach.bso', + directoryCode: 'pol', + features: { hasFlags: true, requirePostLink: true }, + publicKey: 'pol-candidate-key', + title: '/pol/ - Politically Incorrect', + }); + }); +}); diff --git a/src/hooks/use-directory-entry.ts b/src/hooks/use-directory-entry.ts new file mode 100644 index 00000000..33cde2c7 --- /dev/null +++ b/src/hooks/use-directory-entry.ts @@ -0,0 +1,80 @@ +import { findDirectoryByAddress, type DirectoryCommunity, useDirectories } from './use-directories'; +import { getDirectoryCodeForBoardAddress, normalizeBoardAddress } from '../lib/utils/directory-list-lookup-utils'; +import { type DirectoryList, type DirectoryListBoard } from '../lib/utils/directory-list-utils'; +import { useDirectoryList } from './use-directory-list'; + +const DIRECTORY_CODE_HINT_PATTERN = /^[a-z0-9]+$/i; + +const getDirectoryCode = (directory: DirectoryCommunity | undefined): string | undefined => + directory?.directoryCode ?? directory?.title?.match(/^\/([^/]+)\//)?.[1]?.toLowerCase(); + +const findDirectoryByCode = (directories: DirectoryCommunity[], directoryCode: string | undefined): DirectoryCommunity | undefined => + directoryCode ? directories.find((directory) => getDirectoryCode(directory) === directoryCode) : undefined; + +const getDirectoryCodeFromHint = (directoryCodeHint: string | undefined, directories: DirectoryCommunity[]): string | undefined => { + if (!directoryCodeHint) return undefined; + return ( + getDirectoryCode(findDirectoryByCode(directories, directoryCodeHint)) ?? + (DIRECTORY_CODE_HINT_PATTERN.test(directoryCodeHint) ? directoryCodeHint.toLowerCase() : undefined) + ); +}; + +const isSameBoardAddress = (left: string | undefined, right: string | undefined): boolean => { + if (!left || !right) return false; + return left === right || normalizeBoardAddress(left) === normalizeBoardAddress(right); +}; + +const findBoardInDirectoryList = (list: DirectoryList | null, address: string | undefined): DirectoryListBoard | undefined => + address ? list?.boards.find((board) => isSameBoardAddress(board.address, address) || board.publicKey === address) : undefined; + +const getCandidateDirectoryEntry = ({ + address, + directory, + list, +}: { + address: string | undefined; + directory: DirectoryCommunity | undefined; + list: DirectoryList | null; +}): DirectoryCommunity | undefined => { + const board = findBoardInDirectoryList(list, address); + if (!board || !list) return undefined; + const features = list.features ?? directory?.features ?? board.features; + const nsfw = board.nsfw ?? directory?.nsfw; + + return { + address: board.address, + name: board.address, + ...(board.publicKey ? { publicKey: board.publicKey } : {}), + ...((list.title ?? directory?.title) ? { title: list.title ?? directory?.title } : {}), + directoryCode: list.directoryCode, + ...(features ? { features } : {}), + ...(nsfw !== undefined ? { nsfw } : {}), + }; +}; + +export const useDirectoryEntry = (address: string | undefined, directoryCodeHint?: string): DirectoryCommunity | undefined => { + const directories = useDirectories(); + const directEntry = findDirectoryByAddress(directories, address); + const directoryCode = getDirectoryCodeFromHint(directoryCodeHint, directories) ?? getDirectoryCode(directEntry) ?? getDirectoryCodeForBoardAddress(address); + const { list } = useDirectoryList(directoryCode); + + return getDirectoryEntryForAddress({ address, directories, directoryCodeHint: directoryCode, list }); +}; + +export const getDirectoryEntryForAddress = ({ + address, + directories, + directoryCodeHint, + list, +}: { + address: string | undefined; + directories: DirectoryCommunity[]; + directoryCodeHint?: string; + list?: DirectoryList | null; +}): DirectoryCommunity | undefined => { + const directEntry = findDirectoryByAddress(directories, address); + const hintedDirectoryCode = getDirectoryCodeFromHint(directoryCodeHint, directories); + const directoryCode = hintedDirectoryCode ?? getDirectoryCode(directEntry) ?? list?.directoryCode ?? getDirectoryCodeForBoardAddress(address); + const directory = findDirectoryByCode(directories, directoryCode); + return getCandidateDirectoryEntry({ address, directory, list: list ?? null }) ?? directEntry; +};