mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Merge branch 'codex/fix/thread-author-controls'
This commit is contained in:
@@ -506,7 +506,7 @@
|
||||
"postsPerPage": 15
|
||||
},
|
||||
"rules": [
|
||||
"This is the destination for all cartoon/anime pony related content on 4chan.",
|
||||
"This is the destination for all cartoon/anime pony related content on 5chan.",
|
||||
"This is a work safe board. No pornographic images or other not safe for work content is allowed (this includes clop).",
|
||||
"Topics must be show-related. When discussing people, they must be associated with the show and not the fandom.",
|
||||
"No roleplay.",
|
||||
@@ -562,7 +562,7 @@
|
||||
"safeForWork": true,
|
||||
"hasFlags": false,
|
||||
"requirePostLink": false,
|
||||
"requirePostLinkIsMedia": false,
|
||||
"requirePostLinkIsMedia": true,
|
||||
"bumpLimit": 300,
|
||||
"noSpoilers": false,
|
||||
"noSpoilerReplies": false,
|
||||
@@ -748,9 +748,7 @@
|
||||
"postsPerPage": 15
|
||||
},
|
||||
"rules": [
|
||||
"You must check your #fortune in order to post on this board.",
|
||||
"No porn dump threads. Keep that shit on /b/.",
|
||||
"Moderators must check their #fortune as well."
|
||||
"No porn dump threads. Keep that shit on /b/."
|
||||
]
|
||||
},
|
||||
"sci": {
|
||||
|
||||
@@ -11,6 +11,11 @@ const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise
|
||||
|
||||
type TestComment = {
|
||||
approved?: boolean;
|
||||
accountId?: string;
|
||||
author?: {
|
||||
address?: string;
|
||||
community?: unknown;
|
||||
};
|
||||
cid?: string;
|
||||
content?: string;
|
||||
error?: Error;
|
||||
@@ -35,6 +40,7 @@ type TestComment = {
|
||||
};
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
accountCommentsByCid: {} as Record<string, TestComment | undefined>,
|
||||
cachedComments: {} as Record<string, TestComment>,
|
||||
communityFieldAddress: undefined as string | undefined,
|
||||
commentsByCid: {} as Record<string, TestComment>,
|
||||
@@ -78,6 +84,8 @@ vi.mock('react-router-dom', async () => {
|
||||
});
|
||||
|
||||
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
|
||||
useAccount: () => ({ id: 'active-account', author: { address: 'account-author' } }),
|
||||
useAccountComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.accountCommentsByCid[commentCid] : undefined),
|
||||
useComment: ({ commentCid, autoUpdate, community }: { commentCid?: string; autoUpdate?: boolean; community?: { name?: string; publicKey?: string } }) => {
|
||||
testState.useCommentCalls.push({ commentCid, autoUpdate, community });
|
||||
return commentCid ? testState.commentsByCid[commentCid] : undefined;
|
||||
@@ -177,6 +185,7 @@ vi.mock('../../../components/post-desktop/post-desktop', () => ({
|
||||
{
|
||||
'data-testid': 'post-desktop',
|
||||
'data-approved': post?.approved === undefined ? '' : String(post.approved),
|
||||
'data-author-address': post?.author?.address || '',
|
||||
'data-number': post?.number === undefined ? '' : String(post.number),
|
||||
'data-pending-approval': post?.pendingApproval === undefined ? '' : String(post.pendingApproval),
|
||||
'data-replies': replyPaginationOverride?.replies?.map((reply) => reply.cid).join(',') || '',
|
||||
@@ -204,6 +213,7 @@ vi.mock('../../../components/post-mobile/post-mobile', () => ({
|
||||
{
|
||||
'data-testid': 'post-mobile',
|
||||
'data-approved': post?.approved === undefined ? '' : String(post.approved),
|
||||
'data-author-address': post?.author?.address || '',
|
||||
'data-number': post?.number === undefined ? '' : String(post.number),
|
||||
'data-pending-approval': post?.pendingApproval === undefined ? '' : String(post.pendingApproval),
|
||||
'data-replies': replyPaginationOverride?.replies?.map((reply) => reply.cid).join(',') || '',
|
||||
@@ -251,6 +261,7 @@ const renderPostPage = async (initialEntry: string | { pathname: string; state?:
|
||||
describe('Post', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.accountCommentsByCid = {};
|
||||
testState.cachedComments = {};
|
||||
testState.communityFieldAddress = undefined;
|
||||
testState.commentsByCid = {};
|
||||
@@ -399,6 +410,41 @@ describe('Post', () => {
|
||||
expect(desktopPresenter?.getAttribute('data-approved')).toBe('true');
|
||||
});
|
||||
|
||||
it('rerenders posts when local author data becomes available after the remote post shell', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(Post, {
|
||||
post: {
|
||||
cid: 'post-author',
|
||||
communityAddress: 'music-posting.eth',
|
||||
content: 'body',
|
||||
replyCount: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-testid="post-desktop"]')?.getAttribute('data-author-address')).toBe('');
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(Post, {
|
||||
post: {
|
||||
author: {
|
||||
address: 'account-author',
|
||||
},
|
||||
cid: 'post-author',
|
||||
communityAddress: 'music-posting.eth',
|
||||
content: 'body',
|
||||
replyCount: 0,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-testid="post-desktop"]')?.getAttribute('data-author-address')).toBe('account-author');
|
||||
});
|
||||
|
||||
it('hydrates thread pages from cached feed data, sets the document title, and renders thread footers', async () => {
|
||||
testState.commentsByCid = {
|
||||
'cached-cid': {
|
||||
@@ -429,6 +475,115 @@ describe('Post', () => {
|
||||
expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('overlays local account comments on thread pages so author controls receive account author data', async () => {
|
||||
testState.commentsByCid = {
|
||||
'owned-cid': {
|
||||
cid: 'owned-cid',
|
||||
author: {
|
||||
community: { displayName: 'Anonymous' },
|
||||
},
|
||||
content: 'remote body',
|
||||
number: 14,
|
||||
replyCount: 3,
|
||||
communityAddress: 'music-posting.eth',
|
||||
title: 'Remote thread',
|
||||
},
|
||||
};
|
||||
testState.accountCommentsByCid = {
|
||||
'owned-cid': {
|
||||
cid: 'owned-cid',
|
||||
postCid: 'owned-cid',
|
||||
author: {
|
||||
address: 'account-author',
|
||||
},
|
||||
content: 'local body',
|
||||
communityAddress: 'music-posting.eth',
|
||||
title: 'Local thread',
|
||||
},
|
||||
};
|
||||
|
||||
await renderPostPage('/mu/thread/owned-cid');
|
||||
|
||||
const desktopPresenter = container.querySelector('[data-testid="post-desktop"]');
|
||||
expect(desktopPresenter?.getAttribute('data-author-address')).toBe('account-author');
|
||||
expect(desktopPresenter?.getAttribute('data-number')).toBe('14');
|
||||
expect(container.querySelector('[data-testid="thread-footer-first-row"]')?.textContent).toBe('owned-cid:14:music-posting.eth:false');
|
||||
});
|
||||
|
||||
it('restores the active account author when a mapped account comment only has anonymous author metadata', async () => {
|
||||
testState.commentsByCid = {
|
||||
'mapped-owned-cid': {
|
||||
cid: 'mapped-owned-cid',
|
||||
author: {
|
||||
community: { displayName: 'Anonymous' },
|
||||
},
|
||||
content: 'remote body',
|
||||
number: 16,
|
||||
replyCount: 0,
|
||||
communityAddress: 'music-posting.eth',
|
||||
title: 'Remote thread',
|
||||
},
|
||||
};
|
||||
testState.accountCommentsByCid = {
|
||||
'mapped-owned-cid': {
|
||||
accountId: 'active-account',
|
||||
cid: 'mapped-owned-cid',
|
||||
postCid: 'mapped-owned-cid',
|
||||
author: {
|
||||
community: { displayName: 'Anonymous' },
|
||||
},
|
||||
content: 'local body',
|
||||
communityAddress: 'music-posting.eth',
|
||||
title: 'Local thread',
|
||||
},
|
||||
};
|
||||
|
||||
await renderPostPage('/mu/thread/mapped-owned-cid');
|
||||
|
||||
const desktopPresenter = container.querySelector('[data-testid="post-desktop"]');
|
||||
expect(desktopPresenter?.getAttribute('data-author-address')).toBe('account-author');
|
||||
expect(desktopPresenter?.getAttribute('data-number')).toBe('16');
|
||||
});
|
||||
|
||||
it('keeps queued publish authors on thread pages when the remote OP is already renderable', async () => {
|
||||
testState.commentsByCid = {
|
||||
'queued-owned-cid': {
|
||||
cid: 'queued-owned-cid',
|
||||
author: {
|
||||
community: { displayName: 'Anonymous' },
|
||||
},
|
||||
content: 'remote body',
|
||||
number: 15,
|
||||
pendingApproval: false,
|
||||
replyCount: 0,
|
||||
communityAddress: 'music-posting.eth',
|
||||
title: 'Remote thread',
|
||||
},
|
||||
};
|
||||
|
||||
await renderPostPage({
|
||||
pathname: '/mu/thread/queued-owned-cid',
|
||||
state: {
|
||||
queuedComment: {
|
||||
cid: 'queued-owned-cid',
|
||||
author: {
|
||||
address: 'account-author',
|
||||
},
|
||||
content: 'queued body',
|
||||
communityAddress: 'music-posting.eth',
|
||||
pendingApproval: true,
|
||||
title: 'Queued thread',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const desktopPresenter = container.querySelector('[data-testid="post-desktop"]');
|
||||
expect(desktopPresenter?.getAttribute('data-author-address')).toBe('account-author');
|
||||
expect(desktopPresenter?.getAttribute('data-number')).toBe('15');
|
||||
expect(desktopPresenter?.getAttribute('data-pending-approval')).toBe('false');
|
||||
expect(container.querySelector('[data-testid="thread-footer-first-row"]')?.textContent).toBe('queued-owned-cid:15:music-posting.eth:false');
|
||||
});
|
||||
|
||||
it('passes archived OP state through to thread footers as closed', async () => {
|
||||
testState.commentsByCid = {
|
||||
'archived-thread': {
|
||||
|
||||
+94
-21
@@ -1,6 +1,16 @@
|
||||
import { memo, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { type Comment, type CommunityIdentifier, type Role, useComment, useEditedComment, useCommunity, useReplies } from '@bitsocial/bitsocial-react-hooks';
|
||||
import {
|
||||
type Account,
|
||||
type Comment,
|
||||
type CommunityIdentifier,
|
||||
type Role,
|
||||
useAccount,
|
||||
useComment,
|
||||
useEditedComment,
|
||||
useCommunity,
|
||||
useReplies,
|
||||
} from '@bitsocial/bitsocial-react-hooks';
|
||||
import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
|
||||
import { useCommunityField } from '../../hooks/use-stable-community';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
@@ -20,6 +30,7 @@ import { getRequestedThreadTopCid, scrollThreadContainerToTop } from '../../lib/
|
||||
import { evictThreadRefreshCaches } from '../../lib/utils/thread-refresh-cache-utils';
|
||||
import { REPLIES_PER_PAGE } from '../../lib/constants';
|
||||
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
||||
import useSafeAccountComment from '../../hooks/use-safe-account-comment';
|
||||
import type { QueuedCommentRouteState } from '../../lib/utils/mod-queue-utils';
|
||||
import type { ReplyVirtualizationMode } from '../../lib/utils/pretext-height-estimates';
|
||||
import styles from './post.module.css';
|
||||
@@ -36,6 +47,19 @@ export type CommentWithRefresh = Comment & {
|
||||
removed?: boolean;
|
||||
};
|
||||
|
||||
const mergeDefinedFields = <T extends object>(base: T | undefined, override: T | undefined): T | undefined => {
|
||||
if (!override) return base;
|
||||
|
||||
const merged = { ...base } as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(override)) {
|
||||
if (value !== undefined) {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return merged as T;
|
||||
};
|
||||
|
||||
const getRouteUserState = (state: unknown): QueuedCommentRouteState | undefined => {
|
||||
if (!state || typeof state !== 'object') return undefined;
|
||||
if ('queuedComment' in state || 'scrollThreadContainerCid' in state) {
|
||||
@@ -62,25 +86,6 @@ interface ReplyPaginationOverride {
|
||||
reset?: () => Promise<void>;
|
||||
}
|
||||
|
||||
// useComment may not return cached feed data immediately due to its updatedAt comparison logic.
|
||||
// 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; autoUpdate?: boolean; community?: CommunityIdentifier }): CommentWithRefresh | undefined => {
|
||||
const comment = useComment(options);
|
||||
const cachedComment = useCommunitiesPagesStore((state) => state.comments[options?.commentCid || '']);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!cachedComment || comment?.timestamp) return comment;
|
||||
return {
|
||||
...cachedComment,
|
||||
refresh: comment?.refresh,
|
||||
state: comment?.state,
|
||||
error: comment?.error,
|
||||
errors: comment?.errors,
|
||||
} as CommentWithRefresh;
|
||||
}, [comment, cachedComment]);
|
||||
};
|
||||
|
||||
const getQueuedCommentFromRouteState = (state: unknown, commentCid: string | undefined): CommentWithRefresh | undefined => {
|
||||
if (!commentCid) return undefined;
|
||||
|
||||
@@ -117,6 +122,71 @@ const mergeCommentFallback = (comment: CommentWithRefresh | undefined, fallback:
|
||||
};
|
||||
};
|
||||
|
||||
const mergeLocalCommentAuthor = (comment: CommentWithRefresh | undefined, localComment: CommentWithRefresh | undefined): CommentWithRefresh | undefined => {
|
||||
if (!localComment?.author) return comment;
|
||||
if (!comment) return localComment;
|
||||
if (comment.cid && localComment.cid && comment.cid !== localComment.cid) return comment;
|
||||
|
||||
const mergedAuthor = mergeDefinedFields(comment.author, localComment.author);
|
||||
|
||||
return {
|
||||
...comment,
|
||||
...(mergedAuthor ? { author: mergedAuthor } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const mergeLocalAccountComment = (comment: CommentWithRefresh | undefined, accountComment: CommentWithRefresh | undefined): CommentWithRefresh | undefined => {
|
||||
if (!accountComment) return comment;
|
||||
if (!comment) return accountComment;
|
||||
if (comment.cid && accountComment.cid && comment.cid !== accountComment.cid) return comment;
|
||||
|
||||
const mergedComment = mergeDefinedFields(comment, accountComment) ?? comment;
|
||||
return mergeLocalCommentAuthor(mergedComment, accountComment);
|
||||
};
|
||||
|
||||
const restoreActiveAccountAuthor = (accountComment: CommentWithRefresh | undefined, account: Account | undefined): CommentWithRefresh | undefined => {
|
||||
if (!accountComment || accountComment.author?.address || !account?.id || accountComment.accountId !== account.id || !account.author?.address) {
|
||||
return accountComment;
|
||||
}
|
||||
|
||||
const accountAuthor = {
|
||||
address: account.author.address,
|
||||
shortAddress: account.author.shortAddress,
|
||||
displayName: account.author.displayName,
|
||||
avatar: account.author.avatar,
|
||||
flair: account.author.flair,
|
||||
};
|
||||
|
||||
return {
|
||||
...accountComment,
|
||||
author: mergeDefinedFields(accountComment.author, accountAuthor),
|
||||
};
|
||||
};
|
||||
|
||||
// useComment may not return cached feed data immediately due to its updatedAt comparison logic.
|
||||
// This hook falls back to the communities pages store and then overlays a matching
|
||||
// local account comment so author controls keep working after publish navigation.
|
||||
const useCommentWithFeedCache = (options: { commentCid: string | undefined; autoUpdate?: boolean; community?: CommunityIdentifier }): CommentWithRefresh | undefined => {
|
||||
const comment = useComment(options);
|
||||
const cachedComment = useCommunitiesPagesStore((state) => state.comments[options?.commentCid || '']);
|
||||
const account = useAccount();
|
||||
const accountComment = useSafeAccountComment({ commentCid: options.commentCid }) as CommentWithRefresh | undefined;
|
||||
const accountCommentWithAuthor = useMemo(() => restoreActiveAccountAuthor(accountComment, account), [accountComment, account]);
|
||||
|
||||
const commentWithFeedCache = useMemo(() => {
|
||||
if (!cachedComment || comment?.timestamp) return comment;
|
||||
return {
|
||||
...cachedComment,
|
||||
refresh: comment?.refresh,
|
||||
state: comment?.state,
|
||||
error: comment?.error,
|
||||
errors: comment?.errors,
|
||||
} as CommentWithRefresh;
|
||||
}, [comment, cachedComment]);
|
||||
|
||||
return useMemo(() => mergeLocalAccountComment(commentWithFeedCache, accountCommentWithAuthor), [commentWithFeedCache, accountCommentWithAuthor]);
|
||||
};
|
||||
|
||||
const mergeRepliesWithQueuedReply = (replies: Comment[], queuedReply: CommentWithRefresh | undefined): Comment[] => {
|
||||
if (!queuedReply?.cid) {
|
||||
return replies;
|
||||
@@ -243,6 +313,9 @@ export const Post = memo(
|
||||
prev?.updatedAt === next?.updatedAt &&
|
||||
prev?.state === next?.state &&
|
||||
prev?.publishingState === next?.publishingState &&
|
||||
prev?.author?.address === next?.author?.address &&
|
||||
prev?.author?.displayName === next?.author?.displayName &&
|
||||
prev?.author?.shortAddress === next?.author?.shortAddress &&
|
||||
prev?.error === next?.error &&
|
||||
prev?.errors === next?.errors &&
|
||||
prev?.approved === next?.approved &&
|
||||
@@ -294,7 +367,7 @@ const PostPage = () => {
|
||||
|
||||
const resolvedComment = useCommentWithFeedCache({ commentCid, autoUpdate: autoUpdateEnabled, community: resolvedCommunityIdentifier });
|
||||
const queuedComment = useMemo(() => getQueuedCommentFromRouteState(routeState, commentCid), [routeState, commentCid]);
|
||||
const comment = useMemo(() => mergeCommentFallback(resolvedComment, queuedComment), [resolvedComment, queuedComment]);
|
||||
const comment = useMemo(() => mergeLocalCommentAuthor(mergeCommentFallback(resolvedComment, queuedComment), queuedComment), [resolvedComment, queuedComment]);
|
||||
const commentCommunityAddress = getCommentCommunityAddress(comment);
|
||||
const communityAddress = resolvedCommunityAddress ?? commentCommunityAddress;
|
||||
const communityIdentifier = useCommunityIdentifier(communityAddress);
|
||||
|
||||
Reference in New Issue
Block a user