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
+143 -84
View File
@@ -78,6 +78,57 @@ const createFetchResponse = (body: unknown, ok = true, status = 200) => ({
json: vi.fn().mockResolvedValue(body),
});
const getDirectoryCodeFromUrl = (url: unknown): string => String(url).match(/5chan-([a-z0-9]+)-directory\.json/)?.[1] ?? 'unknown';
const isDefaultsUrl = (url: unknown): boolean => String(url).endsWith('/5chan-directories-defaults.json');
const REMOTE_DIRECTORY_CODES = ['a', 'mu', 'biz'];
type RemoteDirectoryOverride = {
address?: string;
publicKey?: string;
title?: string;
features?: Record<string, unknown>;
};
const createRemoteDirectoryList = (code: string, board: RemoteDirectoryOverride = {}) => ({
createdAt: 3,
updatedAt: 4,
boards: [
{
address: board.address ?? `${code}-posting.bso`,
publicKey: board.publicKey ?? `${code}-public-key`,
addedAt: 3,
},
],
});
const createRemoteDefaults = (codes = REMOTE_DIRECTORY_CODES, overrides: Record<string, RemoteDirectoryOverride> = {}) => ({
title: '5chan Directory Defaults',
description: 'remote defaults',
createdAt: 1,
updatedAt: 10,
directories: Object.fromEntries(
codes.map((code) => [
code,
{
directoryCode: code,
title: overrides[code]?.title ?? `/${code}/ - Test`,
features: overrides[code]?.features ?? { safeForWork: true },
},
]),
),
});
const mockDirectoryListFetches = (overrides: Record<string, RemoteDirectoryOverride> = {}) => {
fetchMock.mockImplementation((url: unknown) => {
if (isDefaultsUrl(url)) {
return Promise.resolve(createFetchResponse(createRemoteDefaults(REMOTE_DIRECTORY_CODES, overrides)));
}
const code = getDirectoryCodeFromUrl(url);
const payload = createRemoteDirectoryList(code, overrides[code]);
return Promise.resolve(createFetchResponse(payload));
});
};
const flushEffects = async (count = 4) => {
for (let i = 0; i < count; i += 1) {
await act(async () => {
@@ -153,45 +204,25 @@ describe('use-directories', () => {
],
};
const remotePayload = {
title: 'Fresh directories',
description: 'fresh description',
createdAt: 3,
updatedAt: 4,
directories: [
{
name: 'music-posting.bso',
publicKey: 'music-public-key',
title: '/mu/ - Music',
directoryCode: 'mu',
features: { safeForWork: true, postsPerPage: 25, nested: { ignore: true } },
},
{
name: 'flash.bso',
publicKey: 'flash-public-key',
title: '/f/ - Flash',
directoryCode: 'f',
features: { nsfw: true, postsPerPage: 10 },
},
{
name: 'flash.bso',
publicKey: 'flash-public-key',
title: '/f/ - Duplicate Flash',
directoryCode: 'f',
},
],
};
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(cachedData));
localStorage.setItem(LOCALSTORAGE_TIMESTAMP_KEY, String(Date.now()));
const pendingFetch = createDeferred<ReturnType<typeof createFetchResponse>>();
fetchMock.mockReturnValueOnce(pendingFetch.promise);
const pendingDefaults = createDeferred<ReturnType<typeof createFetchResponse>>();
const pendingFetches = new Map<string, Deferred<ReturnType<typeof createFetchResponse>>>();
fetchMock.mockImplementation((url: unknown) => {
if (isDefaultsUrl(url)) {
return pendingDefaults.promise;
}
const code = getDirectoryCodeFromUrl(url);
const deferred = createDeferred<ReturnType<typeof createFetchResponse>>();
pendingFetches.set(code, deferred);
return deferred.promise;
});
renderHarness();
await flushEffects();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalled();
expect(latestSnapshot?.state.loading).toBe(false);
expect(latestSnapshot?.state.communities.map((community) => community.address)).toEqual(['music-posting.bso', 'flash.bso']);
expect(latestSnapshot?.addresses).toEqual(['music-posting.bso', 'flash.bso']);
@@ -203,70 +234,64 @@ describe('use-directories', () => {
updatedAt: 2,
});
pendingFetch.resolve(createFetchResponse(remotePayload));
const remoteOverrides: Record<string, RemoteDirectoryOverride> = {
mu: {
address: 'music-posting.bso',
publicKey: 'music-public-key',
title: '/mu/ - Music',
features: { safeForWork: true, postsPerPage: 25, nested: { ignore: true } },
},
};
pendingDefaults.resolve(createFetchResponse(createRemoteDefaults(REMOTE_DIRECTORY_CODES, remoteOverrides)));
await flushEffects();
pendingFetches.forEach((pendingFetch, code) => {
const override = code === 'mu' ? remoteOverrides.mu : undefined;
pendingFetch.resolve(createFetchResponse(createRemoteDirectoryList(code, override)));
});
await flushEffects();
expect(latestSnapshot?.state.loading).toBe(false);
expect(latestSnapshot?.directories).toEqual([
{
address: 'music-posting.bso',
name: 'music-posting.bso',
publicKey: 'music-public-key',
title: '/mu/ - Music',
directoryCode: 'mu',
features: { safeForWork: true, postsPerPage: 25 },
nsfw: false,
},
{
address: 'flash.bso',
name: 'flash.bso',
publicKey: 'flash-public-key',
title: '/f/ - Flash',
directoryCode: 'f',
features: { nsfw: true, postsPerPage: 10 },
nsfw: true,
},
]);
expect(latestSnapshot?.addresses).toEqual(['music-posting.bso', 'flash.bso']);
expect(latestSnapshot?.directory?.address).toBe('music-posting.bso');
expect(latestSnapshot?.metadata).toEqual({
title: 'Fresh directories',
description: 'fresh description',
createdAt: 3,
updatedAt: 4,
});
const persisted = JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY) ?? '{}');
expect(persisted.title).toBe('Fresh directories');
expect(persisted.communities).toHaveLength(2);
});
it('does not refetch GitHub directories for each later hook mount after a successful refresh', async () => {
const remotePayload = {
title: 'Fresh directories',
description: 'fresh description',
createdAt: 3,
updatedAt: 4,
directories: [
expect(latestSnapshot?.directories).toEqual(
expect.arrayContaining([
{
address: 'music-posting.bso',
name: 'music-posting.bso',
publicKey: 'music-public-key',
title: '/mu/ - Music',
directoryCode: 'mu',
features: { safeForWork: true, postsPerPage: 25 },
nsfw: false,
},
],
};
]),
);
expect(latestSnapshot?.addresses).toContain('music-posting.bso');
expect(latestSnapshot?.directory?.address).toBe('music-posting.bso');
expect(latestSnapshot?.metadata?.title).toBe('5chan directories');
fetchMock.mockResolvedValueOnce(createFetchResponse(remotePayload));
const persisted = JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY) ?? '{}');
expect(persisted.title).toBe('5chan directories');
expect(persisted.communities.length).toBeGreaterThan(1);
});
it('does not refetch GitHub directories for each later hook mount after a successful refresh', async () => {
mockDirectoryListFetches({
mu: {
address: 'music-posting.bso',
publicKey: 'music-public-key',
title: '/mu/ - Music',
features: { safeForWork: true, postsPerPage: 25 },
},
});
renderHarness();
await flushEffects(8);
const firstSnapshot = expectLatestSnapshot();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(firstSnapshot.directories.map((community) => community.address)).toEqual(['music-posting.bso']);
expect(firstSnapshot.metadata?.title).toBe('Fresh directories');
const firstFetchCount = fetchMock.mock.calls.length;
expect(firstFetchCount).toBeGreaterThan(1);
expect(firstSnapshot.directories.map((community) => community.address)).toContain('music-posting.bso');
expect(firstSnapshot.metadata?.title).toBe('5chan directories');
latestSnapshot = null;
act(() => {
@@ -277,9 +302,43 @@ describe('use-directories', () => {
await flushEffects(8);
const secondSnapshot = expectLatestSnapshot();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(secondSnapshot.directories.map((community) => community.address)).toEqual(['music-posting.bso']);
expect(secondSnapshot.metadata?.title).toBe('Fresh directories');
expect(fetchMock).toHaveBeenCalledTimes(firstFetchCount);
expect(secondSnapshot.directories.map((community) => community.address)).toContain('music-posting.bso');
expect(secondSnapshot.metadata?.title).toBe('5chan directories');
});
it('keeps vendored non-default directories when remote defaults omit them and one list fetch fails', async () => {
fetchMock.mockImplementation((url: unknown) => {
if (isDefaultsUrl(url)) {
return Promise.resolve(
createFetchResponse(
createRemoteDefaults(['mu'], {
mu: { address: 'music-remote.bso', publicKey: 'music-remote-public-key', title: '/mu/ - Music' },
}),
),
);
}
const code = getDirectoryCodeFromUrl(url);
if (code === 'mu') {
return Promise.resolve(createFetchResponse(createRemoteDirectoryList('mu', { address: 'music-remote.bso', publicKey: 'music-remote-public-key' })));
}
if (code === 'biz') {
return Promise.resolve(createFetchResponse({ error: 'temporary failure' }, false, 500));
}
return Promise.resolve(createFetchResponse({ error: 'missing' }, false, 404));
});
renderHarness('television-and-film.bso');
await flushEffects(12);
const snapshot = expectLatestSnapshot();
const directoriesByCode = new Map(snapshot.directories.map((directory) => [directory.directoryCode, directory]));
expect(directoriesByCode.get('mu')?.address).toBe('music-remote.bso');
expect(directoriesByCode.get('biz')?.address).toBe('business-and-finance.bso');
expect(directoriesByCode.get('tv')?.address).toBe('television-and-film.bso');
expect(snapshot.directory?.address).toBe('television-and-film.bso');
expect(warnSpy.mock.calls.some((call: ConsoleWarnCall) => String(call[0]).includes('Failed to fetch directory list "biz"'))).toBe(true);
});
it('clears invalid recent cache entries and falls back to vendored data when GitHub refresh fails', async () => {
@@ -290,7 +349,7 @@ describe('use-directories', () => {
renderHarness('unknown.eth');
await flushEffects(8);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalled();
expect(localStorage.getItem(LOCALSTORAGE_KEY)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE_TIMESTAMP_KEY)).toBeNull();
expect(warnSpy.mock.calls.some((call: ConsoleWarnCall) => String(call[0]).includes('Invalid directories cache format'))).toBe(true);
@@ -19,8 +19,8 @@ const testState = vi.hoisted(() => ({
list: {
directoryCode: 'biz',
boards: [
{ address: 'business-and-finance.bso', score: 100, managedByDevs: true },
{ address: 'backup-business.bso', score: 10, managedByDevs: false },
{ address: 'business-and-finance.bso', score: 100 },
{ address: 'backup-business.bso', score: 10 },
],
},
offlineStates: {} as Record<string, { updatedAt?: number; state?: string }>,
+1 -1
View File
@@ -22,7 +22,7 @@ export const CommunityStatsCollector = ({ communityAddress }: { communityAddress
const setCommunityStats = useCommunitiesStatsStore((state) => state.setCommunityStats);
useEffect(() => {
if (stats && stats.allPostCount !== undefined) {
if (stats && (stats.allPostCount !== undefined || stats.state === 'failed')) {
setCommunityStats(communityAddress, stats);
}
}, [stats, communityAddress, setCommunityStats]);
+125 -98
View File
@@ -1,5 +1,20 @@
import { useEffect, useMemo, useState } from 'react';
import directoriesData from '../data/5chan-directories.json';
import directoryListsData from '../data/5chan-directory-lists.json';
import {
directoryListToCommunity,
isRecord,
normalizeDirectoryDefaultsData,
normalizeDirectoryList,
sortDirectoryLists,
toCanonicalCommunity,
toString,
type DirectoriesData,
type DirectoryDefaultsData,
type DirectoryCommunity,
type DirectoryList,
} from '../lib/utils/directory-list-utils';
export type { DirectoriesData, DirectoryCommunity } from '../lib/utils/directory-list-utils';
interface DirectoriesMetadata {
title: string;
@@ -8,47 +23,19 @@ interface DirectoriesMetadata {
updatedAt: number;
}
interface DirectoryFeatures {
postsPerPage?: number;
pseudonymityMode?: string;
nsfw?: boolean;
noSpoilers?: boolean;
noSpoilerReplies?: boolean;
hasFlags?: boolean;
requirePostLink?: boolean;
requirePostLinkIsMedia?: boolean;
[key: string]: unknown;
}
export interface DirectoryCommunity {
title?: string;
address: string;
name?: string;
publicKey?: string;
nsfw?: boolean;
directoryCode?: string;
features?: DirectoryFeatures;
}
export interface DirectoriesData {
title: string;
description: string;
createdAt: number;
updatedAt: number;
communities: DirectoryCommunity[];
}
interface DirectoriesState {
communities: DirectoryCommunity[];
loading: boolean;
error: Error | null;
}
const GITHUB_URL = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-directories.json';
const GITHUB_URL_TEMPLATE = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-directories/5chan-{code}-directory.json';
const GITHUB_DEFAULTS_URL = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-directories/5chan-directories-defaults.json';
const LOCALSTORAGE_KEY = '5chan-directories-cache';
const LOCALSTORAGE_TIMESTAMP_KEY = '5chan-directories-cache-timestamp';
const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
const FETCH_RETRY_DELAY_MS = 60 * 1000; // 1 minute
const FETCH_TIMEOUT_MS = 10 * 1000;
let cacheCommunities: DirectoryCommunity[] | null = null;
let cacheMetadata: DirectoriesMetadata | null = null;
@@ -67,69 +54,10 @@ export const __resetDirectoriesModuleStateForTests = () => {
fallbackDirectoriesData = null;
};
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
const getStringValue = (...values: unknown[]): string | undefined => values.find((value): value is string => typeof value === 'string' && value.length > 0);
const normalizeFeatures = (value: unknown): DirectoryFeatures | undefined => {
if (!isRecord(value)) {
return undefined;
}
const normalizedFeatures = Object.entries(value).reduce<DirectoryFeatures>((acc, [key, featureValue]) => {
if (typeof featureValue === 'string' || typeof featureValue === 'boolean' || typeof featureValue === 'number') {
acc[key] = featureValue;
}
return acc;
}, {});
return Object.keys(normalizedFeatures).length > 0 ? normalizedFeatures : undefined;
};
const deriveNsfw = (value: { nsfw?: unknown; features?: { nsfw?: boolean; safeForWork?: boolean } }): boolean | undefined => {
const features = value.features;
const safeForWork = typeof features?.safeForWork === 'boolean' ? features.safeForWork : undefined;
if (safeForWork !== undefined) {
return !safeForWork;
}
const featuresNsfw = typeof features?.nsfw === 'boolean' ? features.nsfw : undefined;
const topLevelNsfw = typeof (value as { nsfw?: boolean }).nsfw === 'boolean' ? (value as { nsfw: boolean }).nsfw : undefined;
return topLevelNsfw ?? featuresNsfw;
};
const getDirectoryIdentifiers = (community: DirectoryCommunity): string[] => [
...new Set([community.address, community.name, community.publicKey].filter((value): value is string => typeof value === 'string' && value.length > 0)),
];
const toCanonicalCommunity = (value: {
address?: unknown;
communityAddress?: unknown;
name?: unknown;
publicKey?: unknown;
title?: unknown;
nsfw?: unknown;
directoryCode?: unknown;
features?: unknown;
}): DirectoryCommunity | null => {
const name = getStringValue(value.name, value.address, value.communityAddress);
if (!name) {
return null;
}
const features = normalizeFeatures(value.features);
const nsfw = deriveNsfw({ ...value, features });
return {
address: name,
name,
...(typeof value.publicKey === 'string' ? { publicKey: value.publicKey } : {}),
...(typeof value.title === 'string' ? { title: value.title } : {}),
...(typeof value.directoryCode === 'string' ? { directoryCode: value.directoryCode } : {}),
...(features ? { features } : {}),
...(nsfw !== undefined ? { nsfw } : {}),
};
};
const dedupeCommunities = (entries: DirectoryCommunity[]): DirectoryCommunity[] => {
const seenAddresses = new Set<string>();
const normalizedEntries: DirectoryCommunity[] = [];
@@ -146,6 +74,27 @@ const dedupeCommunities = (entries: DirectoryCommunity[]): DirectoryCommunity[]
return normalizedEntries;
};
const adaptDirectoryLists = (value: Record<string, unknown>): DirectoryCommunity[] => {
if (!Array.isArray(value.directories)) {
return [];
}
const lists = value.directories
.map((directory) => {
if (!isRecord(directory) || !Array.isArray(directory.boards) || typeof directory.directoryCode !== 'string') {
return null;
}
return normalizeDirectoryList(directory, directory.directoryCode);
})
.filter((list): list is DirectoryList => list !== null);
const communities = sortDirectoryLists(lists)
.map(directoryListToCommunity)
.filter((community): community is DirectoryCommunity => community !== null);
return dedupeCommunities(communities);
};
const adaptV2Directories = (value: Record<string, unknown>): DirectoryCommunity[] => {
if (!Array.isArray(value.directories)) {
return [];
@@ -248,14 +197,14 @@ const normalizeDirectoriesData = (value: unknown): DirectoriesData | null => {
return null;
}
const adapters: Array<(raw: Record<string, unknown>) => DirectoryCommunity[]> = [adaptV2Directories, adaptV1Communities];
const adapters: Array<(raw: Record<string, unknown>) => DirectoryCommunity[]> = [adaptDirectoryLists, adaptV2Directories, adaptV1Communities];
const communities = adapters.map((adapter) => adapter(value)).find((normalized) => normalized.length > 0) ?? [];
if (communities.length === 0) {
return null;
}
const fallbackMetadata = getDirectoriesMetadata(directoriesData as unknown);
const fallbackMetadata = getDirectoriesMetadata(directoryListsData as unknown);
return {
title: typeof value.title === 'string' ? value.title : fallbackMetadata.title,
description: typeof value.description === 'string' ? value.description : fallbackMetadata.description,
@@ -269,8 +218,8 @@ let fallbackDirectoriesData: DirectoriesData | null = null;
export const getFallbackDirectoriesData = (): DirectoriesData => {
if (fallbackDirectoriesData) return fallbackDirectoriesData;
const normalized = normalizeDirectoriesData(directoriesData as unknown);
const metadata = getDirectoriesMetadata(directoriesData as unknown);
const normalized = normalizeDirectoriesData(directoryListsData as unknown);
const metadata = getDirectoriesMetadata(directoryListsData as unknown);
fallbackDirectoriesData = normalized ?? {
...metadata,
communities: [],
@@ -322,12 +271,90 @@ const hydrateModuleCaches = (data: DirectoriesData) => {
cacheMetadata = toDirectoriesMetadata(data);
};
const fetchDirectoriesFromGitHub = async (): Promise<DirectoriesData> => {
const response = await fetch(GITHUB_URL, { cache: 'no-cache' });
const getFallbackDirectoryLists = (defaults?: DirectoryDefaultsData): DirectoryList[] => {
const rawData = directoryListsData as unknown;
if (!isRecord(rawData) || !Array.isArray(rawData.directories)) {
return [];
}
return rawData.directories
.map((directory) => {
if (!isRecord(directory)) {
return null;
}
const fallbackCode = toString(directory.directoryCode);
return fallbackCode ? normalizeDirectoryList(directory, fallbackCode, defaults) : null;
})
.filter((list): list is DirectoryList => list !== null);
};
const fetchWithTimeout = async (url: string): Promise<Response> => {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
return await fetch(url, { cache: 'no-cache', signal: controller.signal });
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Timed out fetching ${url}`);
}
throw error;
} finally {
window.clearTimeout(timeoutId);
}
};
const fetchJsonWithTimeout = async (url: string): Promise<unknown> => {
const response = await fetchWithTimeout(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = normalizeDirectoriesData(await response.json());
return response.json();
};
const fetchDirectoryDefaultsFromGitHub = async (): Promise<DirectoryDefaultsData> => {
return normalizeDirectoryDefaultsData(await fetchJsonWithTimeout(GITHUB_DEFAULTS_URL));
};
const fetchDirectoryListFromGitHub = async (code: string, defaults: DirectoryDefaultsData): Promise<DirectoryList | null> => {
const response = await fetchWithTimeout(GITHUB_URL_TEMPLATE.replace('{code}', code));
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const list = normalizeDirectoryList(await response.json(), code, defaults);
if (!list) {
throw new Error(`Invalid directory list payload for ${code}`);
}
return list;
};
const fetchDirectoriesFromGitHub = async (): Promise<DirectoriesData> => {
const defaults = await fetchDirectoryDefaultsFromGitHub();
const fallbackLists = getFallbackDirectoryLists(defaults);
const fallbackListsByCode = new Map(fallbackLists.map((list) => [list.directoryCode, list]));
const codes = [...new Set([...Object.keys(defaults.directories), ...fallbackLists.map((list) => list.directoryCode)])];
const fetchedLists = await Promise.all(
codes.map(async (code) => {
try {
return (await fetchDirectoryListFromGitHub(code, defaults)) ?? fallbackListsByCode.get(code) ?? null;
} catch (error) {
console.warn(`Failed to fetch directory list "${code}" from GitHub, using fallback if available:`, error);
return fallbackListsByCode.get(code) ?? null;
}
}),
);
const lists = sortDirectoryLists(fetchedLists.filter((list): list is DirectoryList => list !== null));
const fallbackMetadata = getDirectoriesMetadata(directoryListsData as unknown);
const timestamps = [defaults.createdAt, defaults.updatedAt, ...lists.flatMap((list) => [list.createdAt, list.updatedAt])].filter(
(value): value is number => typeof value === 'number',
);
const data = normalizeDirectoriesData({
...fallbackMetadata,
...(timestamps.length > 0 ? { createdAt: Math.min(...timestamps), updatedAt: Math.max(...timestamps) } : {}),
directories: lists,
});
if (!data) {
throw new Error('Invalid directories payload');
}
+20 -81
View File
@@ -1,25 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import { DirectoryCommunity, useDirectories } from './use-directories';
import { type DirectoryList, type DirectoryListBoard, normalizeDirectoryList, sortDirectoryBoardsByRank } from '../lib/utils/directory-list-utils';
export interface DirectoryListBoard {
address: string;
publicKey?: string;
title?: string;
description?: string;
owner?: string;
score: number;
managedByDevs: boolean;
addedAt?: number;
}
interface DirectoryList {
directoryCode: string;
title?: string;
description?: string;
createdAt?: number;
updatedAt?: number;
boards: DirectoryListBoard[];
}
export type { DirectoryListBoard } from '../lib/utils/directory-list-utils';
interface DirectoryListState {
list: DirectoryList | null;
@@ -33,7 +16,7 @@ interface DirectoryListsState {
errorsByCode: Record<string, Error | null>;
}
const GITHUB_URL_TEMPLATE = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-{code}-directory.json';
const GITHUB_URL_TEMPLATE = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-directories/5chan-{code}-directory.json';
const LOCALSTORAGE_KEY_PREFIX = '5chan-directory-list-cache:';
const LOCALSTORAGE_TIMESTAMP_KEY_PREFIX = '5chan-directory-list-cache-timestamp:';
const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
@@ -46,49 +29,6 @@ const inFlightFetches = new Map<string, Promise<DirectoryList | null>>();
const lastFetchSuccessAt = new Map<string, number>();
const lastFetchAttemptAt = new Map<string, number>();
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
const toNumber = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
const toString = (value: unknown): string | undefined => (typeof value === 'string' && value.length > 0 ? value : undefined);
const toBool = (value: unknown): boolean => value === true;
const normalizeBoard = (raw: unknown): DirectoryListBoard | null => {
if (!isRecord(raw)) return null;
const address = toString(raw.address) ?? toString(raw.name);
if (!address) return null;
return {
address,
...(toString(raw.publicKey) ? { publicKey: toString(raw.publicKey)! } : {}),
...(toString(raw.title) ? { title: toString(raw.title)! } : {}),
...(toString(raw.description) ? { description: toString(raw.description)! } : {}),
...(toString(raw.owner) ? { owner: toString(raw.owner)! } : {}),
score: toNumber(raw.score) ?? 0,
managedByDevs: toBool(raw.managedByDevs),
...(toNumber(raw.addedAt) !== undefined ? { addedAt: toNumber(raw.addedAt) } : {}),
};
};
const normalizeDirectoryList = (raw: unknown, fallbackCode: string): DirectoryList | null => {
if (!isRecord(raw)) return null;
const boardsRaw = Array.isArray(raw.boards) ? raw.boards : Array.isArray(raw.communities) ? raw.communities : null;
if (!boardsRaw) return null;
const boards = boardsRaw.map(normalizeBoard).filter((board): board is DirectoryListBoard => board !== null);
if (boards.length === 0) return null;
return {
directoryCode: toString(raw.directoryCode) ?? fallbackCode,
...(toString(raw.title) ? { title: toString(raw.title)! } : {}),
...(toString(raw.description) ? { description: toString(raw.description)! } : {}),
...(toNumber(raw.createdAt) !== undefined ? { createdAt: toNumber(raw.createdAt) } : {}),
...(toNumber(raw.updatedAt) !== undefined ? { updatedAt: toNumber(raw.updatedAt) } : {}),
boards,
};
};
const synthesizeFromMainDirectory = (directoryCode: string, directories: DirectoryCommunity[]): DirectoryList | null => {
const match = directories.find((community) => community.directoryCode === directoryCode);
if (!match || !match.address) return null;
@@ -96,18 +36,24 @@ const synthesizeFromMainDirectory = (directoryCode: string, directories: Directo
const board: DirectoryListBoard = {
address: match.address,
...(match.publicKey ? { publicKey: match.publicKey } : {}),
...(match.title ? { title: match.title } : {}),
score: 0,
managedByDevs: true,
...(match.nsfw !== undefined ? { nsfw: match.nsfw } : {}),
...(match.features ? { features: match.features } : {}),
};
return {
directoryCode,
...(match.title ? { title: match.title } : {}),
...(match.features ? { features: match.features } : {}),
boards: [board],
};
};
const mergeDirectoryListDefaults = (list: DirectoryList, fallback: DirectoryList | null): DirectoryList => ({
...list,
...(list.title || !fallback?.title ? {} : { title: fallback.title }),
...(list.features || !fallback?.features ? {} : { features: fallback.features }),
});
const getLocalStorageKey = (code: string) => `${LOCALSTORAGE_KEY_PREFIX}${code}`;
const getLocalStorageTimestampKey = (code: string) => `${LOCALSTORAGE_TIMESTAMP_KEY_PREFIX}${code}`;
@@ -199,9 +145,9 @@ const fetchDirectoryListDeduped = (code: string): Promise<DirectoryList | null>
/**
* Fetch the candidate boards for a single directory code (e.g. 'biz').
*
* Source: `bitsocialnet/lists/5chan-{code}-directory.json`. When the network is unavailable
* Source: `bitsocialnet/lists/5chan-directories/5chan-{code}-directory.json`. When the network is unavailable
* or the file is not yet published, falls back to a synthesized single-entry list derived
* from the main 5chan-directories.json (the dev-managed default).
* from the merged directory assignments.
*/
export const useDirectoryList = (directoryCode: string | undefined): DirectoryListState => {
const directories = useDirectories();
@@ -227,16 +173,17 @@ export const useDirectoryList = (directoryCode: string | undefined): DirectoryLi
let isMounted = true;
const hydrate = (list: DirectoryList) => {
moduleCaches.set(directoryCode, list);
const mergedList = mergeDirectoryListDefaults(list, fallback);
moduleCaches.set(directoryCode, mergedList);
if (isMounted) {
setState({ list, loading: false, error: null });
setState({ list: mergedList, loading: false, error: null });
}
};
(async () => {
const cached = moduleCaches.get(directoryCode);
if (cached) {
setState({ list: cached, loading: false, error: null });
setState({ list: mergeDirectoryListDefaults(cached, fallback), loading: false, error: null });
} else {
const local = getFromLocalStorage(directoryCode);
if (local) {
@@ -365,16 +312,6 @@ export const useDirectoryLists = (directoryCodes: string[] | undefined): Directo
return state;
};
/**
* Sort boards by score (desc). Ties break in favor of `managedByDevs`, then `addedAt` asc.
*/
export const sortDirectoryBoardsByRank = (boards: DirectoryListBoard[]): DirectoryListBoard[] =>
[...boards].sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (a.managedByDevs !== b.managedByDevs) return a.managedByDevs ? -1 : 1;
return (a.addedAt ?? 0) - (b.addedAt ?? 0);
});
/**
* Pick the winning board for a directory, skipping any boards reported offline.
* Returns the highest-ranked online board, or — if every candidate looks offline —
@@ -384,3 +321,5 @@ export const pickDirectoryWinner = (boards: DirectoryListBoard[], isOffline: (ad
const ranked = sortDirectoryBoardsByRank(boards);
return ranked.find((board) => !isOffline(board.address)) ?? ranked[0];
};
export { sortDirectoryBoardsByRank };