fix(directory): load per-directory list files (#1134)

Merge board directories from the new bitsocialnet/lists per-directory layout on the client side.

Update vendored fallback data, sync tooling, directory references, and tests.

Treat failed board stat fetches as complete on the home page so stats do not stay loading forever.
This commit is contained in:
Tommaso Casaburi
2026-05-20 23:31:58 +07:00
committed by GitHub
parent c8edc92983
commit 536d9ac0b8
21 changed files with 1671 additions and 938 deletions
@@ -17,7 +17,6 @@ const testState = vi.hoisted(() => ({
{
address: 'anime-and-manga.bso',
score: 12,
managedByDevs: false,
},
],
directories: [
@@ -157,7 +156,6 @@ const renderDirectory = async () => {
const createDirectoryBoard = (address: string, score = 12) => ({
address,
score,
managedByDevs: false,
});
const createCommunity = (address: string, updatedAt = testState.nowSeconds - 60) => ({
+2 -2
View File
@@ -145,7 +145,7 @@ const DirectoryRow = ({ board, nowSeconds, rank, onVote }: DirectoryRowProps) =>
)}
</td>
<td className={styles.scoreCell}>
<span className={styles.scoreValue}>{board.score}</span>
<span className={styles.scoreValue}>{board.score ?? DIRECTORY_STATUS_UNAVAILABLE_MARKER}</span>
</td>
<td className={styles.actionsCell}>
[
@@ -166,7 +166,7 @@ const DirectoryRow = ({ board, nowSeconds, rank, onVote }: DirectoryRowProps) =>
);
};
const getRepoEditUrl = (directoryCode: string) => `https://github.com/bitsocialnet/lists/edit/master/5chan-${directoryCode}-directory.json`;
const getRepoEditUrl = (directoryCode: string) => `https://github.com/bitsocialnet/lists/edit/master/5chan-directories/5chan-${directoryCode}-directory.json`;
const Directory = () => {
const { t } = useTranslation();
+6 -6
View File
@@ -70,9 +70,9 @@ const FAQ_SECTIONS: FAQSection[] = [
answer: (
<>
Directories are short paths such as <code>/g/</code>, <code>/biz/</code>, or <code>/mu/</code> that point to featured boards. Today they are handpicked
through pull requests to{' '}
<a href='https://github.com/bitsocialnet/lists/blob/master/5chan-directories.json' {...externalLinkProps}>
5chan-directories.json
through pull requests to the{' '}
<a href='https://github.com/bitsocialnet/lists/tree/master/5chan-directories' {...externalLinkProps}>
5chan directory files
</a>{' '}
until directory voting is available. A board does not need a directory slot to exist or be reachable.
</>
@@ -330,9 +330,9 @@ const FAQ_SECTIONS: FAQSection[] = [
question: 'How do I get my board into a directory?',
answer: (
<>
Open a pull request against{' '}
<a href='https://github.com/bitsocialnet/lists/blob/master/5chan-directories.json' {...externalLinkProps}>
5chan-directories.json
Open a pull request against the relevant file in the{' '}
<a href='https://github.com/bitsocialnet/lists/tree/master/5chan-directories' {...externalLinkProps}>
5chan-directories folder
</a>{' '}
with the board title, address, and NSFW status. Current expectations include high uptime, active use, relevant topic fit, and responsible moderation. Future
directory voting is planned for 5chan Pass holders.
+15 -1
View File
@@ -18,7 +18,7 @@ const testState = vi.hoisted(() => ({
nowSeconds: 1_704_067_210,
navigateMock: vi.fn(),
communities: {} as Record<string, unknown>,
communityStats: {} as Record<string, { allPostCount?: number; weekActiveUserCount?: number }>,
communityStats: {} as Record<string, { allPostCount?: number; weekActiveUserCount?: number; state?: string }>,
}));
vi.mock('react-i18next', () => ({
@@ -227,6 +227,20 @@ describe('Home', () => {
expect(container.textContent).toContain('boards_tracked 2');
});
it('does not keep aggregate stats loading after a directory stats fetch fails', () => {
testState.communityStats = {
'music-posting.eth': { allPostCount: 5, weekActiveUserCount: 2 },
'tech-posting.eth': { state: 'failed' },
};
renderHome();
expect(container.querySelectorAll('[data-testid="loading-ellipsis"]')).toHaveLength(0);
expect(container.textContent).toContain('total_posts 5');
expect(container.textContent).toContain('current_users 2');
expect(container.textContent).toContain('boards_tracked 2');
});
it('navigates to the canonical board path when the search form is submitted', async () => {
renderHome();
+15 -2
View File
@@ -18,7 +18,7 @@ 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
// https://github.com/bitsocialnet/lists/tree/master/5chan-directories
const SearchBar = () => {
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -97,6 +97,7 @@ interface StatValueProps {
type HomepageStats = {
allPostCount?: number;
weekActiveUserCount?: number;
state?: string;
};
const StatValue = ({ isLoaded, loadingLabel, value }: StatValueProps) => (isLoaded ? <>{value}</> : <LoadingEllipsis string={loadingLabel} />);
@@ -108,6 +109,7 @@ const getDirectoryCode = (directory: DirectoryCommunity): string | null => direc
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 hasFailedStats = (stat: HomepageStats | undefined): boolean => stat?.state === 'failed';
const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
const { t } = useTranslation();
@@ -153,15 +155,26 @@ const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
candidateAddresses.forEach((address) => collectorAddressSet.add(address));
let stat: HomepageStats | undefined;
let failedStat: HomepageStats | undefined;
let allCandidateStatsResolved = candidateAddresses.length > 0;
for (const address of candidateAddresses) {
const candidateStats = communitiesStats[address];
if (hasLoadedStats(candidateStats)) {
stat = candidateStats;
break;
}
if (hasFailedStats(candidateStats)) {
failedStat ??= candidateStats;
continue;
}
allCandidateStatsResolved = false;
}
if (!hasLoadedStats(stat)) {
if (!stat && allCandidateStatsResolved) {
stat = failedStat;
}
if (!stat || (!hasLoadedStats(stat) && !hasFailedStats(stat))) {
allDirectoryStatsLoaded = false;
continue;
}
@@ -79,6 +79,7 @@ vi.mock('../../box-modal', () => ({
const directories = [
{ address: 'music-posting.eth', title: '/mu/ - Music' },
{ address: 'tech-posting.eth', title: '/g/ - Technology' },
{ address: 'tv-posting.eth', title: '/tv/ - Television & Film Directory' },
];
let container: HTMLDivElement;
@@ -158,6 +159,24 @@ describe('PopularThreadsBox', () => {
expect(container.querySelector('.spoilertext')).toBeNull();
});
it('does not show a stale directory suffix in board titles', () => {
testState.popularPosts = [
{
cid: 'thread-tv',
communityAddress: 'tv-posting.eth',
content: 'thread content',
link: 'https://cdn.example/thread-tv.jpg',
thumbnailUrl: 'https://cdn.example/thread-tv-thumb.jpg',
title: '',
},
];
renderPopularThreadsBox();
expect(container.textContent).toContain('Television & Film');
expect(container.textContent).not.toContain('Television & Film Directory');
});
it('subscribes to feed state only while popular threads are loading', () => {
testState.isLoading = true;
testState.popularPosts = [];
@@ -165,6 +184,6 @@ describe('PopularThreadsBox', () => {
renderPopularThreadsBox();
expect(container.textContent).toContain('Downloading boards');
expect(vi.mocked(useFeedStateString)).toHaveBeenCalledWith(['music-posting.eth', 'tech-posting.eth']);
expect(vi.mocked(useFeedStateString)).toHaveBeenCalledWith(['music-posting.eth', 'tech-posting.eth', 'tv-posting.eth']);
});
});
@@ -64,6 +64,8 @@ const PopularThreadCard = memo(
(prevProps, nextProps) => prevProps.post?.cid === nextProps.post?.cid && prevProps.boardTitle === nextProps.boardTitle && prevProps.boardPath === nextProps.boardPath,
);
const getPopularThreadBoardTitle = (directoryTitle: string | undefined): string => directoryTitle?.replace(/^\/[^/]+\/\s*-\s*/, '').replace(/\s+Directory$/, '') || '';
const PopularThreadsBox = ({ directories, directoryAddresses }: { directories: DirectoryCommunity[]; directoryAddresses: string[] }) => {
const { t } = useTranslation();
const showWorksafeContentOnly = usePopularThreadsOptionsStore((state) => state.showWorksafeContentOnly);
@@ -103,7 +105,7 @@ const PopularThreadsBox = ({ directories, directoryAddresses }: { directories: D
popularPosts.map((post: Comment) => {
const communityAddress = getCommentCommunityAddress(post);
const directoryEntry = findDirectoryByAddress(directories, communityAddress);
const boardTitle = directoryEntry?.title?.replace(/^\/[^/]+\/\s*-\s*/, '') || '';
const boardTitle = getPopularThreadBoardTitle(directoryEntry?.title);
const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : '';
return <PopularThreadCard key={post.cid} post={post} boardTitle={boardTitle} boardPath={boardPath} />;
})