refactor: migrate 5chan to the community hooks API (#1073)

* refactor(community-api): migrate 5chan to community hooks

* fix(review): address PR feedback

* fix(review): preserve legacy board context fallbacks

* fix(review): address latest bot feedback

* fix(review): use communityAddress in edit menu privileges
This commit is contained in:
Tommaso Casaburi
2026-03-13 13:25:10 +08:00
committed by GitHub
parent 9dc4d96d27
commit d7703953fb
105 changed files with 2659 additions and 1491 deletions
@@ -24,7 +24,16 @@ type TestComment = {
postCid?: string;
removed?: boolean;
replyCount?: number;
replies?: {
pages?: Record<
string,
{
comments?: TestComment[];
}
>;
};
spoiler?: boolean;
communityAddress?: string;
subplebbitAddress?: string;
thumbnailUrl?: string;
timestamp?: number;
@@ -44,6 +53,7 @@ const testState = vi.hoisted(() => ({
linkCount: 0,
matchedFilters: new Map<string, string>(),
mediaInfoByLink: {} as Record<string, { patternThumbnailUrl?: string; thumbnail?: string; type: string; url: string }>,
lastRepliesComment: undefined as TestComment | undefined,
replies: [] as TestComment[],
roleByAddress: {} as Record<string, { commentAuthorRole?: string; isCommentAuthorMod: boolean }>,
showOPComment: true,
@@ -63,9 +73,28 @@ vi.mock('react-i18next', () => ({
}));
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
useReplies: ({ comment }: { comment?: TestComment }) => ({
replies: comment ? testState.replies : [],
}),
useReplies: ({ comment, sortType }: { comment?: TestComment; sortType?: string }) => {
if (comment) {
testState.lastRepliesComment = comment;
}
const preloadedReplies =
comment?.replies?.pages?.[sortType || 'best']?.comments ?? Object.values(comment?.replies?.pages ?? {}).find((page) => page?.comments?.length)?.comments;
const compatiblePreloadedReplies: TestComment[] = [];
if (preloadedReplies?.length && comment?.communityAddress) {
for (const reply of preloadedReplies) {
if (!reply?.communityAddress || reply.communityAddress !== comment.communityAddress) {
break;
}
compatiblePreloadedReplies.push(reply);
}
}
return {
replies: comment ? (compatiblePreloadedReplies.length ? compatiblePreloadedReplies : testState.replies) : [],
};
},
}));
vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/lib/localforage-lru/index.js', () => ({
@@ -202,6 +231,7 @@ describe('CatalogRow', () => {
testState.linkCount = 0;
testState.matchedFilters = new Map<string, string>();
testState.mediaInfoByLink = {};
testState.lastRepliesComment = undefined;
testState.replies = [];
testState.roleByAddress = {};
testState.showOPComment = true;
@@ -296,7 +326,7 @@ describe('CatalogRow', () => {
locked: true,
pinned: true,
replyCount: 5,
subplebbitAddress: 'music-posting.eth',
communityAddress: 'music-posting.eth',
timestamp: 100,
title: 'Thread title',
};
@@ -335,7 +365,7 @@ describe('CatalogRow', () => {
content: 'Alias test',
link: 'https://example.com/media.png',
replyCount: 4,
subplebbitAddress: 'music-posting.eth',
communityAddress: 'music-posting.eth',
title: 'Alias title',
};
@@ -348,6 +378,52 @@ describe('CatalogRow', () => {
expect(container.querySelector('[title=\"(R)eplies / (I)mage Replies\"]')).toBeTruthy();
});
it('normalizes legacy board addresses before fetching hover preview replies', async () => {
testState.directories = [{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' }];
testState.mediaInfoByLink['https://example.com/legacy.png'] = { type: 'image', url: 'https://example.com/legacy.png' };
testState.replies = [];
const post: TestComment = {
author: { address: 'author-1', displayName: 'Alice' },
cid: 'post-legacy',
content: 'Legacy address thread',
link: 'https://example.com/legacy.png',
replyCount: 1,
replies: {
pages: {
new: {
comments: [
{
author: { address: 'author-2', displayName: 'Bob' },
cid: 'reply-legacy',
subplebbitAddress: 'music-posting.eth',
timestamp: 200,
},
],
},
},
},
subplebbitAddress: 'music-posting.eth',
timestamp: 100,
title: 'Legacy title',
};
await renderWithRouter(createElement(CatalogRow, { row: [post] }), '/all/catalog');
vi.useFakeTimers();
const previewTrigger = document.body.querySelector('a[href="/mu/thread/post-legacy"] > div');
await act(async () => {
previewTrigger?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
vi.advanceTimersByTime(260);
await Promise.resolve();
});
expect(testState.lastRepliesComment?.communityAddress).toBe('music-posting.eth');
expect(testState.lastRepliesComment?.replies?.pages?.new?.comments?.[0]?.communityAddress).toBe('music-posting.eth');
expect(document.body.textContent).toContain('Legacy title by Alice');
expect(document.body.textContent).toContain('last_reply_by Bob');
});
it('renders hidden and text-only threads with canonical board thread links', async () => {
testState.hiddenCids = new Set(['hidden-1']);
testState.showOPComment = false;
@@ -358,14 +434,14 @@ describe('CatalogRow', () => {
cid: 'hidden-1',
content: 'hidden text',
link: 'https://example.com/hidden.png',
subplebbitAddress: 'music-posting.eth',
communityAddress: 'music-posting.eth',
},
{
author: { address: 'text-author', displayName: 'Anon' },
cid: 'text-1',
content: 'Plain thread body',
replyCount: 1,
subplebbitAddress: 'music-posting.eth',
communityAddress: 'music-posting.eth',
title: 'Text title',
},
];
+15 -10
View File
@@ -23,6 +23,7 @@ import PostMenuDesktop from '../post-desktop/post-menu-desktop';
import styles from './catalog-row.module.css';
import capitalize from 'lodash/capitalize';
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
import { withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
interface CatalogPostMediaProps {
cid: string;
@@ -117,8 +118,10 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight
const CatalogPost = memo(
({ post }: { post: Comment }) => {
const { t } = useTranslation();
const { author, cid, content, link, linkHeight, linkWidth, locked, pinned, replyCount, spoiler, subplebbitAddress, timestamp, title, thumbnailUrl } = post || {};
const linkCount = useCountLinksInReplies(post);
const resolvedPost = useMemo(() => withResolvedCommentCommunityAddress(post), [post]);
const { author, cid, content, link, linkHeight, linkWidth, locked, pinned, replyCount, spoiler, communityAddress, timestamp, title, thumbnailUrl } =
resolvedPost || {};
const linkCount = useCountLinksInReplies(resolvedPost);
const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
@@ -130,10 +133,10 @@ const CatalogPost = memo(
const isInAllView = isAllView(location.pathname);
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
const directories = useDirectories();
const directoryEntry = findDirectoryByAddress(directories, subplebbitAddress);
const directoryEntry = findDirectoryByAddress(directories, communityAddress);
const requirePostLinkIsMedia = directoryEntry?.features?.requirePostLinkIsMedia === true;
const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, directories) : '';
const postMenuProps = useMemo(() => selectPostMenuProps(post), [post]);
const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : '';
const postMenuProps = useMemo(() => selectPostMenuProps(resolvedPost), [resolvedPost]);
const postLink = boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`;
@@ -185,16 +188,16 @@ const CatalogPost = memo(
if (showPortal) update();
}, [showPortal, update]);
const { replies } = useReplies({ comment: showPortal ? post : undefined, flat: true });
const { replies } = useReplies({ comment: showPortal ? resolvedPost : undefined, flat: true });
const lastReply = replies?.length > 0 ? replies[replies.length - 1] : null;
const { isCommentAuthorMod: isCatalogPostAuthorMod, commentAuthorRole: catalogPostAuthorRole } = useEditCommentPrivileges({
commentAuthorAddress: author?.address,
subplebbitAddress,
communityAddress: communityAddress ?? '',
});
const { isCommentAuthorMod: isLastReplyAuthorMod, commentAuthorRole: lastReplyAuthorRole } = useEditCommentPrivileges({
commentAuthorAddress: lastReply?.author?.address,
subplebbitAddress,
communityAddress: communityAddress ?? '',
});
const postContent = (
@@ -292,7 +295,7 @@ const CatalogPost = memo(
{author?.displayName || capitalize(t('anonymous'))}
{isCatalogPostAuthorMod && <span className='capitalize'>{` ## Board ${catalogPostAuthorRole}`}</span>}
</span>
{(isInAllView || isInSubscriptionsView) && subplebbitAddress && ` to p/${getShortAddress(subplebbitAddress)}`}
{(isInAllView || isInSubscriptionsView) && communityAddress && ` to p/${getShortAddress(communityAddress)}`}
<span className={styles.postAgo}> {getFormattedTimeAgo(timestamp)}</span>
{replyCount > 0 && (
<div className={styles.postLast}>
@@ -313,6 +316,8 @@ const CatalogPost = memo(
(prevProps, nextProps) => {
const prev = prevProps.post;
const next = nextProps.post;
const prevCommunityAddress = prev?.communityAddress ?? prev?.subplebbitAddress;
const nextCommunityAddress = next?.communityAddress ?? next?.subplebbitAddress;
// Compare all fields that affect rendering to avoid stale displays
return (
prev?.cid === next?.cid &&
@@ -329,7 +334,7 @@ const CatalogPost = memo(
prev?.thumbnailUrl === next?.thumbnailUrl &&
prev?.linkWidth === next?.linkWidth &&
prev?.linkHeight === next?.linkHeight &&
prev?.subplebbitAddress === next?.subplebbitAddress
prevCommunityAddress === nextCommunityAddress
);
},
);