fix(directory): use resolved board for directory feeds

This commit is contained in:
Tommaso Casaburi
2026-05-21 00:05:29 +07:00
parent 536d9ac0b8
commit 616fea39fb
9 changed files with 198 additions and 50 deletions
@@ -2,13 +2,14 @@ import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useResolvedCommunityAddress } from '../use-resolved-community-address';
import { useResolvedCommunityAddress, useResolvedDirectoryBoardPath } from '../use-resolved-community-address';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
boardIdentifier: 'biz',
boardIdentifierOverride: undefined as string | undefined,
directories: [
{
address: 'business-and-finance.bso',
@@ -20,7 +21,7 @@ const testState = vi.hoisted(() => ({
directoryCode: 'biz',
boards: [
{ address: 'business-and-finance.bso', score: 100 },
{ address: 'backup-business.bso', score: 10 },
{ address: 'bizraelis.bso', score: 10 },
],
},
offlineStates: {} as Record<string, { updatedAt?: number; state?: string }>,
@@ -37,6 +38,7 @@ vi.mock('react-router-dom', async () => {
vi.mock('../use-directories', () => ({
useDirectories: () => testState.directories,
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
}));
vi.mock('../use-directory-list', async () => {
@@ -56,11 +58,13 @@ vi.mock('../../stores/use-community-offline-store', () => ({
}));
let latestValue: string | undefined;
let latestDirectoryBoardPath: { boardPath: string | undefined; isDirectoryCandidate: boolean };
let container: HTMLDivElement;
let root: Root;
const HookHarness = () => {
latestValue = useResolvedCommunityAddress();
latestValue = useResolvedCommunityAddress(testState.boardIdentifierOverride);
latestDirectoryBoardPath = useResolvedDirectoryBoardPath(testState.boardIdentifier);
return null;
};
@@ -75,7 +79,9 @@ describe('useResolvedCommunityAddress', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:10Z'));
latestValue = undefined;
latestDirectoryBoardPath = { boardPath: undefined, isDirectoryCandidate: false };
testState.boardIdentifier = 'biz';
testState.boardIdentifierOverride = undefined;
testState.offlineStates = {};
testState.offlineSelections = [];
@@ -99,7 +105,7 @@ describe('useResolvedCommunityAddress', () => {
await renderHook();
expect(latestValue).toBe('backup-business.bso');
expect(latestValue).toBe('bizraelis.bso');
});
it('keeps a higher-ranked directory board when its last update is newer than 30 minutes', async () => {
@@ -125,7 +131,7 @@ describe('useResolvedCommunityAddress', () => {
await renderHook();
expect(latestValue).toBe('custom-board.bso');
expect(testState.offlineSelections).toEqual([undefined]);
expect(testState.offlineSelections.every((selection) => selection === undefined)).toBe(true);
});
it('switches away from a directory board when it crosses the offline threshold while mounted', async () => {
@@ -143,6 +149,52 @@ describe('useResolvedCommunityAddress', () => {
vi.advanceTimersByTime(2 * 60 * 1000);
});
expect(latestValue).toBe('backup-business.bso');
expect(latestValue).toBe('bizraelis.bso');
});
it('uses an explicit directory identifier for cached board feeds', async () => {
testState.boardIdentifier = 'all';
testState.boardIdentifierOverride = 'biz';
testState.offlineStates = {
'business-and-finance.bso': {
updatedAt: 1_704_067_210 - 31 * 60,
},
};
await renderHook();
expect(latestValue).toBe('bizraelis.bso');
});
it('canonicalizes the direct address for the current directory winner', async () => {
testState.boardIdentifier = 'bizraelis.bso';
testState.offlineStates = {
'business-and-finance.bso': {
updatedAt: 1_704_067_210 - 31 * 60,
},
};
await renderHook();
expect(latestDirectoryBoardPath).toEqual({
boardPath: 'biz',
isDirectoryCandidate: true,
});
});
it('does not canonicalize a directory candidate address when it is not the current winner', async () => {
testState.boardIdentifier = 'business-and-finance.bso';
testState.offlineStates = {
'business-and-finance.bso': {
updatedAt: 1_704_067_210 - 31 * 60,
},
};
await renderHook();
expect(latestDirectoryBoardPath).toEqual({
boardPath: undefined,
isDirectoryCandidate: true,
});
});
});
+37 -6
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { DirectoryCommunity, useDirectories } from './use-directories';
import { type DirectoryCommunity, normalizeBoardAddress, useDirectories } from './use-directories';
import { type DirectoryList, type DirectoryListBoard, normalizeDirectoryList, sortDirectoryBoardsByRank } from '../lib/utils/directory-list-utils';
import directoryListsData from '../data/5chan-directory-lists.json';
export type { DirectoryListBoard } from '../lib/utils/directory-list-utils';
@@ -28,6 +29,33 @@ const moduleCaches = new Map<string, DirectoryList>();
const inFlightFetches = new Map<string, Promise<DirectoryList | null>>();
const lastFetchSuccessAt = new Map<string, number>();
const lastFetchAttemptAt = new Map<string, number>();
let vendoredDirectoryListsCache: DirectoryList[] | null = null;
const getVendoredDirectoryLists = (): DirectoryList[] => {
if (vendoredDirectoryListsCache) return vendoredDirectoryListsCache;
const directories = Array.isArray(directoryListsData.directories) ? directoryListsData.directories : [];
vendoredDirectoryListsCache = directories.flatMap((directory) => {
const directoryCode = typeof directory.directoryCode === 'string' ? directory.directoryCode : undefined;
if (!directoryCode) return [];
const normalized = normalizeDirectoryList(directory, directoryCode);
return normalized ? [normalized] : [];
});
return vendoredDirectoryListsCache;
};
const getVendoredDirectoryList = (directoryCode: string): DirectoryList | null =>
getVendoredDirectoryLists().find((directory) => directory.directoryCode === directoryCode) ?? null;
export const getDirectoryCodeForBoardAddress = (address: string | undefined): string | undefined => {
if (!address) return undefined;
const normalizedAddress = normalizeBoardAddress(address);
return getVendoredDirectoryLists().find((directory) =>
directory.boards.some((board) => normalizeBoardAddress(board.address) === normalizedAddress || board.publicKey === address),
)?.directoryCode;
};
const synthesizeFromMainDirectory = (directoryCode: string, directories: DirectoryCommunity[]): DirectoryList | null => {
const match = directories.find((community) => community.directoryCode === directoryCode);
@@ -48,6 +76,9 @@ const synthesizeFromMainDirectory = (directoryCode: string, directories: Directo
};
};
const getFallbackDirectoryList = (directoryCode: string, directories: DirectoryCommunity[]): DirectoryList | null =>
getVendoredDirectoryList(directoryCode) ?? synthesizeFromMainDirectory(directoryCode, directories);
const mergeDirectoryListDefaults = (list: DirectoryList, fallback: DirectoryList | null): DirectoryList => ({
...list,
...(list.title || !fallback?.title ? {} : { title: fallback.title }),
@@ -145,13 +176,13 @@ const fetchDirectoryListDeduped = (code: string): Promise<DirectoryList | null>
/**
* Fetch the candidate boards for a single directory code (e.g. 'biz').
*
* 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 merged directory assignments.
* Source: `bitsocialnet/lists/5chan-directories/5chan-{code}-directory.json`. While the network
* is unavailable or the remote file is still loading, falls back to the vendored list
* generated from that repo, then to a synthesized single-entry list from directory assignments.
*/
export const useDirectoryList = (directoryCode: string | undefined): DirectoryListState => {
const directories = useDirectories();
const fallback = useMemo(() => (directoryCode ? synthesizeFromMainDirectory(directoryCode, directories) : null), [directoryCode, directories]);
const fallback = useMemo(() => (directoryCode ? getFallbackDirectoryList(directoryCode, directories) : null), [directoryCode, directories]);
const [state, setState] = useState<DirectoryListState>(() => {
if (!directoryCode) {
@@ -233,7 +264,7 @@ export const useDirectoryLists = (directoryCodes: string[] | undefined): Directo
const fallbackByCode = useMemo(() => {
const normalizedDirectoryCodes = directoryCodesKey ? directoryCodesKey.split('\0') : [];
return Object.fromEntries(normalizedDirectoryCodes.map((directoryCode) => [directoryCode, synthesizeFromMainDirectory(directoryCode, directories)])) as Record<
return Object.fromEntries(normalizedDirectoryCodes.map((directoryCode) => [directoryCode, getFallbackDirectoryList(directoryCode, directories)])) as Record<
string,
DirectoryList | null
>;
+42 -6
View File
@@ -1,23 +1,28 @@
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { useDirectories } from './use-directories';
import { pickDirectoryWinner, useDirectoryList } from './use-directory-list';
import { getDirectoryCodeForBoardAddress, pickDirectoryWinner, useDirectoryList } from './use-directory-list';
import useCommunityOfflineStore from '../stores/use-community-offline-store';
import { getCommunityAddress, getBoardPath, isDirectoryRoute } from '../lib/utils/route-utils';
import { areSameBoardAddress, getCommunityAddress, getBoardPath, isDirectoryRoute } from '../lib/utils/route-utils';
import { isCommunityKnownOffline } from '../lib/utils/community-freshness-utils';
import { useNowSeconds } from './use-now-seconds';
interface ResolvedDirectoryBoardPath {
boardPath: string | undefined;
isDirectoryCandidate: boolean;
}
/**
* Resolve a board identifier from URL params to canonical community address.
* Resolve a board identifier to its canonical community address.
*
* For directory codes (e.g. /biz) with a per-directory list of candidates, picks the
* highest-ranked candidate that is not currently flagged offline. Falls back to the
* vendored single-candidate default while the per-directory list is still loading.
* vendored directory list while the remote list is still loading.
*/
export const useResolvedCommunityAddress = (): string | undefined => {
export const useResolvedCommunityAddress = (boardIdentifierOverride?: string): string | undefined => {
const params = useParams<{ boardIdentifier?: string }>();
const directories = useDirectories();
const boardIdentifier = params.boardIdentifier;
const boardIdentifier = boardIdentifierOverride ?? params.boardIdentifier;
const isCode = !!boardIdentifier && isDirectoryRoute(boardIdentifier, directories);
const { list } = useDirectoryList(isCode ? boardIdentifier : undefined);
const offlineStates = useCommunityOfflineStore((state) => (isCode ? state.communityOfflineState : undefined));
@@ -34,6 +39,37 @@ export const useResolvedCommunityAddress = (): string | undefined => {
}, [boardIdentifier, directories, isCode, list, offlineStates, nowSeconds]);
};
/**
* Return the directory code only when a direct board-address route points at the
* board currently winning that directory.
*/
export const useResolvedDirectoryBoardPath = (boardIdentifier: string | undefined): ResolvedDirectoryBoardPath => {
const directories = useDirectories();
const isCode = !!boardIdentifier && isDirectoryRoute(boardIdentifier, directories);
const directoryCode = useMemo(() => (boardIdentifier && !isCode ? getDirectoryCodeForBoardAddress(boardIdentifier) : undefined), [boardIdentifier, isCode]);
const { list } = useDirectoryList(directoryCode);
const offlineStates = useCommunityOfflineStore((state) => (directoryCode ? state.communityOfflineState : undefined));
const nowSeconds = useNowSeconds(!!directoryCode);
return useMemo(() => {
if (!boardIdentifier || !directoryCode) {
return { boardPath: undefined, isDirectoryCandidate: false };
}
if (!list || list.boards.length === 0) {
return { boardPath: undefined, isDirectoryCandidate: true };
}
const isOffline = (address: string) => isCommunityKnownOffline(offlineStates?.[address], nowSeconds);
const winner = pickDirectoryWinner(list.boards, isOffline);
return {
boardPath: winner && areSameBoardAddress(winner.address, boardIdentifier) ? directoryCode : undefined,
isDirectoryCandidate: true,
};
}, [boardIdentifier, directoryCode, list, offlineStates, nowSeconds]);
};
/**
* Resolve a community address to board path (directory code or address) for links.
*/