fix(home): use directory fallbacks for stats (#1133)

This commit is contained in:
Tommaso Casaburi
2026-05-20 00:33:55 +07:00
committed by GitHub
parent d21f91a171
commit 6a7df90129
3 changed files with 229 additions and 15 deletions
+91
View File
@@ -27,6 +27,12 @@ interface DirectoryListState {
error: Error | null;
}
interface DirectoryListsState {
listsByCode: Record<string, DirectoryList | null>;
loadingByCode: Record<string, boolean>;
errorsByCode: Record<string, Error | null>;
}
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<DirectoryListsState>({
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<DirectoryListsState>(
(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.
*/
+65 -3
View File
@@ -10,8 +10,12 @@ const act = (React as { act?: (cb: () => void | Promise<void>) => 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<string, { boards: Array<{ address: string; score: number; managedByDevs: boolean; addedAt?: number }> }>,
loadingStartTimestamps: [] as number[],
nowSeconds: 1_704_067_210,
navigateMock: vi.fn(),
communities: {} as Record<string, unknown>,
communityStats: {} as Record<string, { allPostCount?: number; weekActiveUserCount?: number }>,
@@ -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<typeof import('../../../hooks/use-directory-list')>('../../../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 },
+73 -12
View File
@@ -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}</> : <LoadingEllipsis string={loadingLabel} />);
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) => (
<CommunityStatsCollector key={address} communityAddress={address} />
))}
<div className={styles.box}>
@@ -225,7 +286,7 @@ const Home = () => {
<InfoBox />
<BoardsList multisub={directories} />
<PopularThreadsBox directories={directories} directoryAddresses={directoryAddresses} />
<Stats directoryAddresses={directoryAddresses} />
<Stats directories={directories} />
<Footer />
</div>
</>