2026-03-07 13:48:18 +08:00
|
|
|
|
import { DirectoryCommunity, findDirectoryByAddress, normalizeBoardAddress } from '../../hooks/use-directories';
|
2026-04-18 15:58:27 +07:00
|
|
|
|
import { getEffectiveTimeFilterName, getSearchWithTimeFilter } from './time-filter-utils';
|
2025-11-07 12:49:49 +01:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Extract directory short code from title (e.g., "/biz/ - Business & Finance" -> "biz")
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const extractDirectoryFromTitle = (title: string): string | null => {
|
|
|
|
|
|
const match = title.match(/^\/([^/]+)\//);
|
|
|
|
|
|
return match ? match[1] : null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-07 18:34:06 +01:00
|
|
|
|
// Cache for directory-to-address map
|
2026-01-28 16:51:27 +08:00
|
|
|
|
let cachedCommunitiesForDirectory: DirectoryCommunity[] | null = null;
|
2025-11-07 12:49:49 +01:00
|
|
|
|
let cachedDirectoryToAddressMap: Map<string, string> | null = null;
|
2025-11-07 18:34:06 +01:00
|
|
|
|
|
|
|
|
|
|
// Cache for address-to-directory map
|
2026-01-28 16:51:27 +08:00
|
|
|
|
let cachedCommunitiesForAddress: DirectoryCommunity[] | null = null;
|
2025-11-07 12:49:49 +01:00
|
|
|
|
let cachedAddressToDirectoryMap: Map<string, string> | null = null;
|
|
|
|
|
|
|
2026-03-07 13:48:18 +08:00
|
|
|
|
const getDirectoryCode = (community: DirectoryCommunity): string | null => community.directoryCode ?? extractDirectoryFromTitle(community.title ?? '');
|
|
|
|
|
|
|
2026-04-13 14:54:23 +07:00
|
|
|
|
const getCommunityLookupAddresses = (community: DirectoryCommunity): string[] => [
|
|
|
|
|
|
...new Set([community.address, community.name, community.publicKey].filter((value): value is string => typeof value === 'string' && value.length > 0)),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2025-11-07 12:49:49 +01:00
|
|
|
|
/**
|
2026-01-28 16:51:27 +08:00
|
|
|
|
* Create a map from directory codes to community addresses
|
|
|
|
|
|
* Uses caching to avoid recreating the map when communities array hasn't changed
|
2025-11-07 12:49:49 +01:00
|
|
|
|
*/
|
2026-02-24 15:15:38 +08:00
|
|
|
|
const getDirectoryToAddressMap = (communities: DirectoryCommunity[]): Map<string, string> => {
|
2025-11-07 12:49:49 +01:00
|
|
|
|
// Check if we can use cached map (same array reference)
|
2026-01-28 16:51:27 +08:00
|
|
|
|
if (cachedDirectoryToAddressMap && cachedCommunitiesForDirectory === communities) {
|
2025-11-07 12:49:49 +01:00
|
|
|
|
return cachedDirectoryToAddressMap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const map = new Map<string, string>();
|
2026-01-28 16:51:27 +08:00
|
|
|
|
for (const community of communities) {
|
2026-03-07 13:48:18 +08:00
|
|
|
|
if (!community.address) continue;
|
|
|
|
|
|
const directory = getDirectoryCode(community);
|
|
|
|
|
|
if (directory) {
|
|
|
|
|
|
map.set(directory, community.address);
|
2025-11-07 12:49:49 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cache the map and array reference
|
|
|
|
|
|
cachedDirectoryToAddressMap = map;
|
2026-01-28 16:51:27 +08:00
|
|
|
|
cachedCommunitiesForDirectory = communities;
|
2025-11-07 12:49:49 +01:00
|
|
|
|
return map;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-28 16:51:27 +08:00
|
|
|
|
* Create a map from community addresses to directory codes
|
|
|
|
|
|
* Uses caching to avoid recreating the map when communities array hasn't changed
|
2025-11-07 12:49:49 +01:00
|
|
|
|
*/
|
2026-02-24 15:15:38 +08:00
|
|
|
|
const getAddressToDirectoryMap = (communities: DirectoryCommunity[]): Map<string, string> => {
|
2025-11-07 12:49:49 +01:00
|
|
|
|
// Check if we can use cached map (same array reference)
|
2026-01-28 16:51:27 +08:00
|
|
|
|
if (cachedAddressToDirectoryMap && cachedCommunitiesForAddress === communities) {
|
2025-11-07 12:49:49 +01:00
|
|
|
|
return cachedAddressToDirectoryMap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const map = new Map<string, string>();
|
2026-01-28 16:51:27 +08:00
|
|
|
|
for (const community of communities) {
|
2026-03-07 13:48:18 +08:00
|
|
|
|
if (!community.address) continue;
|
|
|
|
|
|
const directory = getDirectoryCode(community);
|
|
|
|
|
|
if (directory) {
|
2026-04-13 14:54:23 +07:00
|
|
|
|
for (const address of getCommunityLookupAddresses(community)) {
|
|
|
|
|
|
map.set(address, directory);
|
|
|
|
|
|
}
|
2025-11-07 12:49:49 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Cache the map and array reference
|
|
|
|
|
|
cachedAddressToDirectoryMap = map;
|
2026-01-28 16:51:27 +08:00
|
|
|
|
cachedCommunitiesForAddress = communities;
|
2025-11-07 12:49:49 +01:00
|
|
|
|
return map;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-07 13:48:18 +08:00
|
|
|
|
* Convert community address to URL path (directory code if available, otherwise full address).
|
|
|
|
|
|
* Uses findDirectoryByAddress for alias resolution (.bso/.eth) so music-posting.eth maps to mu.
|
2025-11-07 12:49:49 +01:00
|
|
|
|
*/
|
2026-01-28 16:51:27 +08:00
|
|
|
|
export const getBoardPath = (communityAddress: string, communities: DirectoryCommunity[]): string => {
|
|
|
|
|
|
const addressToDirectory = getAddressToDirectoryMap(communities);
|
2026-03-07 13:48:18 +08:00
|
|
|
|
let directory = addressToDirectory.get(communityAddress);
|
|
|
|
|
|
if (!directory) {
|
|
|
|
|
|
const entry = findDirectoryByAddress(communities, communityAddress);
|
|
|
|
|
|
directory = entry ? (getDirectoryCode(entry) ?? undefined) : undefined;
|
|
|
|
|
|
}
|
2026-01-28 16:51:27 +08:00
|
|
|
|
return directory || communityAddress;
|
2025-11-07 12:49:49 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-28 16:51:27 +08:00
|
|
|
|
* Convert URL path (directory code or address) to community address
|
2025-11-07 12:49:49 +01:00
|
|
|
|
*/
|
2026-03-13 13:25:10 +08:00
|
|
|
|
export const getCommunityAddress = (boardIdentifier: string, communities: DirectoryCommunity[]): string => {
|
2026-01-28 16:51:27 +08:00
|
|
|
|
const directoryToAddress = getDirectoryToAddressMap(communities);
|
2025-11-07 12:49:49 +01:00
|
|
|
|
|
|
|
|
|
|
// Check if it's a directory code
|
|
|
|
|
|
const address = directoryToAddress.get(boardIdentifier);
|
|
|
|
|
|
if (address) {
|
|
|
|
|
|
return address;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Otherwise, assume it's already an address
|
|
|
|
|
|
return boardIdentifier;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-07 13:48:18 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Compare two addresses; returns true if they refer to the same board (handles .bso/.eth aliases).
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const areSameBoardAddress = (a: string | undefined, b: string | undefined): boolean => {
|
|
|
|
|
|
if (!a || !b) return false;
|
|
|
|
|
|
if (a === b) return true;
|
|
|
|
|
|
return normalizeBoardAddress(a) === normalizeBoardAddress(b);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-07 12:49:49 +01:00
|
|
|
|
/**
|
2026-05-19 23:34:33 +07:00
|
|
|
|
* True when the URL board segment is a directory short code (e.g. /biz), not a full board address (e.g. /board.bso).
|
2025-11-07 12:49:49 +01:00
|
|
|
|
*/
|
2026-05-19 23:34:33 +07:00
|
|
|
|
export const isDirectoryRoute = (boardIdentifier: string, communities: DirectoryCommunity[]): boolean => {
|
2026-01-28 16:51:27 +08:00
|
|
|
|
const directoryToAddress = getDirectoryToAddressMap(communities);
|
2026-05-19 23:34:33 +07:00
|
|
|
|
return directoryToAddress.has(boardIdentifier);
|
2025-11-07 12:49:49 +01:00
|
|
|
|
};
|
2025-12-08 22:40:16 +01:00
|
|
|
|
|
2026-05-19 23:34:33 +07:00
|
|
|
|
/** @deprecated Use {@link isDirectoryRoute} */
|
|
|
|
|
|
export const isDirectoryBoard = isDirectoryRoute;
|
|
|
|
|
|
|
2026-03-13 16:06:36 +08:00
|
|
|
|
export const isArchiveRoute = (pathname: string): boolean => {
|
|
|
|
|
|
const normalizedPath = pathname.replace(/\/settings$/, '').replace(/\/$/, '');
|
2026-05-10 18:25:13 +07:00
|
|
|
|
return normalizedPath.endsWith('/archive');
|
2026-03-13 16:06:36 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-19 23:34:33 +07:00
|
|
|
|
export const isDirectoryListRoute = (pathname: string): boolean => {
|
|
|
|
|
|
const normalizedPath = pathname.replace(/\/settings$/, '').replace(/\/$/, '');
|
|
|
|
|
|
return normalizedPath.endsWith('/directory');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-08 22:40:16 +01:00
|
|
|
|
export const isFeedRoute = (pathname: string): boolean => {
|
|
|
|
|
|
const normalizedPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
|
|
|
|
|
|
|
|
|
|
|
if (normalizedPath.includes('/thread/')) return false;
|
|
|
|
|
|
if (normalizedPath.startsWith('/pending/')) return false;
|
2026-03-13 16:06:36 +08:00
|
|
|
|
if (isArchiveRoute(normalizedPath)) return false;
|
2026-05-19 23:34:33 +07:00
|
|
|
|
if (isDirectoryListRoute(normalizedPath)) return false;
|
2026-03-07 16:02:19 +08:00
|
|
|
|
if (isBoardModRoute(normalizedPath) || isModQueueRoute(normalizedPath)) return false;
|
2025-12-08 22:40:16 +01:00
|
|
|
|
|
|
|
|
|
|
const pathWithoutSettings = normalizedPath.replace(/\/settings$/, '');
|
|
|
|
|
|
const segments = pathWithoutSettings.split('/').filter(Boolean);
|
|
|
|
|
|
if (segments.length >= 1) {
|
|
|
|
|
|
if (segments.length === 1) return true;
|
|
|
|
|
|
if (segments.length === 2 && segments[1] === 'catalog') return true;
|
2026-02-22 15:57:42 +08:00
|
|
|
|
if (segments.length === 2 && /^([1-9]|10)$/.test(segments[1])) return true;
|
|
|
|
|
|
if (segments.length === 3 && segments[1] === 'catalog' && /^([1-9]|10)$/.test(segments[2])) return true;
|
2025-12-08 22:40:16 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const isPostRoute = (pathname: string): boolean => {
|
|
|
|
|
|
const normalizedPath = pathname.replace(/\/settings$/, '');
|
|
|
|
|
|
|
|
|
|
|
|
if (normalizedPath.includes('/thread/')) return true;
|
|
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const isPendingPostRoute = (pathname: string): boolean => {
|
|
|
|
|
|
const normalizedPath = pathname.replace(/\/settings$/, '');
|
|
|
|
|
|
return normalizedPath.startsWith('/pending/');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-07 16:02:19 +08:00
|
|
|
|
export const isBoardModRoute = (pathname: string): boolean => {
|
|
|
|
|
|
const normalizedPath = pathname.replace(/\/$/, '');
|
|
|
|
|
|
return /^\/[^/]+\/mod(?:\/.*)?$/.test(normalizedPath);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const isLegacyBoardModQueueRoute = (pathname: string): boolean => {
|
|
|
|
|
|
const normalizedPath = pathname.replace(/\/$/, '');
|
|
|
|
|
|
return /^\/[^/]+\/modqueue(?:\/settings)?$/.test(normalizedPath);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-07 16:03:02 +01:00
|
|
|
|
export const isModQueueRoute = (pathname: string): boolean => {
|
2026-03-07 16:02:19 +08:00
|
|
|
|
const normalizedPath = pathname.replace(/\/settings$/, '').replace(/\/$/, '');
|
|
|
|
|
|
return normalizedPath === '/mod/queue' || /^\/[^/]+\/mod\/queue$/.test(normalizedPath);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const VALID_MOD_PATHS = ['/mod', '/mod/settings', '/mod/catalog', '/mod/catalog/settings', '/mod/queue', '/mod/queue/settings'];
|
|
|
|
|
|
const VALID_BOARD_MOD_SUBPATHS = ['queue', 'queue/settings'];
|
|
|
|
|
|
|
|
|
|
|
|
export const isValidModRoute = (pathname: string): boolean => {
|
|
|
|
|
|
const normalized = pathname.replace(/\/$/, '');
|
|
|
|
|
|
return VALID_MOD_PATHS.includes(normalized);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const isValidBoardModRoute = (pathname: string): boolean => {
|
|
|
|
|
|
const normalizedPath = pathname.replace(/\/$/, '');
|
|
|
|
|
|
const match = normalizedPath.match(/^\/[^/]+\/mod(?:\/(.*))?$/);
|
|
|
|
|
|
if (!match) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const subpath = match[1] ?? '';
|
|
|
|
|
|
return VALID_BOARD_MOD_SUBPATHS.includes(subpath);
|
2026-01-07 16:03:02 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-20 20:16:11 +08:00
|
|
|
|
/** Page numbers 1–10 for board feed pagination */
|
2026-02-24 15:15:38 +08:00
|
|
|
|
const BOARD_PAGE_REGEX = /^([1-9]|10)$/;
|
2026-02-20 20:16:11 +08:00
|
|
|
|
|
2026-02-24 15:15:38 +08:00
|
|
|
|
const isBoardFeedPageNumber = (segment: string): boolean => BOARD_PAGE_REGEX.test(segment);
|
2026-02-20 20:16:11 +08:00
|
|
|
|
|
2026-02-22 14:24:45 +08:00
|
|
|
|
/** Internal: check if segment is a multiboard root (all, subs, mod) */
|
|
|
|
|
|
function isMultiboardRoot(segment: string): boolean {
|
|
|
|
|
|
return segment === 'all' || segment === 'subs' || segment === 'mod';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Internal: check if pathname is a multiboard feed path (starts with /all, /subs, or /mod) */
|
|
|
|
|
|
function isMultiboardFeedPath(pathname: string): boolean {
|
|
|
|
|
|
const trimmed = pathname.replace(/\/$/, '');
|
|
|
|
|
|
const segments = trimmed.split('/').filter(Boolean);
|
|
|
|
|
|
return segments.length > 0 && isMultiboardRoot(segments[0]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-22 15:57:42 +08:00
|
|
|
|
* Normalize multiboard feed paths by removing trailing page-number segments (1–10)
|
|
|
|
|
|
* and preserving /settings.
|
2026-02-22 14:24:45 +08:00
|
|
|
|
* Non-multiboard paths are returned unchanged.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const normalizeMultiboardFeedPath = (pathname: string): string => {
|
|
|
|
|
|
let path = pathname.replace(/\/$/, '');
|
|
|
|
|
|
if (!isMultiboardFeedPath(path)) {
|
|
|
|
|
|
return pathname;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const hasSettings = path.endsWith('/settings');
|
|
|
|
|
|
if (hasSettings) {
|
|
|
|
|
|
path = path.replace(/\/settings$/, '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const segments = path.split('/').filter(Boolean);
|
|
|
|
|
|
if (segments.length > 1 && isBoardFeedPageNumber(segments[segments.length - 1])) {
|
|
|
|
|
|
const newPath = '/' + segments.slice(0, -1).join('/');
|
|
|
|
|
|
return hasSettings ? newPath + '/settings' : newPath;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return hasSettings ? path + '/settings' : path;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-20 20:16:11 +08:00
|
|
|
|
/** Strip trailing page number (1–10) from path for feed cache key */
|
|
|
|
|
|
export const stripPageFromFeedPath = (path: string): string => {
|
|
|
|
|
|
const segments = path.split('/').filter(Boolean);
|
|
|
|
|
|
if (segments.length > 1 && isBoardFeedPageNumber(segments[segments.length - 1])) {
|
|
|
|
|
|
return '/' + segments.slice(0, -1).join('/');
|
|
|
|
|
|
}
|
|
|
|
|
|
return path;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** Parse current page number (1–10) from feed pathname; returns 1 if none */
|
|
|
|
|
|
export const getPageFromFeedPath = (pathname: string): number => {
|
|
|
|
|
|
const normalized = pathname.replace(/\/settings$/, '').replace(/\/$/, '');
|
|
|
|
|
|
const segments = normalized.split('/').filter(Boolean);
|
|
|
|
|
|
const last = segments[segments.length - 1];
|
|
|
|
|
|
if (last && isBoardFeedPageNumber(last)) {
|
|
|
|
|
|
const n = parseInt(last, 10);
|
|
|
|
|
|
return Math.min(10, Math.max(1, n));
|
|
|
|
|
|
}
|
|
|
|
|
|
return 1;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-18 15:58:27 +07:00
|
|
|
|
export const getFeedCacheKey = (pathname: string, search = ''): string | null => {
|
2025-12-08 22:40:16 +01:00
|
|
|
|
let normalizedPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
|
|
|
|
|
normalizedPath = normalizedPath.replace(/\/settings$/, '');
|
|
|
|
|
|
|
|
|
|
|
|
if (normalizedPath.includes('/thread/')) {
|
|
|
|
|
|
const parts = normalizedPath.split('/thread/');
|
|
|
|
|
|
return parts[0] || null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (normalizedPath.startsWith('/pending/')) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 23:34:33 +07:00
|
|
|
|
if (isArchiveRoute(normalizedPath) || isDirectoryListRoute(normalizedPath) || isBoardModRoute(normalizedPath) || isModQueueRoute(normalizedPath)) {
|
2026-01-07 16:03:02 +01:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-08 22:40:16 +01:00
|
|
|
|
if (isFeedRoute(pathname)) {
|
2026-04-18 15:58:27 +07:00
|
|
|
|
const strippedFeedPath = stripPageFromFeedPath(normalizedPath);
|
|
|
|
|
|
if (isMultiboardFeedPath(strippedFeedPath)) {
|
|
|
|
|
|
const timeFilterName = getEffectiveTimeFilterName(search);
|
|
|
|
|
|
return `${strippedFeedPath}${getSearchWithTimeFilter('', timeFilterName)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return strippedFeedPath;
|
2025-12-08 22:40:16 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const getFeedType = (pathname: string): 'board' | 'catalog' | null => {
|
|
|
|
|
|
const normalizedPath = pathname.replace(/\/settings$/, '');
|
|
|
|
|
|
|
|
|
|
|
|
if (normalizedPath.includes('/catalog')) {
|
|
|
|
|
|
return 'catalog';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isFeedRoute(pathname)) {
|
|
|
|
|
|
return 'board';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isPostRoute(pathname)) {
|
|
|
|
|
|
return 'board';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
};
|