mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -20,6 +20,7 @@ type TestComment = {
|
||||
replyCount?: number;
|
||||
replies?: unknown[];
|
||||
state?: string;
|
||||
communityAddress?: string;
|
||||
subplebbitAddress?: string;
|
||||
timestamp?: number;
|
||||
title?: string;
|
||||
@@ -27,18 +28,19 @@ type TestComment = {
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
cachedComments: {} as Record<string, TestComment>,
|
||||
communityFieldAddress: undefined as string | undefined,
|
||||
commentsByCid: {} as Record<string, TestComment>,
|
||||
directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>,
|
||||
editedCommentsByCid: {} as Record<string, TestComment | undefined>,
|
||||
isMobile: false,
|
||||
navigateMock: vi.fn(),
|
||||
resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined,
|
||||
subplebbit: {
|
||||
resolvedCommunityAddress: 'music-posting.eth' as string | undefined,
|
||||
community: {
|
||||
error: undefined as Error | undefined,
|
||||
shortAddress: 'music-posting.eth',
|
||||
title: '/mu/ - Music',
|
||||
},
|
||||
subplebbitSnapshot: {
|
||||
communitySnapshot: {
|
||||
roles: {
|
||||
'0xmod': { role: 'admin' },
|
||||
},
|
||||
@@ -64,22 +66,25 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
||||
useEditedComment: ({ comment }: { comment?: TestComment }) => ({
|
||||
editedComment: comment?.cid ? testState.editedCommentsByCid[comment.cid] : undefined,
|
||||
}),
|
||||
useSubplebbit: () => testState.subplebbit,
|
||||
useCommunity: () => testState.community,
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages', () => ({
|
||||
vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/communities-pages', () => ({
|
||||
default: (selector: (state: { comments: typeof testState.cachedComments }) => unknown) =>
|
||||
selector({
|
||||
comments: testState.cachedComments,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-stable-subplebbit', () => ({
|
||||
useSubplebbitField: (_address: string | undefined, selector: (subplebbit: typeof testState.subplebbitSnapshot) => unknown) => selector(testState.subplebbitSnapshot),
|
||||
vi.mock('../../../hooks/use-stable-community', () => ({
|
||||
useCommunityField: (address: string | undefined, selector: (community: typeof testState.communitySnapshot) => unknown) => {
|
||||
testState.communityFieldAddress = address;
|
||||
return selector(testState.communitySnapshot);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({
|
||||
useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress,
|
||||
vi.mock('../../../hooks/use-resolved-community-address', () => ({
|
||||
useResolvedCommunityAddress: () => testState.resolvedCommunityAddress,
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-directories', async () => {
|
||||
@@ -104,25 +109,25 @@ vi.mock('../../../components/footer', () => ({
|
||||
ThreadFooterFirstRow: ({
|
||||
isThreadClosed,
|
||||
postCid,
|
||||
subplebbitAddress,
|
||||
communityAddress,
|
||||
threadNumber,
|
||||
}: {
|
||||
isThreadClosed: boolean;
|
||||
postCid: string;
|
||||
subplebbitAddress: string;
|
||||
communityAddress: string;
|
||||
threadNumber?: number;
|
||||
}) => createElement('div', { 'data-testid': 'thread-footer-first-row' }, `${postCid}:${threadNumber}:${subplebbitAddress}:${String(isThreadClosed)}`),
|
||||
}) => createElement('div', { 'data-testid': 'thread-footer-first-row' }, `${postCid}:${threadNumber}:${communityAddress}:${String(isThreadClosed)}`),
|
||||
ThreadFooterMobile: ({
|
||||
isThreadClosed,
|
||||
postCid,
|
||||
subplebbitAddress,
|
||||
communityAddress,
|
||||
threadNumber,
|
||||
}: {
|
||||
isThreadClosed: boolean;
|
||||
postCid: string;
|
||||
subplebbitAddress: string;
|
||||
communityAddress: string;
|
||||
threadNumber?: number;
|
||||
}) => createElement('div', { 'data-testid': 'thread-footer-mobile' }, `${postCid}:${threadNumber}:${subplebbitAddress}:${String(isThreadClosed)}`),
|
||||
}) => createElement('div', { 'data-testid': 'thread-footer-mobile' }, `${postCid}:${threadNumber}:${communityAddress}:${String(isThreadClosed)}`),
|
||||
ThreadFooterStyleRow: () => createElement('div', { 'data-testid': 'thread-footer-style-row' }, 'thread-footer-style-row'),
|
||||
}));
|
||||
|
||||
@@ -182,17 +187,18 @@ describe('Post', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.cachedComments = {};
|
||||
testState.communityFieldAddress = undefined;
|
||||
testState.commentsByCid = {};
|
||||
testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }];
|
||||
testState.editedCommentsByCid = {};
|
||||
testState.isMobile = false;
|
||||
testState.resolvedSubplebbitAddress = 'music-posting.eth';
|
||||
testState.subplebbit = {
|
||||
testState.resolvedCommunityAddress = 'music-posting.eth';
|
||||
testState.community = {
|
||||
error: undefined,
|
||||
shortAddress: 'music-posting.eth',
|
||||
title: '/mu/ - Music',
|
||||
};
|
||||
testState.subplebbitSnapshot = {
|
||||
testState.communitySnapshot = {
|
||||
roles: {
|
||||
'0xmod': { role: 'admin' },
|
||||
},
|
||||
@@ -251,17 +257,17 @@ describe('Post', () => {
|
||||
|
||||
it('renders edited posts through the desktop and mobile presenters with stable role data', async () => {
|
||||
testState.editedCommentsByCid = {
|
||||
'post-1': { cid: 'edited-post', subplebbitAddress: 'music-posting.eth' },
|
||||
'post-1': { cid: 'edited-post', communityAddress: 'music-posting.eth' },
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(Post, { post: { cid: 'post-1', subplebbitAddress: 'music-posting.eth' } }));
|
||||
root.render(createElement(Post, { post: { cid: 'post-1', communityAddress: 'music-posting.eth' } }));
|
||||
});
|
||||
expect(container.querySelector('[data-testid="post-desktop"]')?.textContent).toBe('edited-post:none:1');
|
||||
|
||||
testState.isMobile = true;
|
||||
await act(async () => {
|
||||
root.render(createElement(Post, { post: { cid: 'post-2', subplebbitAddress: 'music-posting.eth' } }));
|
||||
root.render(createElement(Post, { post: { cid: 'post-2', communityAddress: 'music-posting.eth' } }));
|
||||
});
|
||||
expect(container.querySelector('[data-testid="post-mobile"]')?.textContent).toBe('post-2:none:1');
|
||||
});
|
||||
@@ -271,7 +277,7 @@ describe('Post', () => {
|
||||
'cached-cid': {
|
||||
cid: 'cached-cid',
|
||||
state: 'updating',
|
||||
subplebbitAddress: 'music-posting.eth',
|
||||
communityAddress: 'music-posting.eth',
|
||||
},
|
||||
};
|
||||
testState.cachedComments = {
|
||||
@@ -280,7 +286,7 @@ describe('Post', () => {
|
||||
content: 'cached body',
|
||||
number: 42,
|
||||
replyCount: 0,
|
||||
subplebbitAddress: 'music-posting.eth',
|
||||
communityAddress: 'music-posting.eth',
|
||||
title: 'Cached thread',
|
||||
},
|
||||
};
|
||||
@@ -290,6 +296,7 @@ describe('Post', () => {
|
||||
expect(container.querySelector('[data-testid="post-desktop"]')?.textContent).toBe('cached-cid:none:1');
|
||||
expect(container.querySelector('[data-testid="thread-footer-first-row"]')?.textContent).toBe('cached-cid:42:music-posting.eth:false');
|
||||
expect(container.querySelector('[data-testid="thread-footer-mobile"]')?.textContent).toBe('cached-cid:42:music-posting.eth:false');
|
||||
expect(testState.communityFieldAddress).toBe('music-posting.eth');
|
||||
expect(document.title).toBe('/mu/ - Cached thread... - 5chan');
|
||||
expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
|
||||
expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
|
||||
@@ -301,7 +308,7 @@ describe('Post', () => {
|
||||
cid: 'thread-cid',
|
||||
number: 8,
|
||||
replyCount: 0,
|
||||
subplebbitAddress: 'music-posting.eth',
|
||||
communityAddress: 'music-posting.eth',
|
||||
title: 'Thread title',
|
||||
},
|
||||
};
|
||||
@@ -326,7 +333,7 @@ describe('Post', () => {
|
||||
'comment-1': {
|
||||
cid: 'comment-1',
|
||||
postCid: 'comment-1',
|
||||
subplebbitAddress: 'other.eth',
|
||||
communityAddress: 'other.eth',
|
||||
title: 'Other board thread',
|
||||
},
|
||||
};
|
||||
@@ -336,13 +343,42 @@ describe('Post', () => {
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith('/not-found', { replace: true });
|
||||
});
|
||||
|
||||
it('hydrates multiboard thread pages from a legacy-only comment address', async () => {
|
||||
testState.resolvedCommunityAddress = undefined;
|
||||
testState.commentsByCid = {
|
||||
'legacy-cid': {
|
||||
cid: 'legacy-cid',
|
||||
state: 'updating',
|
||||
subplebbitAddress: 'music-posting.eth',
|
||||
},
|
||||
};
|
||||
testState.cachedComments = {
|
||||
'legacy-cid': {
|
||||
cid: 'legacy-cid',
|
||||
content: 'cached body',
|
||||
number: 7,
|
||||
replyCount: 0,
|
||||
subplebbitAddress: 'music-posting.eth',
|
||||
title: 'Legacy thread',
|
||||
},
|
||||
};
|
||||
|
||||
await renderPostPage('/all/thread/legacy-cid');
|
||||
|
||||
expect(container.querySelector('[data-testid="post-desktop"]')?.textContent).toBe('legacy-cid:none:1');
|
||||
expect(container.querySelector('[data-testid="thread-footer-first-row"]')?.textContent).toBe('legacy-cid:7:music-posting.eth:false');
|
||||
expect(testState.communityFieldAddress).toBe('music-posting.eth');
|
||||
expect(document.title).toBe('all - Legacy thread... - 5chan');
|
||||
expect(testState.navigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders reply pages using the root post, highlights the reply target, and shows thread errors', async () => {
|
||||
testState.commentsByCid = {
|
||||
'reply-cid': {
|
||||
cid: 'reply-cid',
|
||||
parentCid: 'root-cid',
|
||||
postCid: 'root-cid',
|
||||
subplebbitAddress: 'music-posting.eth',
|
||||
communityAddress: 'music-posting.eth',
|
||||
},
|
||||
'root-cid': {
|
||||
cid: 'root-cid',
|
||||
@@ -351,7 +387,7 @@ describe('Post', () => {
|
||||
number: 99,
|
||||
replies: [],
|
||||
replyCount: 4,
|
||||
subplebbitAddress: 'music-posting.eth',
|
||||
communityAddress: 'music-posting.eth',
|
||||
title: 'Root thread',
|
||||
},
|
||||
};
|
||||
@@ -369,7 +405,7 @@ describe('Post', () => {
|
||||
error: new Error('missing comment'),
|
||||
},
|
||||
};
|
||||
testState.subplebbit = {
|
||||
testState.community = {
|
||||
error: new Error('board failed'),
|
||||
shortAddress: 'music-posting.eth',
|
||||
title: '/mu/ - Music',
|
||||
|
||||
+24
-20
@@ -1,13 +1,14 @@
|
||||
import { memo, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Comment, Role, useComment, useEditedComment, useSubplebbit } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import useSubplebbitsPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages';
|
||||
import { useSubplebbitField } from '../../hooks/use-stable-subplebbit';
|
||||
import { Comment, Role, useComment, useEditedComment, useCommunity } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
import useCommunitiesPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities-pages';
|
||||
import { useCommunityField } from '../../hooks/use-stable-community';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { isAllView } from '../../lib/utils/view-utils';
|
||||
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
|
||||
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
|
||||
import { useDirectories } from '../../hooks/use-directories';
|
||||
import { areSameBoardAddress, isDirectoryBoard } from '../../lib/utils/route-utils';
|
||||
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import ErrorDisplay from '../../components/error-display/error-display';
|
||||
import { PageFooterDesktop, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFooterMobile } from '../../components/footer';
|
||||
@@ -17,11 +18,11 @@ import { getRequestedThreadTopCid, scrollThreadContainerToTop } from '../../lib/
|
||||
import styles from './post.module.css';
|
||||
|
||||
// useComment may not return cached feed data immediately due to its updatedAt comparison logic.
|
||||
// This hook falls back to the subplebbit pages store (populated by useFeed) so content
|
||||
// This hook falls back to the communities pages store (populated by useFeed) so content
|
||||
// from the catalog appears instantly instead of going through a loading phase.
|
||||
const useCommentWithFeedCache = (options: { commentCid: string | undefined }) => {
|
||||
const comment = useComment(options);
|
||||
const cachedComment = useSubplebbitsPagesStore((state) => state.comments[options?.commentCid || '']);
|
||||
const cachedComment = useCommunitiesPagesStore((state) => state.comments[options?.commentCid || '']);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!cachedComment || comment?.timestamp) return comment;
|
||||
@@ -53,7 +54,8 @@ export interface PostProps {
|
||||
export const Post = memo(
|
||||
({ post, showAllReplies = false, showReplies = true, targetReplyCid, isModQueue, modQueueStatus, modQueueError, isPublishing, onApprove, onReject }: PostProps) => {
|
||||
// Only subscribe to roles field to avoid rerenders from updatingState changes
|
||||
const roles = useSubplebbitField(post?.subplebbitAddress, (subplebbit) => subplebbit?.roles);
|
||||
const communityAddress = post?.communityAddress || post?.subplebbitAddress;
|
||||
const roles = useCommunityField(communityAddress, (community) => community?.roles);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
let comment = post;
|
||||
@@ -130,21 +132,23 @@ const PostPage = () => {
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const { commentCid } = params;
|
||||
const subplebbitAddress = useResolvedSubplebbitAddress();
|
||||
const resolvedCommunityAddress = useResolvedCommunityAddress();
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
|
||||
const comment = useCommentWithFeedCache({ commentCid });
|
||||
const commentCommunityAddress = getCommentCommunityAddress(comment);
|
||||
const communityAddress = resolvedCommunityAddress ?? commentCommunityAddress;
|
||||
const consumedThreadTopScrollRef = useRef<string | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
if (comment?.subplebbitAddress && subplebbitAddress && !areSameBoardAddress(comment.subplebbitAddress, subplebbitAddress)) {
|
||||
if (commentCommunityAddress && resolvedCommunityAddress && !areSameBoardAddress(commentCommunityAddress, resolvedCommunityAddress)) {
|
||||
navigate('/not-found', { replace: true });
|
||||
}
|
||||
}, [comment?.subplebbitAddress, subplebbitAddress, navigate]);
|
||||
}, [commentCommunityAddress, resolvedCommunityAddress, navigate]);
|
||||
|
||||
const subplebbit = useSubplebbit({ subplebbitAddress });
|
||||
const { error: subplebbitError, shortAddress, title } = subplebbit || {};
|
||||
const community = useCommunity({ communityAddress });
|
||||
const { error: communityError, shortAddress, title } = community || {};
|
||||
const directories = useDirectories();
|
||||
|
||||
// if the comment is a reply, return the post comment instead, then the reply will be highlighted in the thread
|
||||
@@ -192,17 +196,17 @@ const PostPage = () => {
|
||||
} else if (isDirectory) {
|
||||
boardTitle = `/${boardIdentifier}/`;
|
||||
} else {
|
||||
boardTitle = title ? title : shortAddress || subplebbitAddress || '';
|
||||
boardTitle = title ? title : shortAddress || communityAddress || '';
|
||||
}
|
||||
|
||||
const postTitle = post?.title?.slice(0, 30) || post?.content?.slice(0, 30);
|
||||
const postTitlePart = postTitle ? ` - ${postTitle.trim()}...` : '';
|
||||
document.title = `${boardTitle}${postTitlePart} - 5chan`;
|
||||
}, [title, shortAddress, subplebbitAddress, post?.title, post?.content, isInAllView, t, params.boardIdentifier, directories]);
|
||||
}, [title, shortAddress, communityAddress, post?.title, post?.content, isInAllView, t, params.boardIdentifier, directories]);
|
||||
|
||||
const shouldShowCommentError = comment?.error?.message && !comment?.cid;
|
||||
const shouldShowPostError = post?.error && post?.replyCount > 0 && post?.replies?.length === 0;
|
||||
const shouldShowSubplebbitError = subplebbitError?.message && !post?.cid;
|
||||
const shouldShowCommunityError = communityError?.message && !post?.cid;
|
||||
|
||||
const targetReplyCid = comment?.parentCid ? comment?.cid : undefined;
|
||||
|
||||
@@ -214,9 +218,9 @@ const PostPage = () => {
|
||||
</div>
|
||||
)}
|
||||
<Post post={post} showAllReplies={true} targetReplyCid={targetReplyCid} />
|
||||
{shouldShowSubplebbitError && (
|
||||
{shouldShowCommunityError && (
|
||||
<div className={styles.error}>
|
||||
<ErrorDisplay error={subplebbitError} />
|
||||
<ErrorDisplay error={communityError} />
|
||||
</div>
|
||||
)}
|
||||
{shouldShowCommentError && (
|
||||
@@ -224,13 +228,13 @@ const PostPage = () => {
|
||||
<ErrorDisplay error={comment?.error} />
|
||||
</div>
|
||||
)}
|
||||
{post?.cid && subplebbitAddress ? (
|
||||
{post?.cid && communityAddress ? (
|
||||
<>
|
||||
<PageFooterDesktop
|
||||
firstRow={<ThreadFooterFirstRow postCid={post.cid} threadNumber={post?.number} subplebbitAddress={subplebbitAddress} isThreadClosed={!!post?.locked} />}
|
||||
firstRow={<ThreadFooterFirstRow postCid={post.cid} threadNumber={post?.number} communityAddress={communityAddress} isThreadClosed={!!post?.locked} />}
|
||||
styleRow={<ThreadFooterStyleRow />}
|
||||
/>
|
||||
<ThreadFooterMobile postCid={post.cid} threadNumber={post?.number} subplebbitAddress={subplebbitAddress} isThreadClosed={!!post?.locked} />
|
||||
<ThreadFooterMobile postCid={post.cid} threadNumber={post?.number} communityAddress={communityAddress} isThreadClosed={!!post?.locked} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user