From 6a7df90129040a53644d5a273b5ca474324074d3 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Wed, 20 May 2026 00:33:55 +0700 Subject: [PATCH] fix(home): use directory fallbacks for stats (#1133) --- src/hooks/use-directory-list.ts | 91 ++++++++++++++++++++++++++ src/views/home/__tests__/home.test.tsx | 68 ++++++++++++++++++- src/views/home/home.tsx | 85 ++++++++++++++++++++---- 3 files changed, 229 insertions(+), 15 deletions(-) diff --git a/src/hooks/use-directory-list.ts b/src/hooks/use-directory-list.ts index 08566d30..6a31e31f 100644 --- a/src/hooks/use-directory-list.ts +++ b/src/hooks/use-directory-list.ts @@ -27,6 +27,12 @@ interface DirectoryListState { error: Error | null; } +interface DirectoryListsState { + listsByCode: Record; + loadingByCode: Record; + errorsByCode: Record; +} + const GITHUB_URL_TEMPLATE = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-{code}-directory.json'; const LOCALSTORAGE_KEY_PREFIX = '5chan-directory-list-cache:'; const LOCALSTORAGE_TIMESTAMP_KEY_PREFIX = '5chan-directory-list-cache-timestamp:'; @@ -274,6 +280,91 @@ export const useDirectoryList = (directoryCode: string | undefined): DirectoryLi return state; }; +export const useDirectoryLists = (directoryCodes: string[] | undefined): DirectoryListsState => { + const directories = useDirectories(); + const directoryCodesKey = useMemo(() => [...new Set(directoryCodes ?? [])].join('\0'), [directoryCodes]); + + const fallbackByCode = useMemo(() => { + const normalizedDirectoryCodes = directoryCodesKey ? directoryCodesKey.split('\0') : []; + return Object.fromEntries(normalizedDirectoryCodes.map((directoryCode) => [directoryCode, synthesizeFromMainDirectory(directoryCode, directories)])) as Record< + string, + DirectoryList | null + >; + }, [directories, directoryCodesKey]); + + const [state, setState] = useState({ + listsByCode: {}, + loadingByCode: {}, + errorsByCode: {}, + }); + + useEffect(() => { + const normalizedDirectoryCodes = directoryCodesKey ? directoryCodesKey.split('\0') : []; + if (normalizedDirectoryCodes.length === 0) { + setState({ + listsByCode: {}, + loadingByCode: {}, + errorsByCode: {}, + }); + return; + } + + let isMounted = true; + const initialState = normalizedDirectoryCodes.reduce( + (acc, directoryCode) => { + const cached = moduleCaches.get(directoryCode); + const local = cached ? null : getFromLocalStorage(directoryCode); + const list = cached ?? local ?? fallbackByCode[directoryCode] ?? null; + + if (local) { + moduleCaches.set(directoryCode, local); + } + + acc.listsByCode[directoryCode] = list; + acc.loadingByCode[directoryCode] = !cached && !local; + acc.errorsByCode[directoryCode] = null; + return acc; + }, + { + listsByCode: {}, + loadingByCode: {}, + errorsByCode: {}, + }, + ); + + setState(initialState); + + normalizedDirectoryCodes.forEach((directoryCode) => { + fetchDirectoryListDeduped(directoryCode) + .then((fetched) => { + if (!isMounted) return; + const list = fetched ?? moduleCaches.get(directoryCode) ?? fallbackByCode[directoryCode] ?? null; + setState((prev) => ({ + listsByCode: { ...prev.listsByCode, [directoryCode]: list }, + loadingByCode: { ...prev.loadingByCode, [directoryCode]: false }, + errorsByCode: { ...prev.errorsByCode, [directoryCode]: null }, + })); + }) + .catch((error) => { + console.warn(`Failed to fetch directory list "${directoryCode}":`, error); + if (!isMounted) return; + const cachedAfter = moduleCaches.get(directoryCode); + setState((prev) => ({ + listsByCode: { ...prev.listsByCode, [directoryCode]: cachedAfter ?? fallbackByCode[directoryCode] ?? null }, + loadingByCode: { ...prev.loadingByCode, [directoryCode]: false }, + errorsByCode: { ...prev.errorsByCode, [directoryCode]: error instanceof Error ? error : new Error(String(error)) }, + })); + }); + }); + + return () => { + isMounted = false; + }; + }, [directoryCodesKey, fallbackByCode]); + + return state; +}; + /** * Sort boards by score (desc). Ties break in favor of `managedByDevs`, then `addedAt` asc. */ diff --git a/src/views/home/__tests__/home.test.tsx b/src/views/home/__tests__/home.test.tsx index 7f23bca8..be06cdea 100644 --- a/src/views/home/__tests__/home.test.tsx +++ b/src/views/home/__tests__/home.test.tsx @@ -10,8 +10,12 @@ const act = (React as { act?: (cb: () => void | Promise) => void | Promise const testState = vi.hoisted(() => ({ closeDirectoryModalMock: vi.fn(), - directories: [] as Array<{ address: string; title?: string }>, + directories: [] as Array<{ address: string; title?: string; directoryCode?: string }>, directoryAddresses: [] as string[], + directoryListCodes: [] as string[], + directoryListsByCode: {} as Record }>, + loadingStartTimestamps: [] as number[], + nowSeconds: 1_704_067_210, navigateMock: vi.fn(), communities: {} as Record, communityStats: {} as Record, @@ -43,12 +47,35 @@ vi.mock('../../../hooks/use-directories', () => ({ directories.find((entry) => entry.address === address || entry.directoryCode === address || entry.title === address), })); +vi.mock('../../../hooks/use-directory-list', async () => { + const actual = await vi.importActual('../../../hooks/use-directory-list'); + return { + ...actual, + useDirectoryLists: (directoryCodes: string[] | undefined) => { + testState.directoryListCodes = directoryCodes ?? []; + return { + listsByCode: testState.directoryListsByCode, + loadingByCode: {}, + errorsByCode: {}, + }; + }, + }; +}); + vi.mock('../../../hooks/use-communities-stats', () => ({ CommunityStatsCollector: ({ communityAddress }: { communityAddress: string }) => createElement('div', { 'data-testid': 'stats-collector', 'data-address': communityAddress }), useCommunitiesStatsStore: (selector: (state: { communityStats: typeof testState.communityStats }) => unknown) => selector({ communityStats: testState.communityStats }), })); +vi.mock('../../../stores/use-communities-loading-start-timestamps-store', () => ({ + default: () => testState.loadingStartTimestamps, +})); + +vi.mock('../../../hooks/use-now-seconds', () => ({ + useNowSeconds: () => testState.nowSeconds, +})); + vi.mock('../../../components/loading-ellipsis', () => ({ default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string), })); @@ -96,10 +123,14 @@ describe('Home', () => { testState.closeDirectoryModalMock.mockReset(); testState.navigateMock.mockReset(); testState.directories = [ - { address: 'music-posting.eth', title: '/mu/ - Music' }, - { address: 'tech-posting.eth', title: '/g/ - Technology' }, + { address: 'music-posting.eth', title: '/mu/ - Music', directoryCode: 'mu' }, + { address: 'tech-posting.eth', title: '/g/ - Technology', directoryCode: 'g' }, ]; testState.directoryAddresses = ['music-posting.eth', 'tech-posting.eth']; + testState.directoryListCodes = []; + testState.directoryListsByCode = {}; + testState.loadingStartTimestamps = []; + testState.nowSeconds = 1_704_067_210; testState.communities = { 'music-posting.eth': { address: 'music-posting.eth' }, 'tech-posting.eth': { address: 'tech-posting.eth' }, @@ -151,6 +182,37 @@ describe('Home', () => { expect(container.textContent).not.toContain('boards_tracked 1'); }); + it('uses a ranked directory fallback board when the default board stats stay unresolved', () => { + testState.directories = [ + { address: 'business-and-finance.bso', title: '/biz/ - Business & Finance', directoryCode: 'biz' }, + { address: 'tech-posting.eth', title: '/g/ - Technology', directoryCode: 'g' }, + ]; + testState.directoryAddresses = ['business-and-finance.bso', 'tech-posting.eth']; + testState.loadingStartTimestamps = [1_704_067_170, 1_704_067_170]; + testState.directoryListsByCode = { + biz: { + boards: [ + { address: 'business-and-finance.bso', score: 100, managedByDevs: true }, + { address: 'backup-business.bso', score: 10, managedByDevs: false }, + ], + }, + }; + testState.communityStats = { + 'backup-business.bso': { allPostCount: 11, weekActiveUserCount: 3 }, + 'tech-posting.eth': { allPostCount: 7, weekActiveUserCount: 4 }, + }; + + renderHome(); + + const collectorAddresses = Array.from(container.querySelectorAll('[data-testid="stats-collector"]')).map((collector) => collector.getAttribute('data-address')); + expect(testState.directoryListCodes).toEqual(['biz']); + expect(collectorAddresses).toEqual(['business-and-finance.bso', 'tech-posting.eth', 'backup-business.bso']); + expect(container.querySelectorAll('[data-testid="loading-ellipsis"]')).toHaveLength(0); + expect(container.textContent).toContain('total_posts 18'); + expect(container.textContent).toContain('current_users 7'); + expect(container.textContent).toContain('boards_tracked 2'); + }); + it('shows zero totals after every directory has loaded zero-count stats', () => { testState.communityStats = { 'music-posting.eth': { allPostCount: 0, weekActiveUserCount: 0 }, diff --git a/src/views/home/home.tsx b/src/views/home/home.tsx index 3dda7c3f..d5fa59e2 100644 --- a/src/views/home/home.tsx +++ b/src/views/home/home.tsx @@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef, FormEvent } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; import styles from './home.module.css'; -import { useDirectories, useDirectoryAddresses } from '../../hooks/use-directories'; +import { type DirectoryCommunity, useDirectories, useDirectoryAddresses } from '../../hooks/use-directories'; +import { sortDirectoryBoardsByRank, useDirectoryLists } from '../../hooks/use-directory-list'; import { CommunityStatsCollector, useCommunitiesStatsStore } from '../../hooks/use-communities-stats'; import PopularThreadsBox from './popular-threads-box'; import BoardsList from './boards-list'; @@ -11,9 +12,11 @@ import LoadingEllipsis from '../../components/loading-ellipsis'; import useDirectoryModalStore from '../../stores/use-directory-modal-store'; import DisclaimerModal from '../../components/disclaimer-modal'; import DirectoryModal from '../../components/directory-modal'; -import { getBoardPath } from '../../lib/utils/route-utils'; +import { extractDirectoryFromTitle, getBoardPath } from '../../lib/utils/route-utils'; import { isWebRuntime } from '../../lib/media-hosting/show-upload-controls'; import lowerCase from 'lodash/lowerCase'; +import useCommunitiesLoadingStartTimestamps from '../../stores/use-communities-loading-start-timestamps-store'; +import { useNowSeconds } from '../../hooks/use-now-seconds'; // https://github.com/bitsocialnet/lists/blob/master/5chan-directories.json @@ -91,22 +94,74 @@ interface StatValueProps { value: number; } +type HomepageStats = { + allPostCount?: number; + weekActiveUserCount?: number; +}; + const StatValue = ({ isLoaded, loadingLabel, value }: StatValueProps) => (isLoaded ? <>{value} : ); -const Stats = ({ directoryAddresses }: { directoryAddresses: string[] }) => { +const STATS_DIRECTORY_FALLBACK_DELAY_SECONDS = 30; + +const getDirectoryCode = (directory: DirectoryCommunity): string | null => directory.directoryCode ?? extractDirectoryFromTitle(directory.title ?? ''); + +const getUniqueAddresses = (addresses: string[]): string[] => [...new Set(addresses.filter((address) => address.length > 0))]; + +const hasLoadedStats = (stat: HomepageStats | undefined): stat is HomepageStats & { allPostCount: number } => stat?.allPostCount !== undefined; + +const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => { const { t } = useTranslation(); const communitiesStats = useCommunitiesStatsStore((state) => state.communityStats); + const defaultDirectoryAddresses = useMemo(() => directories.map((directory) => directory.address), [directories]); + const loadingStartTimestamps = useCommunitiesLoadingStartTimestamps(defaultDirectoryAddresses); + const nowSeconds = useNowSeconds(defaultDirectoryAddresses.length > 0); - const { totalPosts, currentUsers, boardsTracked, allDirectoryStatsLoaded } = useMemo(() => { + const fallbackDirectoryCodes = useMemo( + () => + directories.flatMap((directory, index) => { + const defaultStats = communitiesStats[directory.address]; + if (hasLoadedStats(defaultStats)) { + return []; + } + + const loadingStartTimestamp = loadingStartTimestamps[index]; + if (!loadingStartTimestamp || nowSeconds - loadingStartTimestamp < STATS_DIRECTORY_FALLBACK_DELAY_SECONDS) { + return []; + } + + const directoryCode = getDirectoryCode(directory); + return directoryCode ? [directoryCode] : []; + }), + [communitiesStats, directories, loadingStartTimestamps, nowSeconds], + ); + + const { listsByCode } = useDirectoryLists(fallbackDirectoryCodes); + + const { collectorAddresses, totalPosts, currentUsers, boardsTracked, allDirectoryStatsLoaded } = useMemo(() => { + const collectorAddressSet = new Set(defaultDirectoryAddresses); let totalPosts = 0; let currentUsers = 0; let boardsTracked = 0; - let allDirectoryStatsLoaded = directoryAddresses.length > 0; + let allDirectoryStatsLoaded = directories.length > 0; - for (const address of directoryAddresses) { - const stat = communitiesStats[address]; + for (const directory of directories) { + const directoryCode = getDirectoryCode(directory); + const directoryList = directoryCode ? listsByCode[directoryCode] : null; + const rankedAddresses = directoryList ? sortDirectoryBoardsByRank(directoryList.boards).map((board) => board.address) : [directory.address]; + const candidateAddresses = getUniqueAddresses(rankedAddresses.length > 0 ? rankedAddresses : [directory.address]); - if (!stat || stat.allPostCount === undefined) { + candidateAddresses.forEach((address) => collectorAddressSet.add(address)); + + let stat: HomepageStats | undefined; + for (const address of candidateAddresses) { + const candidateStats = communitiesStats[address]; + if (hasLoadedStats(candidateStats)) { + stat = candidateStats; + break; + } + } + + if (!hasLoadedStats(stat)) { allDirectoryStatsLoaded = false; continue; } @@ -116,15 +171,21 @@ const Stats = ({ directoryAddresses }: { directoryAddresses: string[] }) => { boardsTracked++; } - return { totalPosts, currentUsers, boardsTracked, allDirectoryStatsLoaded }; - }, [communitiesStats, directoryAddresses]); + return { + collectorAddresses: [...collectorAddressSet], + totalPosts, + currentUsers, + boardsTracked, + allDirectoryStatsLoaded, + }; + }, [communitiesStats, defaultDirectoryAddresses, directories, listsByCode]); const loadingLabel = t('loading'); return ( <> {/* Render collectors to fetch stats for each community */} - {directoryAddresses.map((address) => ( + {collectorAddresses.map((address) => ( ))}
@@ -225,7 +286,7 @@ const Home = () => { - +