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:
@@ -25,7 +25,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
directories: [
|
directories: [
|
||||||
{ address: 'music-posting.eth', title: '/mu/ - Music', nsfw: false },
|
{ address: 'music-posting.eth', title: '/mu/ - Music', nsfw: false },
|
||||||
{ address: 'tech-posting.eth', title: '/g/ - Technology', nsfw: false },
|
{ address: 'tech-posting.eth', title: '/g/ - Technology', nsfw: false },
|
||||||
] as Array<{ address: string; title?: string; nsfw?: boolean }>,
|
] as Array<{ address: string; title?: string; nsfw?: boolean; directoryCode?: string }>,
|
||||||
initSnowMock: vi.fn(),
|
initSnowMock: vi.fn(),
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
isSpecialEnabled: false,
|
isSpecialEnabled: false,
|
||||||
@@ -41,6 +41,8 @@ const testState = vi.hoisted(() => ({
|
|||||||
threadNumber: null,
|
threadNumber: null,
|
||||||
} as ReplyModalShape,
|
} as ReplyModalShape,
|
||||||
resolvedCommunityAddress: undefined as string | undefined,
|
resolvedCommunityAddress: undefined as string | undefined,
|
||||||
|
resolvedDirectoryBoardPath: undefined as string | undefined,
|
||||||
|
isDirectoryCandidate: false,
|
||||||
communities: {} as Record<string, unknown>,
|
communities: {} as Record<string, unknown>,
|
||||||
useThemeMock: vi.fn(),
|
useThemeMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -72,7 +74,11 @@ vi.mock('../hooks/use-is-mobile', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../hooks/use-resolved-community-address', () => ({
|
vi.mock('../hooks/use-resolved-community-address', () => ({
|
||||||
useResolvedCommunityAddress: () => testState.resolvedCommunityAddress,
|
useResolvedCommunityAddress: (boardIdentifier?: string) => testState.resolvedCommunityAddress ?? boardIdentifier,
|
||||||
|
useResolvedDirectoryBoardPath: () => ({
|
||||||
|
boardPath: testState.resolvedDirectoryBoardPath,
|
||||||
|
isDirectoryCandidate: testState.isDirectoryCandidate,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../hooks/use-theme', () => ({
|
vi.mock('../hooks/use-theme', () => ({
|
||||||
@@ -340,6 +346,8 @@ describe('App', () => {
|
|||||||
threadNumber: null,
|
threadNumber: null,
|
||||||
} as ReplyModalShape;
|
} as ReplyModalShape;
|
||||||
testState.resolvedCommunityAddress = undefined;
|
testState.resolvedCommunityAddress = undefined;
|
||||||
|
testState.resolvedDirectoryBoardPath = undefined;
|
||||||
|
testState.isDirectoryCandidate = false;
|
||||||
testState.communities = {};
|
testState.communities = {};
|
||||||
testState.useThemeMock.mockReset();
|
testState.useThemeMock.mockReset();
|
||||||
testState.closeCreateBoardModalMock.mockReset();
|
testState.closeCreateBoardModalMock.mockReset();
|
||||||
@@ -416,6 +424,25 @@ describe('App', () => {
|
|||||||
expect(container.querySelector('[data-testid="post-view"]')).toBeTruthy();
|
expect(container.querySelector('[data-testid="post-view"]')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('canonicalizes a direct route for the current resolved directory board', async () => {
|
||||||
|
testState.resolvedDirectoryBoardPath = 'biz';
|
||||||
|
testState.isDirectoryCandidate = true;
|
||||||
|
|
||||||
|
await renderApp('/bizraelis.bso/thread/comment-1?focus=1');
|
||||||
|
|
||||||
|
expect(latestLocation).toBe('/biz/thread/comment-1?focus=1');
|
||||||
|
expect(container.querySelector('[data-testid="post-view"]')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not canonicalize a directory candidate address when another board is resolved', async () => {
|
||||||
|
testState.directories = [{ address: 'business-and-finance.bso', directoryCode: 'biz', title: '/biz/ - Business & Finance', nsfw: false }];
|
||||||
|
testState.isDirectoryCandidate = true;
|
||||||
|
|
||||||
|
await renderApp('/business-and-finance.bso?focus=1');
|
||||||
|
|
||||||
|
expect(latestLocation).toBe('/business-and-finance.bso?focus=1');
|
||||||
|
});
|
||||||
|
|
||||||
it('routes invalid mod aliases and unknown mod paths to not-found', async () => {
|
it('routes invalid mod aliases and unknown mod paths to not-found', async () => {
|
||||||
await renderApp('/mu/modqueue');
|
await renderApp('/mu/modqueue');
|
||||||
expect(latestLocation).toBe('/not-found');
|
expect(latestLocation).toBe('/not-found');
|
||||||
|
|||||||
+4
-4
@@ -14,12 +14,11 @@ import { useAccountCommunityAddresses } from './hooks/use-account-community-addr
|
|||||||
import useTheme from './hooks/use-theme';
|
import useTheme from './hooks/use-theme';
|
||||||
import { useDirectories } from './hooks/use-directories';
|
import { useDirectories } from './hooks/use-directories';
|
||||||
import { useCommunityIdentifier } from './hooks/use-community-identifiers';
|
import { useCommunityIdentifier } from './hooks/use-community-identifiers';
|
||||||
import { useResolvedCommunityAddress } from './hooks/use-resolved-community-address';
|
import { useResolvedCommunityAddress, useResolvedDirectoryBoardPath } from './hooks/use-resolved-community-address';
|
||||||
import useSafeAccountComment from './hooks/use-safe-account-comment';
|
import useSafeAccountComment from './hooks/use-safe-account-comment';
|
||||||
import { getCommentCommunityAddress } from './lib/utils/comment-utils';
|
import { getCommentCommunityAddress } from './lib/utils/comment-utils';
|
||||||
import {
|
import {
|
||||||
getBoardPath,
|
getBoardPath,
|
||||||
getCommunityAddress,
|
|
||||||
isBoardModRoute,
|
isBoardModRoute,
|
||||||
isDirectoryBoard,
|
isDirectoryBoard,
|
||||||
isArchiveRoute,
|
isArchiveRoute,
|
||||||
@@ -77,7 +76,8 @@ const BoardLayout = () => {
|
|||||||
const isInSubscriptionsView = isSubscriptionsView(pathname, useParams());
|
const isInSubscriptionsView = isSubscriptionsView(pathname, useParams());
|
||||||
const isInModView = isModView(pathname);
|
const isInModView = isModView(pathname);
|
||||||
const directories = useDirectories();
|
const directories = useDirectories();
|
||||||
const communityAddress = boardIdentifier ? getCommunityAddress(boardIdentifier, directories) : undefined;
|
const communityAddress = useResolvedCommunityAddress(boardIdentifier);
|
||||||
|
const { boardPath: resolvedDirectoryBoardPath, isDirectoryCandidate } = useResolvedDirectoryBoardPath(boardIdentifier);
|
||||||
const pendingPost = useSafeAccountComment({ commentIndex: accountCommentIndex });
|
const pendingPost = useSafeAccountComment({ commentIndex: accountCommentIndex });
|
||||||
const pendingPostCommunityAddress = getCommentCommunityAddress(pendingPost);
|
const pendingPostCommunityAddress = getCommentCommunityAddress(pendingPost);
|
||||||
const { closeCreateBoardModal } = useCreateBoardModalStore();
|
const { closeCreateBoardModal } = useCreateBoardModalStore();
|
||||||
@@ -128,7 +128,7 @@ const BoardLayout = () => {
|
|||||||
|
|
||||||
// Normalize address URLs to directory codes: /anime-and-manga.eth/thread/xxx -> /a/thread/xxx
|
// Normalize address URLs to directory codes: /anime-and-manga.eth/thread/xxx -> /a/thread/xxx
|
||||||
if (boardIdentifier && !isDirectoryBoard(boardIdentifier, directories)) {
|
if (boardIdentifier && !isDirectoryBoard(boardIdentifier, directories)) {
|
||||||
const canonicalBoardIdentifier = getBoardPath(boardIdentifier, directories);
|
const canonicalBoardIdentifier = resolvedDirectoryBoardPath ?? (isDirectoryCandidate ? boardIdentifier : getBoardPath(boardIdentifier, directories));
|
||||||
if (canonicalBoardIdentifier !== boardIdentifier) {
|
if (canonicalBoardIdentifier !== boardIdentifier) {
|
||||||
const canonicalPath = pathname.replace(`/${boardIdentifier}`, `/${canonicalBoardIdentifier}`);
|
const canonicalPath = pathname.replace(`/${boardIdentifier}`, `/${canonicalBoardIdentifier}`);
|
||||||
return <Navigate to={canonicalPath + (search || '')} replace />;
|
return <Navigate to={canonicalPath + (search || '')} replace />;
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import * as React from 'react';
|
|||||||
import { createElement } from 'react';
|
import { createElement } from 'react';
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
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;
|
(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 act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||||
|
|
||||||
const testState = vi.hoisted(() => ({
|
const testState = vi.hoisted(() => ({
|
||||||
boardIdentifier: 'biz',
|
boardIdentifier: 'biz',
|
||||||
|
boardIdentifierOverride: undefined as string | undefined,
|
||||||
directories: [
|
directories: [
|
||||||
{
|
{
|
||||||
address: 'business-and-finance.bso',
|
address: 'business-and-finance.bso',
|
||||||
@@ -20,7 +21,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
directoryCode: 'biz',
|
directoryCode: 'biz',
|
||||||
boards: [
|
boards: [
|
||||||
{ address: 'business-and-finance.bso', score: 100 },
|
{ 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 }>,
|
offlineStates: {} as Record<string, { updatedAt?: number; state?: string }>,
|
||||||
@@ -37,6 +38,7 @@ vi.mock('react-router-dom', async () => {
|
|||||||
|
|
||||||
vi.mock('../use-directories', () => ({
|
vi.mock('../use-directories', () => ({
|
||||||
useDirectories: () => testState.directories,
|
useDirectories: () => testState.directories,
|
||||||
|
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../use-directory-list', async () => {
|
vi.mock('../use-directory-list', async () => {
|
||||||
@@ -56,11 +58,13 @@ vi.mock('../../stores/use-community-offline-store', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let latestValue: string | undefined;
|
let latestValue: string | undefined;
|
||||||
|
let latestDirectoryBoardPath: { boardPath: string | undefined; isDirectoryCandidate: boolean };
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
|
|
||||||
const HookHarness = () => {
|
const HookHarness = () => {
|
||||||
latestValue = useResolvedCommunityAddress();
|
latestValue = useResolvedCommunityAddress(testState.boardIdentifierOverride);
|
||||||
|
latestDirectoryBoardPath = useResolvedDirectoryBoardPath(testState.boardIdentifier);
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,7 +79,9 @@ describe('useResolvedCommunityAddress', () => {
|
|||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date('2024-01-01T00:00:10Z'));
|
vi.setSystemTime(new Date('2024-01-01T00:00:10Z'));
|
||||||
latestValue = undefined;
|
latestValue = undefined;
|
||||||
|
latestDirectoryBoardPath = { boardPath: undefined, isDirectoryCandidate: false };
|
||||||
testState.boardIdentifier = 'biz';
|
testState.boardIdentifier = 'biz';
|
||||||
|
testState.boardIdentifierOverride = undefined;
|
||||||
testState.offlineStates = {};
|
testState.offlineStates = {};
|
||||||
testState.offlineSelections = [];
|
testState.offlineSelections = [];
|
||||||
|
|
||||||
@@ -99,7 +105,7 @@ describe('useResolvedCommunityAddress', () => {
|
|||||||
|
|
||||||
await renderHook();
|
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 () => {
|
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();
|
await renderHook();
|
||||||
|
|
||||||
expect(latestValue).toBe('custom-board.bso');
|
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 () => {
|
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);
|
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 { 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 { 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';
|
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 inFlightFetches = new Map<string, Promise<DirectoryList | null>>();
|
||||||
const lastFetchSuccessAt = new Map<string, number>();
|
const lastFetchSuccessAt = new Map<string, number>();
|
||||||
const lastFetchAttemptAt = 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 synthesizeFromMainDirectory = (directoryCode: string, directories: DirectoryCommunity[]): DirectoryList | null => {
|
||||||
const match = directories.find((community) => community.directoryCode === directoryCode);
|
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 => ({
|
const mergeDirectoryListDefaults = (list: DirectoryList, fallback: DirectoryList | null): DirectoryList => ({
|
||||||
...list,
|
...list,
|
||||||
...(list.title || !fallback?.title ? {} : { title: fallback.title }),
|
...(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').
|
* 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
|
* Source: `bitsocialnet/lists/5chan-directories/5chan-{code}-directory.json`. While the network
|
||||||
* or the file is not yet published, falls back to a synthesized single-entry list derived
|
* is unavailable or the remote file is still loading, falls back to the vendored list
|
||||||
* from the merged directory assignments.
|
* generated from that repo, then to a synthesized single-entry list from directory assignments.
|
||||||
*/
|
*/
|
||||||
export const useDirectoryList = (directoryCode: string | undefined): DirectoryListState => {
|
export const useDirectoryList = (directoryCode: string | undefined): DirectoryListState => {
|
||||||
const directories = useDirectories();
|
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>(() => {
|
const [state, setState] = useState<DirectoryListState>(() => {
|
||||||
if (!directoryCode) {
|
if (!directoryCode) {
|
||||||
@@ -233,7 +264,7 @@ export const useDirectoryLists = (directoryCodes: string[] | undefined): Directo
|
|||||||
|
|
||||||
const fallbackByCode = useMemo(() => {
|
const fallbackByCode = useMemo(() => {
|
||||||
const normalizedDirectoryCodes = directoryCodesKey ? directoryCodesKey.split('\0') : [];
|
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,
|
string,
|
||||||
DirectoryList | null
|
DirectoryList | null
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useDirectories } from './use-directories';
|
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 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 { isCommunityKnownOffline } from '../lib/utils/community-freshness-utils';
|
||||||
import { useNowSeconds } from './use-now-seconds';
|
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
|
* 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
|
* 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 params = useParams<{ boardIdentifier?: string }>();
|
||||||
const directories = useDirectories();
|
const directories = useDirectories();
|
||||||
const boardIdentifier = params.boardIdentifier;
|
const boardIdentifier = boardIdentifierOverride ?? params.boardIdentifier;
|
||||||
const isCode = !!boardIdentifier && isDirectoryRoute(boardIdentifier, directories);
|
const isCode = !!boardIdentifier && isDirectoryRoute(boardIdentifier, directories);
|
||||||
const { list } = useDirectoryList(isCode ? boardIdentifier : undefined);
|
const { list } = useDirectoryList(isCode ? boardIdentifier : undefined);
|
||||||
const offlineStates = useCommunityOfflineStore((state) => (isCode ? state.communityOfflineState : undefined));
|
const offlineStates = useCommunityOfflineStore((state) => (isCode ? state.communityOfflineState : undefined));
|
||||||
@@ -34,6 +39,37 @@ export const useResolvedCommunityAddress = (): string | undefined => {
|
|||||||
}, [boardIdentifier, directories, isCode, list, offlineStates, nowSeconds]);
|
}, [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.
|
* Resolve a community address to board path (directory code or address) for links.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import LoadingEllipsis from '../../components/loading-ellipsis';
|
|||||||
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
|
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
|
||||||
import { useCommunityField } from '../../hooks/use-stable-community';
|
import { useCommunityField } from '../../hooks/use-stable-community';
|
||||||
import { useFeedStateString } from '../../hooks/use-state-string';
|
import { useFeedStateString } from '../../hooks/use-state-string';
|
||||||
import { getCommunityAddress, getBoardPath } from '../../lib/utils/route-utils';
|
import { getBoardPath } from '../../lib/utils/route-utils';
|
||||||
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
||||||
import { removeMarkdown } from '../../lib/utils/post-utils';
|
import { removeMarkdown } from '../../lib/utils/post-utils';
|
||||||
import { useDirectories } from '../../hooks/use-directories';
|
import { useDirectories } from '../../hooks/use-directories';
|
||||||
@@ -151,13 +151,7 @@ const Archive = () => {
|
|||||||
const boardIdentifier = params.boardIdentifier;
|
const boardIdentifier = params.boardIdentifier;
|
||||||
const directories = useDirectories();
|
const directories = useDirectories();
|
||||||
|
|
||||||
const resolvedAddressFromUrl = useResolvedCommunityAddress();
|
const communityAddress = useResolvedCommunityAddress(boardIdentifier);
|
||||||
const communityAddress = useMemo(() => {
|
|
||||||
if (boardIdentifier) {
|
|
||||||
return getCommunityAddress(boardIdentifier, directories);
|
|
||||||
}
|
|
||||||
return resolvedAddressFromUrl;
|
|
||||||
}, [boardIdentifier, directories, resolvedAddressFromUrl]);
|
|
||||||
|
|
||||||
const boardPath = useMemo(() => {
|
const boardPath = useMemo(() => {
|
||||||
if (!communityAddress) {
|
if (!communityAddress) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
accountComments: [] as TestComment[],
|
accountComments: [] as TestComment[],
|
||||||
accountCommentsCalls: [] as Array<{ commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' } | undefined>,
|
accountCommentsCalls: [] as Array<{ commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' } | undefined>,
|
||||||
accountCommunityAddresses: [] as string[],
|
accountCommunityAddresses: [] as string[],
|
||||||
directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>,
|
directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string; directoryCode?: string }>,
|
||||||
directoryByAddress: {
|
directoryByAddress: {
|
||||||
'music-posting.eth': {
|
'music-posting.eth': {
|
||||||
address: 'music-posting.eth',
|
address: 'music-posting.eth',
|
||||||
@@ -33,7 +33,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
},
|
},
|
||||||
} as Record<string, { address: string; features?: Record<string, unknown> }>,
|
} as Record<string, { address: string; features?: Record<string, unknown> }>,
|
||||||
feed: [] as TestComment[],
|
feed: [] as TestComment[],
|
||||||
feedOptionsCalls: [] as Array<{ communitiesLength?: number; newerThan?: number; postsPerPage?: number; sortType?: string }>,
|
feedOptionsCalls: [] as Array<{ communities?: unknown[]; communitiesLength?: number; newerThan?: number; postsPerPage?: number; sortType?: string }>,
|
||||||
feedState: undefined as string | undefined,
|
feedState: undefined as string | undefined,
|
||||||
feedStateString: 'syncing' as string | undefined,
|
feedStateString: 'syncing' as string | undefined,
|
||||||
filteredDirectoryAddresses: ['music-posting.eth'] as string[],
|
filteredDirectoryAddresses: ['music-posting.eth'] as string[],
|
||||||
@@ -129,6 +129,7 @@ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
|
|||||||
sortType?: string;
|
sortType?: string;
|
||||||
}) => {
|
}) => {
|
||||||
testState.feedOptionsCalls.push({
|
testState.feedOptionsCalls.push({
|
||||||
|
communities: options?.communities,
|
||||||
communitiesLength: options?.communities?.length,
|
communitiesLength: options?.communities?.length,
|
||||||
newerThan: options?.newerThan,
|
newerThan: options?.newerThan,
|
||||||
postsPerPage: options?.postsPerPage,
|
postsPerPage: options?.postsPerPage,
|
||||||
@@ -379,6 +380,25 @@ describe('Board', () => {
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the resolved directory winner for cached board feeds', async () => {
|
||||||
|
testState.directories = [{ address: 'business-and-finance.bso', directoryCode: 'biz', title: '/biz/ - Business & Finance' }];
|
||||||
|
testState.directoryByAddress = {
|
||||||
|
'bizraelis.bso': {
|
||||||
|
address: 'bizraelis.bso',
|
||||||
|
features: { postsPerPage: 2 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
testState.resolvedCommunityAddress = 'bizraelis.bso';
|
||||||
|
|
||||||
|
await renderBoard({
|
||||||
|
boardProps: { boardIdentifier: 'biz', viewType: 'board' },
|
||||||
|
initialEntry: '/biz',
|
||||||
|
routePath: '/:boardIdentifier/*',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(testState.feedOptionsCalls.map((call) => call.communities)).toContainEqual([{ name: 'bizraelis.bso' }]);
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the current page feed, inserts recent account comments, and wires footer actions', async () => {
|
it('renders the current page feed, inserts recent account comments, and wires footer actions', async () => {
|
||||||
const currentTimestamp = Math.floor(Date.now() / 1000);
|
const currentTimestamp = Math.floor(Date.now() / 1000);
|
||||||
testState.feed = [
|
testState.feed = [
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import useIsMobile from '../../hooks/use-is-mobile';
|
|||||||
import { useSuggestionFeedLoader } from '../../hooks/use-suggestion-feed-loader';
|
import { useSuggestionFeedLoader } from '../../hooks/use-suggestion-feed-loader';
|
||||||
import useTimeFilter from '../../hooks/use-time-filter';
|
import useTimeFilter from '../../hooks/use-time-filter';
|
||||||
import { getPageSlice } from '../../lib/utils/board-feed-pagination';
|
import { getPageSlice } from '../../lib/utils/board-feed-pagination';
|
||||||
import { getPageFromFeedPath, getCommunityAddress, isDirectoryBoard, normalizeMultiboardFeedPath, stripPageFromFeedPath } from '../../lib/utils/route-utils';
|
import { getPageFromFeedPath, isDirectoryBoard, normalizeMultiboardFeedPath, stripPageFromFeedPath } from '../../lib/utils/route-utils';
|
||||||
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
||||||
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
|
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
|
||||||
import { getSearchWithTimeFilter, getTimeFilterSuggestion, type TimeFilterSuggestion } from '../../lib/utils/time-filter-utils';
|
import { getSearchWithTimeFilter, getTimeFilterSuggestion, type TimeFilterSuggestion } from '../../lib/utils/time-filter-utils';
|
||||||
@@ -166,13 +166,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
|
|||||||
const multiboardTimeFilterSeconds = isMultiboardView ? timeFilterSeconds : undefined;
|
const multiboardTimeFilterSeconds = isMultiboardView ? timeFilterSeconds : undefined;
|
||||||
|
|
||||||
const directories = useDirectories();
|
const directories = useDirectories();
|
||||||
const resolvedAddressFromUrl = useResolvedCommunityAddress();
|
const communityAddress = useResolvedCommunityAddress(boardIdentifierProp);
|
||||||
const communityAddress = useMemo(() => {
|
|
||||||
if (boardIdentifierProp) {
|
|
||||||
return getCommunityAddress(boardIdentifierProp, directories);
|
|
||||||
}
|
|
||||||
return resolvedAddressFromUrl;
|
|
||||||
}, [boardIdentifierProp, directories, resolvedAddressFromUrl]);
|
|
||||||
|
|
||||||
const filteredDirectoryAddresses = useFilteredDirectoryAddresses();
|
const filteredDirectoryAddresses = useFilteredDirectoryAddresses();
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import useFeedResetStore from '../../stores/use-feed-reset-store';
|
|||||||
import useHiddenCatalogThreadsStore from '../../stores/use-hidden-catalog-threads-store';
|
import useHiddenCatalogThreadsStore from '../../stores/use-hidden-catalog-threads-store';
|
||||||
import useSortingStore from '../../stores/use-sorting-store';
|
import useSortingStore from '../../stores/use-sorting-store';
|
||||||
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
||||||
import { getCommunityAddress, isDirectoryBoard, normalizeMultiboardFeedPath } from '../../lib/utils/route-utils';
|
import { isDirectoryBoard, normalizeMultiboardFeedPath } from '../../lib/utils/route-utils';
|
||||||
import CatalogRow from '../../components/catalog-row';
|
import CatalogRow from '../../components/catalog-row';
|
||||||
import { CatalogFooterFirstRow, CatalogFooterStyleRow, PageFooterDesktop, PageFooterMobile } from '../../components/footer';
|
import { CatalogFooterFirstRow, CatalogFooterStyleRow, PageFooterDesktop, PageFooterMobile } from '../../components/footer';
|
||||||
import { ReturnButton, ArchiveButton, TopButton, RefreshButton } from '../../components/board-buttons/board-buttons';
|
import { ReturnButton, ArchiveButton, TopButton, RefreshButton } from '../../components/board-buttons/board-buttons';
|
||||||
@@ -285,13 +285,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
const effectiveInfiniteScroll = isMultiboard;
|
const effectiveInfiniteScroll = isMultiboard;
|
||||||
|
|
||||||
const directories = useDirectories();
|
const directories = useDirectories();
|
||||||
const resolvedAddressFromUrl = useResolvedCommunityAddress();
|
const communityAddress = useResolvedCommunityAddress(boardIdentifierProp);
|
||||||
const communityAddress = useMemo(() => {
|
|
||||||
if (boardIdentifierProp) {
|
|
||||||
return getCommunityAddress(boardIdentifierProp, directories);
|
|
||||||
}
|
|
||||||
return resolvedAddressFromUrl;
|
|
||||||
}, [boardIdentifierProp, directories, resolvedAddressFromUrl]);
|
|
||||||
|
|
||||||
const filterItems = useCatalogFiltersStore((state) => state.filterItems);
|
const filterItems = useCatalogFiltersStore((state) => state.filterItems);
|
||||||
const searchText = useCatalogFiltersStore((state) => state.searchText);
|
const searchText = useCatalogFiltersStore((state) => state.searchText);
|
||||||
|
|||||||
Reference in New Issue
Block a user