mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(mod queue): keep approved items from sticking
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user