fix(mod queue): keep approved items from sticking

This commit is contained in:
Tommaso Casaburi
2026-04-19 13:45:24 +07:00
parent df01cbfb49
commit 8957f0439e
15 changed files with 792 additions and 83 deletions
@@ -25,9 +25,14 @@ let container: HTMLDivElement;
let root: Root;
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const renderDisplay = async (error: unknown) => {
const renderDisplay = async (props: { error: unknown; displayMessage?: string; inline?: boolean; showImmediately?: boolean } | unknown) => {
const normalizedProps =
props && typeof props === 'object' && 'error' in (props as Record<string, unknown>)
? (props as { error: unknown; displayMessage?: string; inline?: boolean; showImmediately?: boolean })
: { error: props };
await act(async () => {
root.render(createElement(ErrorDisplay, { error }));
root.render(createElement(ErrorDisplay, normalizedProps));
});
};
@@ -97,6 +102,27 @@ describe('ErrorDisplay', () => {
expect(container.textContent).toContain('copy failed');
});
it('supports compact custom labels that copy plain string errors immediately', async () => {
testState.copyToClipboardMock.mockResolvedValue(undefined);
await renderDisplay({
displayMessage: 'failed',
error: 'All pubsub providers throw an error and unable to publish or subscribe',
inline: true,
showImmediately: true,
});
const button = container.querySelector('button');
expect(button?.textContent).toBe('failed');
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.copyToClipboardMock).toHaveBeenCalledWith('All pubsub providers throw an error and unable to publish or subscribe');
expect(container.textContent).toContain('full error copied to the clipboard');
});
it('renders plain string errors after the delay and hides again when the error clears', async () => {
await renderDisplay('plain failure');
act(() => {
@@ -1,3 +1,13 @@
.error {
padding: 10px;
text-align: center;
}
.inlineError {
display: inline-flex;
align-items: center;
}
.errorMessage {
color: red;
}
+38 -11
View File
@@ -12,9 +12,28 @@ function reducer(state: State, action: { type: 'RESET_DELAY' } | { type: 'SHOW'
return state;
}
const ErrorDisplay = ({ error }: { error: any }) => {
const serializeErrorForClipboard = (error: unknown): string => {
if (typeof error === 'string') {
return error;
}
try {
return JSON.stringify(error, null, 2);
} catch {
return String(error);
}
};
type ErrorDisplayProps = {
error: any;
displayMessage?: string;
inline?: boolean;
showImmediately?: boolean;
};
const ErrorDisplay = ({ error, displayMessage, inline = false, showImmediately = false }: ErrorDisplayProps) => {
const { t } = useTranslation();
const [state, dispatch] = useReducer(reducer, { showAfterDelay: false, feedbackMessageKey: null });
const [state, dispatch] = useReducer(reducer, { showAfterDelay: showImmediately, feedbackMessageKey: null });
const hasError = !!(error?.message || error?.stack || error?.details || error);
@@ -23,20 +42,25 @@ const ErrorDisplay = ({ error }: { error: any }) => {
queueMicrotask(() => dispatch({ type: 'RESET_DELAY' }));
return;
}
if (showImmediately) {
queueMicrotask(() => dispatch({ type: 'SHOW' }));
return;
}
const timer = setTimeout(() => dispatch({ type: 'SHOW' }), 1000);
return () => clearTimeout(timer);
}, [hasError]);
}, [hasError, showImmediately]);
if (!hasError || !state.showAfterDelay) {
return null;
}
const originalDisplayMessage = error?.message ? `${t('error')}: ${error.message}` : typeof error === 'string' ? error : null;
const originalDisplayMessage = displayMessage || (error?.message ? `${t('error')}: ${error.message}` : typeof error === 'string' ? error : null);
const canCopyError = !!error && (!!displayMessage || !!error?.message);
const handleMessageClick = async () => {
if (!error || !error.message || state.feedbackMessageKey) return;
if (!canCopyError || state.feedbackMessageKey) return;
const errorString = JSON.stringify(error, null, 2);
const errorString = serializeErrorForClipboard(error);
try {
await copyToClipboard(errorString);
dispatch({ type: 'FEEDBACK', payload: 'copied' });
@@ -49,23 +73,26 @@ const ErrorDisplay = ({ error }: { error: any }) => {
};
let currentDisplayMessage = '';
const classNames = [styles.errorMessage];
const classNames: string[] = [];
let isClickable = false;
if (state.feedbackMessageKey === 'copied') {
currentDisplayMessage = t('fullErrorCopiedToClipboard', 'full error copied to the clipboard');
classNames.pop();
classNames.push(styles.feedbackSuccessMessage);
} else if (state.feedbackMessageKey === 'failed') {
currentDisplayMessage = t('copyFailed', 'copy failed');
classNames.push(styles.errorMessage);
} else if (originalDisplayMessage) {
currentDisplayMessage = originalDisplayMessage;
isClickable = true;
classNames.push(styles.clickableErrorMessage);
classNames.push(styles.errorMessage);
isClickable = canCopyError;
if (isClickable) {
classNames.push(styles.clickableErrorMessage);
}
}
return (
<div className={styles.error}>
<div className={inline ? styles.inlineError : styles.error}>
{currentDisplayMessage &&
(isClickable ? (
<button
@@ -4,6 +4,7 @@
.ellipsis {
display: inline-block;
text-align: left;
width: 4ch;
}
@@ -49,4 +50,4 @@
100% {
content: "";
}
}
}
+16 -10
View File
@@ -60,6 +60,8 @@ import {
hasEnoughPreviewReplies,
} from '../../lib/utils/replies-preview-utils';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { formatErrorForDisplay } from '../../lib/utils/error-utils';
import { getModQueueCommentRoute, getQueuedCommentRouteState } from '../../lib/utils/mod-queue-utils';
import { getThreadTopNavigationState, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
import useDeleteFailedPost from '../../hooks/use-delete-failed-post';
import { getThreadPostCountsByAuthor } from '../../lib/utils/author-post-counts';
@@ -111,8 +113,8 @@ const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
onError: (error: Error) => {
console.error('Approve failed:', error);
onError: (error: Error & { details?: unknown }) => {
console.error('Approve failed:', error, error.details);
},
});
@@ -130,8 +132,8 @@ const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
onError: (error: Error) => {
console.error('Reject failed:', error);
onError: (error: Error & { details?: unknown }) => {
console.error('Reject failed:', error, error.details);
},
});
@@ -168,7 +170,8 @@ const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string
const rejectPendingFailed = initiatedPendingAction === 'reject' && rejectPendingState === 'failed';
const pendingStatus = approvePendingSucceeded ? 'approved' : rejectPendingSucceeded ? 'rejected' : approvePendingFailed || rejectPendingFailed ? 'failed' : null;
const pendingErrorMessage = approvePendingFailed ? approvePendingError?.message : rejectPendingFailed ? rejectPendingError?.message : undefined;
const pendingError = approvePendingFailed ? approvePendingError : rejectPendingFailed ? rejectPendingError : undefined;
const pendingErrorMessage = formatErrorForDisplay(pendingError);
return (
<span className={styles.modQueueActions}>
@@ -177,7 +180,7 @@ const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string
) : pendingStatus === 'rejected' ? (
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
) : pendingStatus === 'failed' ? (
<span className={styles.modQueueStatusRejected}>
<span className={styles.modQueueStatusRejected} title={pendingErrorMessage}>
{t('failed')}
{pendingErrorMessage ? `: ${pendingErrorMessage}` : ''}
</span>
@@ -298,6 +301,9 @@ const PostInfo = ({
const threadRoute = cid ? (boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`) : undefined;
const threadTopNavigationState = !isReply ? getThreadTopNavigationState(cid) : undefined;
const modQueueThreadRoute = getModQueueCommentRoute(boardPath, post?.cid);
const modQueueThreadRouteState = getQueuedCommentRouteState(post);
const modQueueErrorMessage = formatErrorForDisplay(modQueueError);
const onLinkToPostClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (!cid || !threadRoute) {
@@ -469,18 +475,18 @@ const PostInfo = ({
) : modQueueStatus === 'rejected' ? (
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
) : modQueueStatus === 'failed' ? (
<span className={styles.modQueueStatusRejected}>
<span className={styles.modQueueStatusRejected} title={modQueueErrorMessage}>
{t('failed')}
{modQueueError ? `: ${modQueueError}` : ''}
{modQueueErrorMessage ? `: ${modQueueErrorMessage}` : ''}
</span>
) : isPublishing ? (
<LoadingEllipsis string={t('publishing')} />
) : (
<>
{isReply && boardPath && (post?.threadCid || post?.parentCid) && (
{isReply && modQueueThreadRoute && (
<span className={styles.modQueueButtonWrapper}>
[
<Link to={`/${boardPath}/thread/${post.threadCid || post.parentCid}`} className={styles.modQueueActionButton}>
<Link to={modQueueThreadRoute} state={modQueueThreadRouteState} className={styles.modQueueActionButton}>
{t('view_thread')}
</Link>
]
+16 -10
View File
@@ -47,6 +47,8 @@ import useReplyHeightEstimates from '../../hooks/use-reply-height-estimates';
import useFreshReplies from '../../hooks/use-fresh-replies';
import { BOARD_REPLIES_PREVIEW_FETCH_SIZE, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT, REPLIES_PER_PAGE } from '../../lib/constants';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { formatErrorForDisplay } from '../../lib/utils/error-utils';
import { getModQueueCommentRoute, getQueuedCommentRouteState } from '../../lib/utils/mod-queue-utils';
import { filterRepliesForDisplay, getPreviewDisplayReplies, hasEnoughPreviewReplies } from '../../lib/utils/replies-preview-utils';
import { getRenderableMobileBacklinks } from '../../lib/utils/reply-backlink-utils';
import { getThreadTopNavigationState, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
@@ -125,8 +127,8 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber, posts
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
onError: (error: Error) => {
console.error('Approve failed:', error);
onError: (error: Error & { details?: unknown }) => {
console.error('Approve failed:', error, error.details);
},
});
@@ -144,8 +146,8 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber, posts
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
onError: (error: Error) => {
console.error('Reject failed:', error);
onError: (error: Error & { details?: unknown }) => {
console.error('Reject failed:', error, error.details);
},
});
@@ -189,7 +191,8 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber, posts
const rejectPendingFailed = initiatedPendingAction === 'reject' && rejectPendingState === 'failed';
const pendingStatus = approvePendingSucceeded ? 'approved' : rejectPendingSucceeded ? 'rejected' : approvePendingFailed || rejectPendingFailed ? 'failed' : null;
const pendingErrorMessage = approvePendingFailed ? approvePendingError?.message : rejectPendingFailed ? rejectPendingError?.message : undefined;
const pendingError = approvePendingFailed ? approvePendingError : rejectPendingFailed ? rejectPendingError : undefined;
const pendingErrorMessage = formatErrorForDisplay(pendingError);
const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
@@ -412,7 +415,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber, posts
) : pendingStatus === 'rejected' ? (
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
) : pendingStatus === 'failed' ? (
<span className={styles.modQueueStatusRejected}>
<span className={styles.modQueueStatusRejected} title={pendingErrorMessage}>
{t('failed')}
{pendingErrorMessage ? `: ${pendingErrorMessage}` : ''}
</span>
@@ -577,6 +580,9 @@ const PostMobile = ({
const directoryEntry = findDirectoryByAddress(directories, communityAddress);
const requirePostLinkIsMedia = directoryEntry?.features?.requirePostLinkIsMedia === true;
const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined;
const modQueueThreadRoute = getModQueueCommentRoute(boardPath, resolvedPost?.cid);
const modQueueThreadRouteState = getQueuedCommentRouteState(resolvedPost);
const modQueueErrorMessage = formatErrorForDisplay(modQueueError);
const linksCount = useCountLinksInReplies(resolvedPost);
const hasReplyPaginationOverride = !!replyPaginationOverride;
const shouldFetchReplies = showReplies && !isModQueue && !hasReplyPaginationOverride;
@@ -848,16 +854,16 @@ const PostMobile = ({
) : modQueueStatus === 'rejected' ? (
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
) : modQueueStatus === 'failed' ? (
<span className={styles.modQueueStatusRejected}>
<span className={styles.modQueueStatusRejected} title={modQueueErrorMessage}>
{t('failed')}
{modQueueError ? `: ${modQueueError}` : ''}
{modQueueErrorMessage ? `: ${modQueueErrorMessage}` : ''}
</span>
) : isPublishing ? (
<LoadingEllipsis string={t('publishing')} />
) : (
<>
{isReply && boardPath && (resolvedPost?.threadCid || resolvedPost?.parentCid) && (
<Link to={`/${boardPath}/thread/${resolvedPost.threadCid || resolvedPost.parentCid}`} className={`button ${styles.approveButton}`}>
{isReply && modQueueThreadRoute && (
<Link to={modQueueThreadRoute} state={modQueueThreadRouteState} className={`button ${styles.approveButton}`}>
{t('view_thread')}
</Link>
)}