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