mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(home): use directory fallbacks for stats (#1133)
This commit is contained in:
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user