mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(rules page): rebuild directory rules page with vendored lists (#1147)
* feat(rules page): rebuild directory rules page with vendored lists Mirror per-directory JSON from lists into src/data/5chan-directories, rework /rules layout to match 4chan (sidebar nav, category boxes, P2P load), and keep spoiler markup visible in rule text via parseSpoilers. * fix(rules): keep directory defaults cache atomic * fix(rules): derive defaults from shared directory refresh * fix(rules): address directory refresh edge cases
This commit is contained in:
@@ -9,15 +9,18 @@ import {
|
||||
useDirectories,
|
||||
useDirectoriesMetadata,
|
||||
useDirectoriesState,
|
||||
useDirectoryDefaults,
|
||||
useDirectoryAddresses,
|
||||
useDirectoryByAddress,
|
||||
type DirectoriesData,
|
||||
type DirectoryDefaultsData,
|
||||
} from '../use-directories';
|
||||
|
||||
(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 LOCALSTORAGE_KEY = '5chan-directories-cache';
|
||||
const LOCALSTORAGE_DEFAULTS_KEY = '5chan-directory-defaults-cache';
|
||||
const LOCALSTORAGE_TIMESTAMP_KEY = '5chan-directories-cache-timestamp';
|
||||
|
||||
type Snapshot = {
|
||||
@@ -25,6 +28,7 @@ type Snapshot = {
|
||||
state: ReturnType<typeof useDirectoriesState>;
|
||||
addresses: ReturnType<typeof useDirectoryAddresses>;
|
||||
directory: ReturnType<typeof useDirectoryByAddress>;
|
||||
directoryDefaults: DirectoryDefaultsData;
|
||||
metadata: ReturnType<typeof useDirectoriesMetadata>;
|
||||
};
|
||||
|
||||
@@ -47,6 +51,7 @@ const HookHarness = ({ address = 'music-posting.eth' }: { address?: string }) =>
|
||||
const state = useDirectoriesState();
|
||||
const addresses = useDirectoryAddresses();
|
||||
const directory = useDirectoryByAddress(address);
|
||||
const directoryDefaults = useDirectoryDefaults();
|
||||
const metadata = useDirectoriesMetadata();
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
@@ -55,9 +60,10 @@ const HookHarness = ({ address = 'music-posting.eth' }: { address?: string }) =>
|
||||
state,
|
||||
addresses,
|
||||
directory,
|
||||
directoryDefaults,
|
||||
metadata,
|
||||
};
|
||||
}, [addresses, directory, directories, metadata, state]);
|
||||
}, [addresses, directory, directories, directoryDefaults, metadata, state]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -273,6 +279,9 @@ describe('use-directories', () => {
|
||||
const persisted = JSON.parse(localStorage.getItem(LOCALSTORAGE_KEY) ?? '{}');
|
||||
expect(persisted.title).toBe('5chan directories');
|
||||
expect(persisted.communities.length).toBeGreaterThan(1);
|
||||
|
||||
const persistedDefaults = JSON.parse(localStorage.getItem(LOCALSTORAGE_DEFAULTS_KEY) ?? '{}');
|
||||
expect(persistedDefaults.description).toBe('remote defaults');
|
||||
});
|
||||
|
||||
it('does not refetch GitHub directories for each later hook mount after a successful refresh', async () => {
|
||||
@@ -342,6 +351,64 @@ describe('use-directories', () => {
|
||||
expect(warnSpy.mock.calls.some((call: ConsoleWarnCall) => String(call[0]).includes('Failed to fetch directory list "biz"'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not expose refreshed defaults before the matching directory payload commits', async () => {
|
||||
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();
|
||||
|
||||
pendingDefaults.resolve(createFetchResponse(createRemoteDefaults()));
|
||||
await flushEffects();
|
||||
|
||||
renderHarness('business-and-finance.bso');
|
||||
await flushEffects();
|
||||
|
||||
expect(latestSnapshot?.directoryDefaults.description).not.toBe('remote defaults');
|
||||
|
||||
pendingFetches.forEach((pendingFetch, code) => {
|
||||
pendingFetch.resolve(createFetchResponse(createRemoteDirectoryList(code)));
|
||||
});
|
||||
await flushEffects(8);
|
||||
|
||||
expect(latestSnapshot?.directoryDefaults.description).toBe('remote defaults');
|
||||
});
|
||||
|
||||
it('hydrates cached directory defaults with cached communities when GitHub refresh fails', async () => {
|
||||
const cachedDefaults = createRemoteDefaults(REMOTE_DIRECTORY_CODES, {
|
||||
mu: { title: '/mu/ - Cached Remote Music' },
|
||||
});
|
||||
const cachedData: DirectoriesData = {
|
||||
title: 'Cached directories',
|
||||
description: 'cached description',
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
communities: [{ address: 'music-posting.bso', title: '/mu/ - Cached Music', directoryCode: 'mu', nsfw: false }],
|
||||
};
|
||||
|
||||
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(cachedData));
|
||||
localStorage.setItem(LOCALSTORAGE_DEFAULTS_KEY, JSON.stringify(cachedDefaults));
|
||||
localStorage.setItem(LOCALSTORAGE_TIMESTAMP_KEY, String(Date.now()));
|
||||
fetchMock.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
renderHarness('music-posting.bso');
|
||||
await flushEffects(8);
|
||||
|
||||
expect(latestSnapshot?.directories.map((community) => community.address)).toEqual(['music-posting.bso']);
|
||||
expect(latestSnapshot?.directoryDefaults.description).toBe('remote defaults');
|
||||
expect(latestSnapshot?.directoryDefaults.directories.mu.title).toBe('/mu/ - Cached Remote Music');
|
||||
expect(warnSpy.mock.calls.some((call: ConsoleWarnCall) => String(call[0]).includes('Failed to fetch directories'))).toBe(true);
|
||||
});
|
||||
|
||||
it('clears invalid recent cache entries and falls back to vendored data when GitHub refresh fails', async () => {
|
||||
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify({ title: 'broken cache' }));
|
||||
localStorage.setItem(LOCALSTORAGE_TIMESTAMP_KEY, String(Date.now()));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import directoryListsData from '../data/5chan-directory-lists.json';
|
||||
import { vendoredDirectoryLists as directoryListsData, vendoredDirectoryDefaults as directoryDefaultsData } from '../data/vendored-directory-lists';
|
||||
import {
|
||||
directoryListToCommunity,
|
||||
isRecord,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from '../lib/utils/directory-list-utils';
|
||||
import { normalizeBoardAddress } from '../lib/utils/directory-list-lookup-utils';
|
||||
|
||||
export type { DirectoriesData, DirectoryCommunity } from '../lib/utils/directory-list-utils';
|
||||
export type { DirectoriesData, DirectoryCommunity, DirectoryDefaultsData } from '../lib/utils/directory-list-utils';
|
||||
export { normalizeBoardAddress };
|
||||
|
||||
interface DirectoriesMetadata {
|
||||
@@ -34,6 +34,7 @@ interface DirectoriesState {
|
||||
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_DEFAULTS_KEY = '5chan-directory-defaults-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
|
||||
@@ -44,6 +45,7 @@ let cacheMetadata: DirectoriesMetadata | null = null;
|
||||
let inFlightGitHubFetch: Promise<DirectoriesData> | null = null;
|
||||
let lastSuccessfulGitHubFetchAt: number | null = null;
|
||||
let lastGitHubFetchAttemptAt: number | null = null;
|
||||
let cacheDefaults: DirectoryDefaultsData | null = null;
|
||||
// Exposed for deterministic unit tests around module-level cache state.
|
||||
export const __resetDirectoriesModuleStateForTests = () => {
|
||||
cacheCommunities = null;
|
||||
@@ -51,7 +53,9 @@ export const __resetDirectoriesModuleStateForTests = () => {
|
||||
inFlightGitHubFetch = null;
|
||||
lastSuccessfulGitHubFetchAt = null;
|
||||
lastGitHubFetchAttemptAt = null;
|
||||
cacheDefaults = null;
|
||||
fallbackDirectoriesData = null;
|
||||
fallbackDirectoryDefaults = null;
|
||||
};
|
||||
|
||||
const getDirectoryIdentifiers = (community: DirectoryCommunity): string[] => [
|
||||
@@ -212,6 +216,13 @@ const normalizeDirectoriesData = (value: unknown): DirectoriesData | null => {
|
||||
};
|
||||
|
||||
let fallbackDirectoriesData: DirectoriesData | null = null;
|
||||
let fallbackDirectoryDefaults: DirectoryDefaultsData | null = null;
|
||||
|
||||
export const getFallbackDirectoryDefaults = (): DirectoryDefaultsData => {
|
||||
if (fallbackDirectoryDefaults) return fallbackDirectoryDefaults;
|
||||
fallbackDirectoryDefaults = normalizeDirectoryDefaultsData(directoryDefaultsData as unknown);
|
||||
return fallbackDirectoryDefaults;
|
||||
};
|
||||
|
||||
export const getFallbackDirectoriesData = (): DirectoriesData => {
|
||||
if (fallbackDirectoriesData) return fallbackDirectoriesData;
|
||||
@@ -224,6 +235,26 @@ export const getFallbackDirectoriesData = (): DirectoriesData => {
|
||||
return fallbackDirectoriesData;
|
||||
};
|
||||
|
||||
const getDirectoryDefaultsFromLocalStorage = (): DirectoryDefaultsData | null => {
|
||||
try {
|
||||
const cached = localStorage.getItem(LOCALSTORAGE_DEFAULTS_KEY);
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = normalizeDirectoryDefaultsData(JSON.parse(cached));
|
||||
if (Object.keys(normalized.directories).length > 0) {
|
||||
return normalized;
|
||||
}
|
||||
console.warn('Invalid directory defaults cache format, clearing stale cache');
|
||||
localStorage.removeItem(LOCALSTORAGE_DEFAULTS_KEY);
|
||||
} catch (e) {
|
||||
console.warn('Failed to read directory defaults from localStorage:', e);
|
||||
localStorage.removeItem(LOCALSTORAGE_DEFAULTS_KEY);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getFromLocalStorage = (): DirectoriesData | null => {
|
||||
try {
|
||||
const cached = localStorage.getItem(LOCALSTORAGE_KEY);
|
||||
@@ -234,10 +265,12 @@ const getFromLocalStorage = (): DirectoriesData | null => {
|
||||
const parsed = JSON.parse(cached);
|
||||
const normalized = normalizeDirectoriesData(parsed);
|
||||
if (normalized) {
|
||||
cacheDefaults ??= getDirectoryDefaultsFromLocalStorage();
|
||||
return normalized;
|
||||
}
|
||||
console.warn('Invalid directories cache format, clearing stale cache');
|
||||
localStorage.removeItem(LOCALSTORAGE_KEY);
|
||||
localStorage.removeItem(LOCALSTORAGE_DEFAULTS_KEY);
|
||||
localStorage.removeItem(LOCALSTORAGE_TIMESTAMP_KEY);
|
||||
}
|
||||
}
|
||||
@@ -247,9 +280,12 @@ const getFromLocalStorage = (): DirectoriesData | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const saveToLocalStorage = (data: DirectoriesData) => {
|
||||
const saveToLocalStorage = (data: DirectoriesData, defaults?: DirectoryDefaultsData) => {
|
||||
try {
|
||||
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(data));
|
||||
if (defaults) {
|
||||
localStorage.setItem(LOCALSTORAGE_DEFAULTS_KEY, JSON.stringify(defaults));
|
||||
}
|
||||
localStorage.setItem(LOCALSTORAGE_TIMESTAMP_KEY, Date.now().toString());
|
||||
} catch (e) {
|
||||
console.warn('Failed to save to localStorage:', e);
|
||||
@@ -356,8 +392,9 @@ const fetchDirectoriesFromGitHub = async (): Promise<DirectoriesData> => {
|
||||
throw new Error('Invalid directories payload');
|
||||
}
|
||||
hydrateModuleCaches(data);
|
||||
cacheDefaults = defaults;
|
||||
lastSuccessfulGitHubFetchAt = Date.now();
|
||||
saveToLocalStorage(data);
|
||||
saveToLocalStorage(data, defaults);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -463,6 +500,12 @@ export const useDirectories = () => {
|
||||
return cacheCommunities || state.communities || getFallbackDirectoriesData().communities;
|
||||
};
|
||||
|
||||
export const useDirectoryDefaults = (): DirectoryDefaultsData => {
|
||||
// Subscribe to the shared directory refresh so defaults update only when the matching directory payload commits.
|
||||
useDirectories();
|
||||
return cacheDefaults ?? getFallbackDirectoryDefaults();
|
||||
};
|
||||
|
||||
export const useDirectoriesState = () => {
|
||||
// Use vendored data as fallback to prevent theme flash on first load
|
||||
const [state, setState] = useState<DirectoriesState>({
|
||||
|
||||
Reference in New Issue
Block a user