fix(routing): canonicalize board aliases in thread routes

This commit is contained in:
plebeius
2026-03-07 13:48:18 +08:00
parent 2d8d280365
commit 1b9f69839d
5 changed files with 52 additions and 31 deletions
+10 -1
View File
@@ -12,7 +12,7 @@ import useIsMobile from './hooks/use-is-mobile';
import useTheme from './hooks/use-theme';
import { useDirectories } from './hooks/use-directories';
import { useResolvedSubplebbitAddress } from './hooks/use-resolved-subplebbit-address';
import { getSubplebbitAddress, isPostRoute, isPendingPostRoute, isModQueueRoute } from './lib/utils/route-utils';
import { getBoardPath, getSubplebbitAddress, isDirectoryBoard, isPostRoute, isPendingPostRoute, isModQueueRoute } from './lib/utils/route-utils';
import styles from './app.module.css';
import { DesktopBoardButtons, MobileBoardButtons } from './components/board-buttons';
import Board from './views/board';
@@ -90,6 +90,15 @@ const BoardLayout = () => {
return <Navigate to='/not-found' replace />;
}
// Normalize address URLs to directory codes: /anime-and-manga.eth/thread/xxx -> /a/thread/xxx
if (boardIdentifier && !isDirectoryBoard(boardIdentifier, directories)) {
const canonicalBoardIdentifier = getBoardPath(boardIdentifier, directories);
if (canonicalBoardIdentifier !== boardIdentifier) {
const canonicalPath = location.pathname.replace(`/${boardIdentifier}`, `/${canonicalBoardIdentifier}`);
return <Navigate to={canonicalPath + (location.search || '')} replace />;
}
}
return (
<div className={styles.boardLayout}>
<BoardsBar />
+4 -4
View File
@@ -135,7 +135,7 @@ const adaptV2Directories = (value: Record<string, unknown>): DirectoryCommunity[
return dedupeCommunities(communities);
};
const getDirectoryAddressLookupKey = (address: string): string => {
export const normalizeBoardAddress = (address: string): string => {
for (const suffix of DIRECTORY_ALIAS_SUFFIXES) {
if (address.endsWith(suffix)) {
return address.slice(0, -suffix.length);
@@ -145,7 +145,7 @@ const getDirectoryAddressLookupKey = (address: string): string => {
return address;
};
const findDirectoryByAddress = (directories: DirectoryCommunity[], address: string | undefined): DirectoryCommunity | undefined => {
export const findDirectoryByAddress = (directories: DirectoryCommunity[], address: string | undefined): DirectoryCommunity | undefined => {
if (!address) {
return undefined;
}
@@ -155,8 +155,8 @@ const findDirectoryByAddress = (directories: DirectoryCommunity[], address: stri
return exactMatch;
}
const addressLookupKey = getDirectoryAddressLookupKey(address);
return directories.find((community) => getDirectoryAddressLookupKey(community.address) === addressLookupKey);
const normalizedAddress = normalizeBoardAddress(address);
return directories.find((community) => normalizeBoardAddress(community.address) === normalizedAddress);
};
const adaptV1Communities = (value: Record<string, unknown>): DirectoryCommunity[] => {
+27 -13
View File
@@ -1,4 +1,4 @@
import { DirectoryCommunity } from '../../hooks/use-directories';
import { DirectoryCommunity, findDirectoryByAddress, normalizeBoardAddress } from '../../hooks/use-directories';
/**
* Extract directory short code from title (e.g., "/biz/ - Business & Finance" -> "biz")
@@ -16,6 +16,8 @@ let cachedDirectoryToAddressMap: Map<string, string> | null = null;
let cachedCommunitiesForAddress: DirectoryCommunity[] | null = null;
let cachedAddressToDirectoryMap: Map<string, string> | null = null;
const getDirectoryCode = (community: DirectoryCommunity): string | null => community.directoryCode ?? extractDirectoryFromTitle(community.title ?? '');
/**
* Create a map from directory codes to community addresses
* Uses caching to avoid recreating the map when communities array hasn't changed
@@ -28,11 +30,10 @@ const getDirectoryToAddressMap = (communities: DirectoryCommunity[]): Map<string
const map = new Map<string, string>();
for (const community of communities) {
if (community.title) {
const directory = extractDirectoryFromTitle(community.title);
if (directory && community.address) {
map.set(directory, community.address);
}
if (!community.address) continue;
const directory = getDirectoryCode(community);
if (directory) {
map.set(directory, community.address);
}
}
@@ -54,11 +55,10 @@ const getAddressToDirectoryMap = (communities: DirectoryCommunity[]): Map<string
const map = new Map<string, string>();
for (const community of communities) {
if (community.title && community.address) {
const directory = extractDirectoryFromTitle(community.title);
if (directory) {
map.set(community.address, directory);
}
if (!community.address) continue;
const directory = getDirectoryCode(community);
if (directory) {
map.set(community.address, directory);
}
}
@@ -69,11 +69,16 @@ const getAddressToDirectoryMap = (communities: DirectoryCommunity[]): Map<string
};
/**
* Convert community address to URL path (directory code if available, otherwise full address)
* 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.
*/
export const getBoardPath = (communityAddress: string, communities: DirectoryCommunity[]): string => {
const addressToDirectory = getAddressToDirectoryMap(communities);
const directory = addressToDirectory.get(communityAddress);
let directory = addressToDirectory.get(communityAddress);
if (!directory) {
const entry = findDirectoryByAddress(communities, communityAddress);
directory = entry ? (getDirectoryCode(entry) ?? undefined) : undefined;
}
return directory || communityAddress;
};
@@ -93,6 +98,15 @@ export const getSubplebbitAddress = (boardIdentifier: string, communities: Direc
return boardIdentifier;
};
/**
* 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);
};
/**
* Check if an identifier is a directory short code
*/
@@ -9,7 +9,7 @@ import { getCommentMediaInfo } from '../../../lib/utils/media-utils';
import { CatalogPostMedia } from '../../../components/catalog-row';
import LoadingEllipsis from '../../../components/loading-ellipsis';
import BoxModal from '../box-modal';
import { DirectoryCommunity } from '../../../hooks/use-directories';
import { DirectoryCommunity, findDirectoryByAddress } from '../../../hooks/use-directories';
import { getBoardPath } from '../../../lib/utils/route-utils';
import { removeMarkdown } from '../../../lib/utils/post-utils';
@@ -51,30 +51,28 @@ const PopularThreadCard = memo(
</div>
);
},
(prevProps, nextProps) => prevProps.post?.cid === nextProps.post?.cid && prevProps.boardTitle === nextProps.boardTitle,
(prevProps, nextProps) => prevProps.post?.cid === nextProps.post?.cid && prevProps.boardTitle === nextProps.boardTitle && prevProps.boardPath === nextProps.boardPath,
);
const PopularThreadsBox = ({ directories, subplebbits }: { directories: DirectoryCommunity[]; subplebbits: any }) => {
const { t } = useTranslation();
const { showWorksafeContentOnly, showNsfwContentOnly } = usePopularThreadsOptionsStore();
const directoryByAddress = useMemo(() => new Map(directories.map((d) => [d.address, d])), [directories]);
const filteredSubplebbits = useMemo(() => {
if (showWorksafeContentOnly) {
return subplebbits.filter((sub: Subplebbit) => {
const entry = directoryByAddress.get(sub?.address);
return entry ? !entry.nsfw : true;
const directoryEntry = findDirectoryByAddress(directories, sub?.address);
return directoryEntry ? !directoryEntry.nsfw : true;
});
}
if (showNsfwContentOnly) {
return subplebbits.filter((sub: Subplebbit) => {
const entry = directoryByAddress.get(sub?.address);
return entry ? entry.nsfw : false;
const directoryEntry = findDirectoryByAddress(directories, sub?.address);
return directoryEntry ? directoryEntry.nsfw : false;
});
}
return subplebbits;
}, [subplebbits, showWorksafeContentOnly, showNsfwContentOnly, directoryByAddress]);
}, [subplebbits, showWorksafeContentOnly, showNsfwContentOnly, directories]);
const { popularPosts } = usePopularPosts(filteredSubplebbits);
const isLoading = popularPosts.length === 0;
@@ -90,8 +88,8 @@ const PopularThreadsBox = ({ directories, subplebbits }: { directories: Director
<LoadingEllipsis string={t('loading')} />
) : (
popularPosts.map((post: Comment) => {
const entry = directoryByAddress.get(post.subplebbitAddress);
const boardTitle = entry?.title?.replace(/^\/[^/]+\/\s*-\s*/, '') || '';
const directoryEntry = findDirectoryByAddress(directories, post.subplebbitAddress);
const boardTitle = directoryEntry?.title?.replace(/^\/[^/]+\/\s*-\s*/, '') || '';
const boardPath = post.subplebbitAddress ? getBoardPath(post.subplebbitAddress, directories) : '';
return <PopularThreadCard key={post.cid} post={post} boardTitle={boardTitle} boardPath={boardPath} />;
})
+2 -2
View File
@@ -7,7 +7,7 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { isAllView } from '../../lib/utils/view-utils';
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
import { useDirectories } from '../../hooks/use-directories';
import { isDirectoryBoard } from '../../lib/utils/route-utils';
import { areSameBoardAddress, isDirectoryBoard } from '../../lib/utils/route-utils';
import useIsMobile from '../../hooks/use-is-mobile';
import ErrorDisplay from '../../components/error-display/error-display';
import { PageFooterDesktop, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFooterMobile } from '../../components/footer';
@@ -136,7 +136,7 @@ const PostPage = () => {
const navigate = useNavigate();
useEffect(() => {
if (comment?.subplebbitAddress && subplebbitAddress && comment.subplebbitAddress !== subplebbitAddress) {
if (comment?.subplebbitAddress && subplebbitAddress && !areSameBoardAddress(comment.subplebbitAddress, subplebbitAddress)) {
navigate('/not-found', { replace: true });
}
}, [comment?.subplebbitAddress, subplebbitAddress, navigate]);