diff --git a/src/__tests__/app.test.tsx b/src/__tests__/app.test.tsx index d25e81e7..2f6fad6a 100644 --- a/src/__tests__/app.test.tsx +++ b/src/__tests__/app.test.tsx @@ -333,6 +333,10 @@ describe('App', () => { testState.account = { author: { address: '0x123' } }; testState.accountComments = {}; testState.accountCommunityAddresses = []; + testState.directories = [ + { address: 'music-posting.eth', title: '/mu/ - Music', nsfw: false }, + { address: 'tech-posting.eth', title: '/g/ - Technology', nsfw: false }, + ]; testState.isMobile = false; testState.isSpecialEnabled = false; testState.replyModalState = { diff --git a/src/hooks/__tests__/use-community-identifiers.test.tsx b/src/hooks/__tests__/use-community-identifiers.test.tsx new file mode 100644 index 00000000..4cc43a77 --- /dev/null +++ b/src/hooks/__tests__/use-community-identifiers.test.tsx @@ -0,0 +1,132 @@ +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 { useCommunityIdentifier, useCommunityIdentifiers } from '../use-community-identifiers'; + +(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; + +type TestDirectoryCommunity = { + address: string; + name?: string; + publicKey?: string; + directoryCode?: string; + title?: string; +}; + +const testState = vi.hoisted(() => ({ + address: undefined as string | undefined, + addresses: [] as Array, + directories: [] as TestDirectoryCommunity[], +})); + +vi.mock('../use-directories', () => ({ + useDirectories: () => testState.directories, + findDirectoryByAddress: (directories: TestDirectoryCommunity[], address: string | undefined) => + address + ? directories.find((directory) => + [directory.address, directory.name, directory.publicKey, directory.directoryCode, directory.title].some((identifier) => identifier === address), + ) + : undefined, +})); + +let latestIdentifier: ReturnType; +let latestIdentifiers: ReturnType; +let container: HTMLDivElement; +let root: Root; + +const SingleHarness = () => { + latestIdentifier = useCommunityIdentifier(testState.address); + return null; +}; + +const MultiHarness = () => { + latestIdentifiers = useCommunityIdentifiers(testState.addresses); + return null; +}; + +const renderHarness = async (element: React.ReactElement) => { + await act(async () => { + root.render(element); + }); +}; + +describe('useCommunityIdentifier', () => { + beforeEach(() => { + testState.address = undefined; + testState.addresses = []; + testState.directories = []; + latestIdentifier = undefined; + latestIdentifiers = []; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('uses vendored directory public keys before the main directory list has hydrated', async () => { + testState.address = 'animals-and-nature.bso'; + + await renderHarness(createElement(SingleHarness)); + + expect(latestIdentifier).toEqual({ + name: 'animals-and-nature.bso', + publicKey: '12D3KooWSpKszPM2c17KBgbnoRrkWPCHJosGGFg3bKnzarYhHeSc', + }); + }); + + it('uses candidate public keys for non-primary boards in a directory list', async () => { + testState.address = 'bizraelis.bso'; + testState.directories = [ + { + address: 'business-and-finance.bso', + directoryCode: 'biz', + publicKey: '12D3KooWNMybS8JqELi38ZBX897PrjWbCrGoMKfw3bgoqzC2n1Dh', + title: '/biz/ - Business & Finance', + }, + ]; + + await renderHarness(createElement(SingleHarness)); + + expect(latestIdentifier).toEqual({ + name: 'bizraelis.bso', + publicKey: '12D3KooWR7nTdKZqZ1twGWMfVsXYDGp1XAKUrnYznKP651jFrizE', + }); + }); + + it('keeps unlisted domain routes as direct community names', async () => { + testState.address = 'unlisted-board.bso'; + + await renderHarness(createElement(SingleHarness)); + + expect(latestIdentifier).toEqual({ name: 'unlisted-board.bso' }); + }); + + it('keeps raw IPNS route identifiers as public keys', async () => { + testState.address = '12D3KooWExamplePublicKey'; + + await renderHarness(createElement(SingleHarness)); + + expect(latestIdentifier).toEqual({ publicKey: '12D3KooWExamplePublicKey' }); + }); + + it('applies the same resolution to feed community arrays', async () => { + testState.addresses = ['bizraelis.bso', 'unlisted-board.bso', undefined, '12D3KooWExamplePublicKey']; + + await renderHarness(createElement(MultiHarness)); + + expect(latestIdentifiers).toEqual([ + { + name: 'bizraelis.bso', + publicKey: '12D3KooWR7nTdKZqZ1twGWMfVsXYDGp1XAKUrnYznKP651jFrizE', + }, + { name: 'unlisted-board.bso' }, + { publicKey: '12D3KooWExamplePublicKey' }, + ]); + }); +}); diff --git a/src/hooks/use-community-identifiers.ts b/src/hooks/use-community-identifiers.ts index 26957391..99efb45c 100644 --- a/src/hooks/use-community-identifiers.ts +++ b/src/hooks/use-community-identifiers.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import type { CommunityIdentifier } from '@bitsocial/bitsocial-react-hooks'; import { findDirectoryByAddress, type DirectoryCommunity, useDirectories } from './use-directories'; +import { getDirectoryCandidateBoardByAddress } from '../lib/utils/directory-list-lookup-utils'; const isLikelyCommunityName = (value: string) => value.includes('.'); @@ -10,20 +11,24 @@ const getCommunityIdentifier = (communityAddress: string | undefined, directorie } const directory = findDirectoryByAddress(directories, communityAddress); - if (directory?.name && directory.publicKey) { + const directoryCandidate = getDirectoryCandidateBoardByAddress(communityAddress); + const name = directory?.name ?? directory?.address ?? directoryCandidate?.address; + const publicKey = directory?.publicKey ?? directoryCandidate?.publicKey; + + if (name && publicKey) { return { - name: directory.name, - publicKey: directory.publicKey, + name, + publicKey, }; } - if (directory?.publicKey) { + if (publicKey) { return { - publicKey: directory.publicKey, + publicKey, }; } - if (directory?.name) { + if (name) { return { - name: directory.name, + name, }; } diff --git a/src/hooks/use-directories.ts b/src/hooks/use-directories.ts index 4a442936..148d2267 100644 --- a/src/hooks/use-directories.ts +++ b/src/hooks/use-directories.ts @@ -13,8 +13,10 @@ import { type DirectoryCommunity, type DirectoryList, } from '../lib/utils/directory-list-utils'; +import { normalizeBoardAddress } from '../lib/utils/directory-list-lookup-utils'; export type { DirectoriesData, DirectoryCommunity } from '../lib/utils/directory-list-utils'; +export { normalizeBoardAddress }; interface DirectoriesMetadata { title: string; @@ -42,8 +44,6 @@ let cacheMetadata: DirectoriesMetadata | null = null; let inFlightGitHubFetch: Promise | null = null; let lastSuccessfulGitHubFetchAt: number | null = null; let lastGitHubFetchAttemptAt: number | null = null; -const DIRECTORY_ALIAS_SUFFIXES = ['.bso', '.eth'] as const; - // Exposed for deterministic unit tests around module-level cache state. export const __resetDirectoriesModuleStateForTests = () => { cacheCommunities = null; @@ -120,16 +120,6 @@ const adaptV2Directories = (value: Record): DirectoryCommunity[ return dedupeCommunities(communities); }; -export const normalizeBoardAddress = (address: string): string => { - for (const suffix of DIRECTORY_ALIAS_SUFFIXES) { - if (address.endsWith(suffix)) { - return address.slice(0, -suffix.length); - } - } - - return address; -}; - export const findDirectoryByAddress = (directories: DirectoryCommunity[], address: string | undefined): DirectoryCommunity | undefined => { if (!address) { return undefined; diff --git a/src/hooks/use-directory-list.ts b/src/hooks/use-directory-list.ts index 9df4eb71..2640b188 100644 --- a/src/hooks/use-directory-list.ts +++ b/src/hooks/use-directory-list.ts @@ -1,9 +1,10 @@ import { useEffect, useMemo, useState } from 'react'; -import { type DirectoryCommunity, normalizeBoardAddress, useDirectories } from './use-directories'; +import { type DirectoryCommunity, useDirectories } from './use-directories'; import { type DirectoryList, type DirectoryListBoard, normalizeDirectoryList, sortDirectoryBoardsByRank } from '../lib/utils/directory-list-utils'; -import directoryListsData from '../data/5chan-directory-lists.json'; +import { getDirectoryCodeForBoardAddress, getVendoredDirectoryList } from '../lib/utils/directory-list-lookup-utils'; export type { DirectoryListBoard } from '../lib/utils/directory-list-utils'; +export { getDirectoryCodeForBoardAddress }; interface DirectoryListState { list: DirectoryList | null; @@ -29,33 +30,6 @@ const moduleCaches = new Map(); const inFlightFetches = new Map>(); const lastFetchSuccessAt = new Map(); const lastFetchAttemptAt = new Map(); -let vendoredDirectoryListsCache: DirectoryList[] | null = null; - -const getVendoredDirectoryLists = (): DirectoryList[] => { - if (vendoredDirectoryListsCache) return vendoredDirectoryListsCache; - - const directories = Array.isArray(directoryListsData.directories) ? directoryListsData.directories : []; - vendoredDirectoryListsCache = directories.flatMap((directory) => { - const directoryCode = typeof directory.directoryCode === 'string' ? directory.directoryCode : undefined; - if (!directoryCode) return []; - const normalized = normalizeDirectoryList(directory, directoryCode); - return normalized ? [normalized] : []; - }); - - return vendoredDirectoryListsCache; -}; - -const getVendoredDirectoryList = (directoryCode: string): DirectoryList | null => - getVendoredDirectoryLists().find((directory) => directory.directoryCode === directoryCode) ?? null; - -export const getDirectoryCodeForBoardAddress = (address: string | undefined): string | undefined => { - if (!address) return undefined; - - const normalizedAddress = normalizeBoardAddress(address); - return getVendoredDirectoryLists().find((directory) => - directory.boards.some((board) => normalizeBoardAddress(board.address) === normalizedAddress || board.publicKey === address), - )?.directoryCode; -}; const synthesizeFromMainDirectory = (directoryCode: string, directories: DirectoryCommunity[]): DirectoryList | null => { const match = directories.find((community) => community.directoryCode === directoryCode); diff --git a/src/lib/utils/directory-list-lookup-utils.ts b/src/lib/utils/directory-list-lookup-utils.ts new file mode 100644 index 00000000..500264aa --- /dev/null +++ b/src/lib/utils/directory-list-lookup-utils.ts @@ -0,0 +1,55 @@ +import directoryListsData from '../../data/5chan-directory-lists.json'; +import { normalizeDirectoryList, type DirectoryList, type DirectoryListBoard } from './directory-list-utils'; + +const DIRECTORY_ALIAS_SUFFIXES = ['.bso', '.eth'] as const; + +let vendoredDirectoryListsCache: DirectoryList[] | null = null; + +export const normalizeBoardAddress = (address: string): string => { + for (const suffix of DIRECTORY_ALIAS_SUFFIXES) { + if (address.endsWith(suffix)) { + return address.slice(0, -suffix.length); + } + } + + return address; +}; + +export const getVendoredDirectoryLists = (): DirectoryList[] => { + if (vendoredDirectoryListsCache) return vendoredDirectoryListsCache; + + const directories = Array.isArray(directoryListsData.directories) ? directoryListsData.directories : []; + vendoredDirectoryListsCache = directories.flatMap((directory) => { + const directoryCode = typeof directory.directoryCode === 'string' ? directory.directoryCode : undefined; + if (!directoryCode) return []; + const normalized = normalizeDirectoryList(directory, directoryCode); + return normalized ? [normalized] : []; + }); + + return vendoredDirectoryListsCache; +}; + +export const getVendoredDirectoryList = (directoryCode: string): DirectoryList | null => + getVendoredDirectoryLists().find((directory) => directory.directoryCode === directoryCode) ?? null; + +const findBoardInList = (list: DirectoryList, address: string): DirectoryListBoard | undefined => { + const normalizedAddress = normalizeBoardAddress(address); + return list.boards.find((board) => normalizeBoardAddress(board.address) === normalizedAddress || board.publicKey === address); +}; + +export const getDirectoryCandidateBoardByAddress = (address: string | undefined): DirectoryListBoard | undefined => { + if (!address) return undefined; + + for (const directory of getVendoredDirectoryLists()) { + const board = findBoardInList(directory, address); + if (board) return board; + } + + return undefined; +}; + +export const getDirectoryCodeForBoardAddress = (address: string | undefined): string | undefined => { + if (!address) return undefined; + + return getVendoredDirectoryLists().find((directory) => findBoardInList(directory, address))?.directoryCode; +}; diff --git a/src/views/board/__tests__/board.test.tsx b/src/views/board/__tests__/board.test.tsx index 76749414..4aaf5bf3 100644 --- a/src/views/board/__tests__/board.test.tsx +++ b/src/views/board/__tests__/board.test.tsx @@ -396,7 +396,12 @@ describe('Board', () => { routePath: '/:boardIdentifier/*', }); - expect(testState.feedOptionsCalls.map((call) => call.communities)).toContainEqual([{ name: 'bizraelis.bso' }]); + expect(testState.feedOptionsCalls.map((call) => call.communities)).toContainEqual([ + { + name: 'bizraelis.bso', + publicKey: '12D3KooWR7nTdKZqZ1twGWMfVsXYDGp1XAKUrnYznKP651jFrizE', + }, + ]); }); it('renders the current page feed, inserts recent account comments, and wires footer actions', async () => {