feat(mod queue): transfer posts across boards (#1182)

* feat(mod queue): transfer posts across boards

* fix(mod queue): polish transfer modal and reason

* fix(mod queue): recover abandoned transfer challenges

* fix(mod queue): address transfer review feedback

* fix(mod queue): hide transfer for unavailable posts

* fix(mod queue): prevent repeat transfer submit

* fix(mod queue): sync transfer state with queue actions

* fix(mod queue): omit transfer temp index

* fix(mod queue): harden transfer failure states

* fix(mod queue): keep hidden transfer lock

* fix(mod queue): release hidden transfer lock
This commit is contained in:
Tommaso Casaburi
2026-07-02 14:52:06 +07:00
committed by GitHub
parent 546ee99f12
commit b4a6f0a65a
55 changed files with 2688 additions and 154 deletions
+86
View File
@@ -21,6 +21,8 @@ type TestComment = {
error?: Error;
commentModeration?: {
archived?: boolean;
flairs?: Array<{ text?: string }>;
purged?: boolean;
};
locked?: boolean;
number?: number;
@@ -89,6 +91,10 @@ const enrichAccountCommentAuthor = (comment: TestComment | undefined): TestComme
};
};
function hasTransferredMarker(post?: TestComment): boolean {
return post?.commentModeration?.flairs?.some((flair) => flair.text === '5chan:transferred') ?? false;
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
@@ -194,11 +200,13 @@ vi.mock('../../../components/post-desktop/post-desktop', () => ({
roles,
targetReplyCid,
replyPaginationOverride,
onTransfer,
}: {
post?: TestComment;
roles?: Record<string, unknown>;
targetReplyCid?: string;
replyPaginationOverride?: { replies?: TestComment[] };
onTransfer?: () => void;
}) =>
createElement(
'div',
@@ -210,6 +218,8 @@ vi.mock('../../../components/post-desktop/post-desktop', () => ({
'data-pending-approval': post?.pendingApproval === undefined ? '' : String(post.pendingApproval),
'data-replies': replyPaginationOverride?.replies?.map((reply) => reply.cid).join(',') || '',
'data-roles-present': String(roles !== undefined),
'data-transfer-enabled': String(typeof onTransfer === 'function'),
'data-transferred': String(hasTransferredMarker(post)),
},
createElement('div', { 'data-thread-container-cid': post?.cid }),
createElement('div', { 'data-post-info-cid': post?.cid }),
@@ -223,11 +233,13 @@ vi.mock('../../../components/post-mobile/post-mobile', () => ({
roles,
targetReplyCid,
replyPaginationOverride,
onTransfer,
}: {
post?: TestComment;
roles?: Record<string, unknown>;
targetReplyCid?: string;
replyPaginationOverride?: { replies?: TestComment[] };
onTransfer?: () => void;
}) =>
createElement(
'div',
@@ -239,6 +251,8 @@ vi.mock('../../../components/post-mobile/post-mobile', () => ({
'data-pending-approval': post?.pendingApproval === undefined ? '' : String(post.pendingApproval),
'data-replies': replyPaginationOverride?.replies?.map((reply) => reply.cid).join(',') || '',
'data-roles-present': String(roles !== undefined),
'data-transfer-enabled': String(typeof onTransfer === 'function'),
'data-transferred': String(hasTransferredMarker(post)),
},
createElement('div', { 'data-thread-container-cid': post?.cid }),
createElement('div', { 'data-post-info-cid': post?.cid }),
@@ -505,6 +519,78 @@ describe('Post', () => {
expect(container.querySelector('[data-testid="post-desktop"]')?.getAttribute('data-author-address')).toBe('account-author');
});
it('rerenders posts when a transferred moderation marker appears', async () => {
await act(async () => {
root.render(
createElement(Post, {
post: {
cid: 'post-transferred',
communityAddress: 'music-posting.eth',
content: 'body',
replyCount: 0,
},
}),
);
});
expect(container.querySelector('[data-testid="post-desktop"]')?.getAttribute('data-transferred')).toBe('false');
await act(async () => {
root.render(
createElement(Post, {
post: {
cid: 'post-transferred',
commentModeration: {
flairs: [{ text: '5chan:transferred' }],
},
communityAddress: 'music-posting.eth',
content: 'body',
replyCount: 0,
},
}),
);
});
expect(container.querySelector('[data-testid="post-desktop"]')?.getAttribute('data-transferred')).toBe('true');
});
it('only forwards transfer handlers for top-level posts', async () => {
const handleTransfer = vi.fn();
await act(async () => {
root.render(
createElement(Post, {
onTransfer: handleTransfer,
post: {
cid: 'post-transfer-handler',
communityAddress: 'music-posting.eth',
content: 'body',
replyCount: 0,
},
}),
);
});
expect(container.querySelector('[data-testid="post-desktop"]')?.getAttribute('data-transfer-enabled')).toBe('true');
await act(async () => {
root.render(
createElement(Post, {
onTransfer: handleTransfer,
post: {
cid: 'post-transfer-handler',
communityAddress: 'music-posting.eth',
content: 'body',
parentCid: 'thread-cid',
replyCount: 0,
},
}),
);
});
expect(container.querySelector('[data-testid="post-desktop"]')?.getAttribute('data-transfer-enabled')).toBe('false');
});
it('hydrates thread pages from cached feed data, sets the document title, and renders thread footers', async () => {
testState.commentsByCid = {
'cached-cid': {
+9
View File
@@ -27,6 +27,7 @@ import PostDesktop from '../../components/post-desktop/post-desktop';
import PostMobile from '../../components/post-mobile/post-mobile';
import { getRequestedThreadTopCid, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
import { evictThreadRefreshCaches } from '../../lib/utils/thread-refresh-cache-utils';
import { hasTransferredCommentMarker } from '../../lib/comment-transfer';
import { REPLIES_PER_PAGE } from '../../lib/constants';
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
import type { QueuedCommentRouteState } from '../../lib/utils/mod-queue-utils';
@@ -205,6 +206,7 @@ export interface PostProps {
isPublishing?: boolean;
onApprove?: () => void;
onReject?: () => void;
onTransfer?: () => void;
onRemoveFromModQueue?: () => void;
quotedByMap?: Map<string, Comment[]>;
}
@@ -221,6 +223,7 @@ export const Post = memo(
isPublishing,
onApprove,
onReject,
onTransfer,
onRemoveFromModQueue,
feedVirtualizationModeOverride,
replyPaginationOverride,
@@ -239,6 +242,7 @@ export const Post = memo(
// handle pending mod or author edit
const { editedComment } = useEditedComment({ comment });
comment = mergeCommentFallback(editedComment as CommentWithRefresh | undefined, comment as CommentWithRefresh | undefined);
const transferHandler = comment?.parentCid ? undefined : onTransfer;
return (
<div className={styles.thread}>
@@ -259,6 +263,7 @@ export const Post = memo(
isPublishing={isPublishing}
onApprove={onApprove}
onReject={onReject}
onTransfer={transferHandler}
onRemoveFromModQueue={onRemoveFromModQueue}
/>
) : (
@@ -277,6 +282,7 @@ export const Post = memo(
isPublishing={isPublishing}
onApprove={onApprove}
onReject={onReject}
onTransfer={transferHandler}
onRemoveFromModQueue={onRemoveFromModQueue}
/>
)}
@@ -290,6 +296,7 @@ export const Post = memo(
return (
prev?.cid === next?.cid &&
prev?.number === next?.number &&
prev?.parentCid === next?.parentCid &&
prev?.postNumber === next?.postNumber &&
prev?.replyCount === next?.replyCount &&
prev?.updatedAt === next?.updatedAt &&
@@ -309,6 +316,7 @@ export const Post = memo(
prev?.deleted === next?.deleted &&
prev?.reason === next?.reason &&
prev?.commentModeration?.purged === next?.commentModeration?.purged &&
hasTransferredCommentMarker(prev) === hasTransferredCommentMarker(next) &&
prevProps.showAllReplies === nextProps.showAllReplies &&
prevProps.showReplies === nextProps.showReplies &&
prevProps.targetReplyCid === nextProps.targetReplyCid &&
@@ -321,6 +329,7 @@ export const Post = memo(
prevProps.isPublishing === nextProps.isPublishing &&
prevProps.onApprove === nextProps.onApprove &&
prevProps.onReject === nextProps.onReject &&
prevProps.onTransfer === nextProps.onTransfer &&
prevProps.onRemoveFromModQueue === nextProps.onRemoveFromModQueue
);
},