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:
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitsocialnet/bitsocial-react-hooks": "https://codeload.github.com/bitsocialnet/bitsocial-react-hooks/tar.gz/e25b593aa05d505a0eb62cbde0505a55523d1998",
|
"@bitsocialnet/bitsocial-react-hooks": "https://codeload.github.com/bitsocialnet/bitsocial-react-hooks/tar.gz/f8af7b652dc924b4392105cc4166ab01486bd79f",
|
||||||
"@capacitor/app": "7.0.1",
|
"@capacitor/app": "7.0.1",
|
||||||
"@capacitor/status-bar": "7.0.1",
|
"@capacitor/status-bar": "7.0.1",
|
||||||
"@capawesome/capacitor-android-edge-to-edge-support": "7.2.2",
|
"@capawesome/capacitor-android-edge-to-edge-support": "7.2.2",
|
||||||
|
|||||||
@@ -25,9 +25,14 @@ let container: HTMLDivElement;
|
|||||||
let root: Root;
|
let root: Root;
|
||||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
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 () => {
|
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');
|
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 () => {
|
it('renders plain string errors after the delay and hides again when the error clears', async () => {
|
||||||
await renderDisplay('plain failure');
|
await renderDisplay('plain failure');
|
||||||
act(() => {
|
act(() => {
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
.error {
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineError {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.errorMessage {
|
.errorMessage {
|
||||||
color: red;
|
color: red;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,28 @@ function reducer(state: State, action: { type: 'RESET_DELAY' } | { type: 'SHOW'
|
|||||||
return state;
|
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 { 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);
|
const hasError = !!(error?.message || error?.stack || error?.details || error);
|
||||||
|
|
||||||
@@ -23,20 +42,25 @@ const ErrorDisplay = ({ error }: { error: any }) => {
|
|||||||
queueMicrotask(() => dispatch({ type: 'RESET_DELAY' }));
|
queueMicrotask(() => dispatch({ type: 'RESET_DELAY' }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (showImmediately) {
|
||||||
|
queueMicrotask(() => dispatch({ type: 'SHOW' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const timer = setTimeout(() => dispatch({ type: 'SHOW' }), 1000);
|
const timer = setTimeout(() => dispatch({ type: 'SHOW' }), 1000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [hasError]);
|
}, [hasError, showImmediately]);
|
||||||
|
|
||||||
if (!hasError || !state.showAfterDelay) {
|
if (!hasError || !state.showAfterDelay) {
|
||||||
return null;
|
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 () => {
|
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 {
|
try {
|
||||||
await copyToClipboard(errorString);
|
await copyToClipboard(errorString);
|
||||||
dispatch({ type: 'FEEDBACK', payload: 'copied' });
|
dispatch({ type: 'FEEDBACK', payload: 'copied' });
|
||||||
@@ -49,23 +73,26 @@ const ErrorDisplay = ({ error }: { error: any }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let currentDisplayMessage = '';
|
let currentDisplayMessage = '';
|
||||||
const classNames = [styles.errorMessage];
|
const classNames: string[] = [];
|
||||||
let isClickable = false;
|
let isClickable = false;
|
||||||
|
|
||||||
if (state.feedbackMessageKey === 'copied') {
|
if (state.feedbackMessageKey === 'copied') {
|
||||||
currentDisplayMessage = t('fullErrorCopiedToClipboard', 'full error copied to the clipboard');
|
currentDisplayMessage = t('fullErrorCopiedToClipboard', 'full error copied to the clipboard');
|
||||||
classNames.pop();
|
|
||||||
classNames.push(styles.feedbackSuccessMessage);
|
classNames.push(styles.feedbackSuccessMessage);
|
||||||
} else if (state.feedbackMessageKey === 'failed') {
|
} else if (state.feedbackMessageKey === 'failed') {
|
||||||
currentDisplayMessage = t('copyFailed', 'copy failed');
|
currentDisplayMessage = t('copyFailed', 'copy failed');
|
||||||
|
classNames.push(styles.errorMessage);
|
||||||
} else if (originalDisplayMessage) {
|
} else if (originalDisplayMessage) {
|
||||||
currentDisplayMessage = originalDisplayMessage;
|
currentDisplayMessage = originalDisplayMessage;
|
||||||
isClickable = true;
|
classNames.push(styles.errorMessage);
|
||||||
classNames.push(styles.clickableErrorMessage);
|
isClickable = canCopyError;
|
||||||
|
if (isClickable) {
|
||||||
|
classNames.push(styles.clickableErrorMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.error}>
|
<div className={inline ? styles.inlineError : styles.error}>
|
||||||
{currentDisplayMessage &&
|
{currentDisplayMessage &&
|
||||||
(isClickable ? (
|
(isClickable ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
.ellipsis {
|
.ellipsis {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
text-align: left;
|
||||||
width: 4ch;
|
width: 4ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ import {
|
|||||||
hasEnoughPreviewReplies,
|
hasEnoughPreviewReplies,
|
||||||
} from '../../lib/utils/replies-preview-utils';
|
} from '../../lib/utils/replies-preview-utils';
|
||||||
import { isCommentArchived } from '../../lib/utils/comment-moderation-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 { getThreadTopNavigationState, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
|
||||||
import useDeleteFailedPost from '../../hooks/use-delete-failed-post';
|
import useDeleteFailedPost from '../../hooks/use-delete-failed-post';
|
||||||
import { getThreadPostCountsByAuthor } from '../../lib/utils/author-post-counts';
|
import { getThreadPostCountsByAuthor } from '../../lib/utils/author-post-counts';
|
||||||
@@ -111,8 +113,8 @@ const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string
|
|||||||
onChallengeVerification: async (challengeVerification, comment) => {
|
onChallengeVerification: async (challengeVerification, comment) => {
|
||||||
alertChallengeVerificationFailed(challengeVerification, comment);
|
alertChallengeVerificationFailed(challengeVerification, comment);
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error & { details?: unknown }) => {
|
||||||
console.error('Approve failed:', error);
|
console.error('Approve failed:', error, error.details);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,8 +132,8 @@ const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string
|
|||||||
onChallengeVerification: async (challengeVerification, comment) => {
|
onChallengeVerification: async (challengeVerification, comment) => {
|
||||||
alertChallengeVerificationFailed(challengeVerification, comment);
|
alertChallengeVerificationFailed(challengeVerification, comment);
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error & { details?: unknown }) => {
|
||||||
console.error('Reject failed:', error);
|
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 rejectPendingFailed = initiatedPendingAction === 'reject' && rejectPendingState === 'failed';
|
||||||
|
|
||||||
const pendingStatus = approvePendingSucceeded ? 'approved' : rejectPendingSucceeded ? 'rejected' : approvePendingFailed || rejectPendingFailed ? 'failed' : null;
|
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 (
|
return (
|
||||||
<span className={styles.modQueueActions}>
|
<span className={styles.modQueueActions}>
|
||||||
@@ -177,7 +180,7 @@ const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string
|
|||||||
) : pendingStatus === 'rejected' ? (
|
) : pendingStatus === 'rejected' ? (
|
||||||
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
|
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
|
||||||
) : pendingStatus === 'failed' ? (
|
) : pendingStatus === 'failed' ? (
|
||||||
<span className={styles.modQueueStatusRejected}>
|
<span className={styles.modQueueStatusRejected} title={pendingErrorMessage}>
|
||||||
{t('failed')}
|
{t('failed')}
|
||||||
{pendingErrorMessage ? `: ${pendingErrorMessage}` : ''}
|
{pendingErrorMessage ? `: ${pendingErrorMessage}` : ''}
|
||||||
</span>
|
</span>
|
||||||
@@ -298,6 +301,9 @@ const PostInfo = ({
|
|||||||
|
|
||||||
const threadRoute = cid ? (boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`) : undefined;
|
const threadRoute = cid ? (boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`) : undefined;
|
||||||
const threadTopNavigationState = !isReply ? getThreadTopNavigationState(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>) => {
|
const onLinkToPostClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
if (!cid || !threadRoute) {
|
if (!cid || !threadRoute) {
|
||||||
@@ -469,18 +475,18 @@ const PostInfo = ({
|
|||||||
) : modQueueStatus === 'rejected' ? (
|
) : modQueueStatus === 'rejected' ? (
|
||||||
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
|
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
|
||||||
) : modQueueStatus === 'failed' ? (
|
) : modQueueStatus === 'failed' ? (
|
||||||
<span className={styles.modQueueStatusRejected}>
|
<span className={styles.modQueueStatusRejected} title={modQueueErrorMessage}>
|
||||||
{t('failed')}
|
{t('failed')}
|
||||||
{modQueueError ? `: ${modQueueError}` : ''}
|
{modQueueErrorMessage ? `: ${modQueueErrorMessage}` : ''}
|
||||||
</span>
|
</span>
|
||||||
) : isPublishing ? (
|
) : isPublishing ? (
|
||||||
<LoadingEllipsis string={t('publishing')} />
|
<LoadingEllipsis string={t('publishing')} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{isReply && boardPath && (post?.threadCid || post?.parentCid) && (
|
{isReply && modQueueThreadRoute && (
|
||||||
<span className={styles.modQueueButtonWrapper}>
|
<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')}
|
{t('view_thread')}
|
||||||
</Link>
|
</Link>
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ import useReplyHeightEstimates from '../../hooks/use-reply-height-estimates';
|
|||||||
import useFreshReplies from '../../hooks/use-fresh-replies';
|
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 { 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 { 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 { filterRepliesForDisplay, getPreviewDisplayReplies, hasEnoughPreviewReplies } from '../../lib/utils/replies-preview-utils';
|
||||||
import { getRenderableMobileBacklinks } from '../../lib/utils/reply-backlink-utils';
|
import { getRenderableMobileBacklinks } from '../../lib/utils/reply-backlink-utils';
|
||||||
import { getThreadTopNavigationState, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-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) => {
|
onChallengeVerification: async (challengeVerification, comment) => {
|
||||||
alertChallengeVerificationFailed(challengeVerification, comment);
|
alertChallengeVerificationFailed(challengeVerification, comment);
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error & { details?: unknown }) => {
|
||||||
console.error('Approve failed:', error);
|
console.error('Approve failed:', error, error.details);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,8 +146,8 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber, posts
|
|||||||
onChallengeVerification: async (challengeVerification, comment) => {
|
onChallengeVerification: async (challengeVerification, comment) => {
|
||||||
alertChallengeVerificationFailed(challengeVerification, comment);
|
alertChallengeVerificationFailed(challengeVerification, comment);
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error & { details?: unknown }) => {
|
||||||
console.error('Reject failed:', error);
|
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 rejectPendingFailed = initiatedPendingAction === 'reject' && rejectPendingState === 'failed';
|
||||||
|
|
||||||
const pendingStatus = approvePendingSucceeded ? 'approved' : rejectPendingSucceeded ? 'rejected' : approvePendingFailed || rejectPendingFailed ? 'failed' : null;
|
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 commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
||||||
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
||||||
@@ -412,7 +415,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber, posts
|
|||||||
) : pendingStatus === 'rejected' ? (
|
) : pendingStatus === 'rejected' ? (
|
||||||
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
|
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
|
||||||
) : pendingStatus === 'failed' ? (
|
) : pendingStatus === 'failed' ? (
|
||||||
<span className={styles.modQueueStatusRejected}>
|
<span className={styles.modQueueStatusRejected} title={pendingErrorMessage}>
|
||||||
{t('failed')}
|
{t('failed')}
|
||||||
{pendingErrorMessage ? `: ${pendingErrorMessage}` : ''}
|
{pendingErrorMessage ? `: ${pendingErrorMessage}` : ''}
|
||||||
</span>
|
</span>
|
||||||
@@ -577,6 +580,9 @@ const PostMobile = ({
|
|||||||
const directoryEntry = findDirectoryByAddress(directories, communityAddress);
|
const directoryEntry = findDirectoryByAddress(directories, communityAddress);
|
||||||
const requirePostLinkIsMedia = directoryEntry?.features?.requirePostLinkIsMedia === true;
|
const requirePostLinkIsMedia = directoryEntry?.features?.requirePostLinkIsMedia === true;
|
||||||
const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined;
|
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 linksCount = useCountLinksInReplies(resolvedPost);
|
||||||
const hasReplyPaginationOverride = !!replyPaginationOverride;
|
const hasReplyPaginationOverride = !!replyPaginationOverride;
|
||||||
const shouldFetchReplies = showReplies && !isModQueue && !hasReplyPaginationOverride;
|
const shouldFetchReplies = showReplies && !isModQueue && !hasReplyPaginationOverride;
|
||||||
@@ -848,16 +854,16 @@ const PostMobile = ({
|
|||||||
) : modQueueStatus === 'rejected' ? (
|
) : modQueueStatus === 'rejected' ? (
|
||||||
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
|
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
|
||||||
) : modQueueStatus === 'failed' ? (
|
) : modQueueStatus === 'failed' ? (
|
||||||
<span className={styles.modQueueStatusRejected}>
|
<span className={styles.modQueueStatusRejected} title={modQueueErrorMessage}>
|
||||||
{t('failed')}
|
{t('failed')}
|
||||||
{modQueueError ? `: ${modQueueError}` : ''}
|
{modQueueErrorMessage ? `: ${modQueueErrorMessage}` : ''}
|
||||||
</span>
|
</span>
|
||||||
) : isPublishing ? (
|
) : isPublishing ? (
|
||||||
<LoadingEllipsis string={t('publishing')} />
|
<LoadingEllipsis string={t('publishing')} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{isReply && boardPath && (resolvedPost?.threadCid || resolvedPost?.parentCid) && (
|
{isReply && modQueueThreadRoute && (
|
||||||
<Link to={`/${boardPath}/thread/${resolvedPost.threadCid || resolvedPost.parentCid}`} className={`button ${styles.approveButton}`}>
|
<Link to={modQueueThreadRoute} state={modQueueThreadRouteState} className={`button ${styles.approveButton}`}>
|
||||||
{t('view_thread')}
|
{t('view_thread')}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatErrorForDisplay } from '../error-utils';
|
||||||
|
|
||||||
|
describe('error utils', () => {
|
||||||
|
it('returns plain string errors unchanged', () => {
|
||||||
|
expect(formatErrorForDisplay('plain failure')).toBe('plain failure');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends structured details to the message', () => {
|
||||||
|
expect(
|
||||||
|
formatErrorForDisplay({
|
||||||
|
details: {
|
||||||
|
plebpubsub: 'timeout',
|
||||||
|
pubsubprovider: 'connection refused',
|
||||||
|
},
|
||||||
|
message: 'All pubsub providers throw an error and unable to publish or subscribe',
|
||||||
|
}),
|
||||||
|
).toBe('All pubsub providers throw an error and unable to publish or subscribe: plebpubsub: timeout; pubsubprovider: connection refused');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to cause when details are missing', () => {
|
||||||
|
expect(
|
||||||
|
formatErrorForDisplay({
|
||||||
|
cause: {
|
||||||
|
provider: 'plebpubsub',
|
||||||
|
reason: 'timeout',
|
||||||
|
},
|
||||||
|
message: 'publish failed',
|
||||||
|
}),
|
||||||
|
).toBe('publish failed: provider: plebpubsub; reason: timeout');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { filterVisibleModQueueFeed, getModQueueCommentRoute, getQueuedCommentRouteState } from '../mod-queue-utils';
|
||||||
|
|
||||||
|
describe('mod queue utils', () => {
|
||||||
|
it('keeps only comments still awaiting approval', () => {
|
||||||
|
const feed = [
|
||||||
|
{ cid: 'pending', communityAddress: 'tech.eth', pendingApproval: true },
|
||||||
|
{ cid: 'approved', approved: true, communityAddress: 'tech.eth', pendingApproval: true },
|
||||||
|
{ cid: 'rejected', approved: false, communityAddress: 'tech.eth', pendingApproval: true },
|
||||||
|
{ cid: 'removed', communityAddress: 'tech.eth', pendingApproval: true, removed: true },
|
||||||
|
{ cid: 'published', communityAddress: 'tech.eth', pendingApproval: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(filterVisibleModQueueFeed(feed, null)).toEqual([{ cid: 'pending', communityAddress: 'tech.eth', pendingApproval: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the selected board filter after removing non-pending items', () => {
|
||||||
|
const feed = [
|
||||||
|
{ cid: 'tech-pending', communityAddress: 'tech.eth', pendingApproval: true },
|
||||||
|
{ cid: 'g-pending', communityAddress: 'g.eth', pendingApproval: true },
|
||||||
|
{ cid: 'tech-approved', approved: true, communityAddress: 'tech.eth', pendingApproval: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(filterVisibleModQueueFeed(feed, 'tech.eth')).toEqual([{ cid: 'tech-pending', communityAddress: 'tech.eth', pendingApproval: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds excerpt routes from the comment permalink cid', () => {
|
||||||
|
expect(getModQueueCommentRoute('g', 'reply-cid')).toBe('/g/thread/reply-cid');
|
||||||
|
expect(getModQueueCommentRoute('g', undefined)).toBeUndefined();
|
||||||
|
expect(getModQueueCommentRoute(undefined, 'reply-cid')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes queued comment route state for reply links', () => {
|
||||||
|
expect(
|
||||||
|
getQueuedCommentRouteState({
|
||||||
|
cid: 'reply-cid',
|
||||||
|
communityAddress: 'g.eth',
|
||||||
|
content: 'pending reply body',
|
||||||
|
parentCid: 'thread-cid',
|
||||||
|
pendingApproval: true,
|
||||||
|
postCid: 'thread-cid',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
queuedComment: {
|
||||||
|
approved: undefined,
|
||||||
|
author: undefined,
|
||||||
|
cid: 'reply-cid',
|
||||||
|
commentModeration: undefined,
|
||||||
|
communityAddress: 'g.eth',
|
||||||
|
content: 'pending reply body',
|
||||||
|
deleted: undefined,
|
||||||
|
link: undefined,
|
||||||
|
linkHeight: undefined,
|
||||||
|
linkWidth: undefined,
|
||||||
|
number: undefined,
|
||||||
|
parentCid: 'thread-cid',
|
||||||
|
pendingApproval: true,
|
||||||
|
postCid: 'thread-cid',
|
||||||
|
reason: undefined,
|
||||||
|
removed: undefined,
|
||||||
|
replyCount: undefined,
|
||||||
|
threadCid: undefined,
|
||||||
|
thumbnailUrl: undefined,
|
||||||
|
timestamp: undefined,
|
||||||
|
title: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
type ErrorLike = {
|
||||||
|
cause?: unknown;
|
||||||
|
details?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeUnknownErrorPart = (value: unknown): string | undefined => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.trim() || undefined;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const parts = value.map(normalizeUnknownErrorPart).filter(Boolean);
|
||||||
|
return parts.length ? parts.join('; ') : undefined;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const entries = Object.entries(value as Record<string, unknown>)
|
||||||
|
.map(([key, entryValue]) => {
|
||||||
|
const normalizedValue = normalizeUnknownErrorPart(entryValue);
|
||||||
|
return normalizedValue ? `${key}: ${normalizedValue}` : undefined;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (entries.length) {
|
||||||
|
return entries.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatErrorForDisplay = (error: unknown): string | undefined => {
|
||||||
|
if (!error) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedString = normalizeUnknownErrorPart(error);
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return normalizedString;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cause, details, message } = error as ErrorLike;
|
||||||
|
const normalizedMessage = normalizeUnknownErrorPart(message);
|
||||||
|
const normalizedDetails = normalizeUnknownErrorPart(details);
|
||||||
|
const normalizedCause = normalizeUnknownErrorPart(cause);
|
||||||
|
|
||||||
|
const detailParts = [normalizedDetails, normalizedCause].filter(Boolean);
|
||||||
|
if (normalizedMessage && detailParts.length) {
|
||||||
|
const detailText = detailParts.join('; ');
|
||||||
|
return normalizedMessage.includes(detailText) ? normalizedMessage : `${normalizedMessage}: ${detailText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedMessage || detailParts[0] || normalizedString;
|
||||||
|
};
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { Comment } from '@bitsocialnet/bitsocial-react-hooks';
|
||||||
|
import { getCommentCommunityAddress } from './comment-utils';
|
||||||
|
import { isPendingApprovalAwaiting } from './pending-approval-moderation';
|
||||||
|
import { getThreadTopNavigationState } from './thread-scroll-utils';
|
||||||
|
|
||||||
|
type ModQueueCommentLike = {
|
||||||
|
approved?: boolean;
|
||||||
|
author?: Comment['author'];
|
||||||
|
cid?: string;
|
||||||
|
commentModeration?: Comment['commentModeration'];
|
||||||
|
communityAddress?: string;
|
||||||
|
content?: Comment['content'];
|
||||||
|
deleted?: Comment['deleted'];
|
||||||
|
link?: Comment['link'];
|
||||||
|
linkHeight?: Comment['linkHeight'];
|
||||||
|
linkWidth?: Comment['linkWidth'];
|
||||||
|
number?: Comment['number'];
|
||||||
|
parentCid?: Comment['parentCid'];
|
||||||
|
pendingApproval?: boolean;
|
||||||
|
postCid?: Comment['postCid'];
|
||||||
|
reason?: Comment['reason'];
|
||||||
|
removed?: boolean;
|
||||||
|
replyCount?: Comment['replyCount'];
|
||||||
|
threadCid?: Comment['threadCid'];
|
||||||
|
thumbnailUrl?: Comment['thumbnailUrl'];
|
||||||
|
timestamp?: Comment['timestamp'];
|
||||||
|
title?: Comment['title'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QueuedCommentRouteState = {
|
||||||
|
scrollThreadContainerCid?: string;
|
||||||
|
queuedComment?: {
|
||||||
|
approved?: Comment['approved'];
|
||||||
|
author?: Comment['author'];
|
||||||
|
cid?: Comment['cid'];
|
||||||
|
commentModeration?: Comment['commentModeration'];
|
||||||
|
communityAddress?: string;
|
||||||
|
content?: Comment['content'];
|
||||||
|
deleted?: Comment['deleted'];
|
||||||
|
link?: Comment['link'];
|
||||||
|
linkHeight?: Comment['linkHeight'];
|
||||||
|
linkWidth?: Comment['linkWidth'];
|
||||||
|
number?: Comment['number'];
|
||||||
|
parentCid?: Comment['parentCid'];
|
||||||
|
pendingApproval?: Comment['pendingApproval'];
|
||||||
|
postCid?: Comment['postCid'];
|
||||||
|
reason?: Comment['reason'];
|
||||||
|
removed?: Comment['removed'];
|
||||||
|
replyCount?: Comment['replyCount'];
|
||||||
|
threadCid?: Comment['threadCid'];
|
||||||
|
thumbnailUrl?: Comment['thumbnailUrl'];
|
||||||
|
timestamp?: Comment['timestamp'];
|
||||||
|
title?: Comment['title'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getModQueueCommentRoute = (boardPath: string | undefined, commentCid: string | undefined): string | undefined =>
|
||||||
|
boardPath && commentCid ? `/${boardPath}/thread/${commentCid}` : undefined;
|
||||||
|
|
||||||
|
export const getQueuedCommentRouteState = (comment: ModQueueCommentLike | undefined): QueuedCommentRouteState | undefined => {
|
||||||
|
if (!comment?.cid) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(comment.parentCid ? {} : getThreadTopNavigationState(comment.cid)),
|
||||||
|
queuedComment: {
|
||||||
|
approved: comment.approved,
|
||||||
|
author: comment.author,
|
||||||
|
cid: comment.cid,
|
||||||
|
commentModeration: comment.commentModeration,
|
||||||
|
communityAddress: getCommentCommunityAddress(comment),
|
||||||
|
content: comment.content,
|
||||||
|
deleted: comment.deleted,
|
||||||
|
link: comment.link,
|
||||||
|
linkHeight: comment.linkHeight,
|
||||||
|
linkWidth: comment.linkWidth,
|
||||||
|
number: comment.number,
|
||||||
|
parentCid: comment.parentCid,
|
||||||
|
pendingApproval: comment.pendingApproval,
|
||||||
|
postCid: comment.postCid,
|
||||||
|
reason: comment.reason,
|
||||||
|
removed: comment.removed,
|
||||||
|
replyCount: comment.replyCount,
|
||||||
|
threadCid: comment.threadCid,
|
||||||
|
thumbnailUrl: comment.thumbnailUrl,
|
||||||
|
timestamp: comment.timestamp,
|
||||||
|
title: comment.title,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterVisibleModQueueFeed = <T extends ModQueueCommentLike>(feed: T[], selectedBoardFilter: string | null): T[] =>
|
||||||
|
feed.filter((comment) => {
|
||||||
|
if (!isPendingApprovalAwaiting(comment)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedBoardFilter) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getCommentCommunityAddress(comment) === selectedBoardFilter;
|
||||||
|
});
|
||||||
@@ -24,6 +24,8 @@ import useFeedResetStore from '../../stores/use-feed-reset-store';
|
|||||||
import useChallengesStore from '../../stores/use-challenges-store';
|
import useChallengesStore from '../../stores/use-challenges-store';
|
||||||
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
|
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
|
||||||
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
|
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
|
||||||
|
import { formatErrorForDisplay } from '../../lib/utils/error-utils';
|
||||||
|
import { filterVisibleModQueueFeed, getModQueueCommentRoute, getQueuedCommentRouteState } from '../../lib/utils/mod-queue-utils';
|
||||||
import Tooltip from '../../components/tooltip';
|
import Tooltip from '../../components/tooltip';
|
||||||
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
|
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
|
||||||
import { useCommunityIdentifier, useCommunityIdentifiers } from '../../hooks/use-community-identifiers';
|
import { useCommunityIdentifier, useCommunityIdentifiers } from '../../hooks/use-community-identifiers';
|
||||||
@@ -83,6 +85,7 @@ type ModerationAction = 'approve' | 'reject' | null;
|
|||||||
|
|
||||||
interface ModQueueActionState {
|
interface ModQueueActionState {
|
||||||
status: 'approved' | 'rejected' | 'failed' | null;
|
status: 'approved' | 'rejected' | 'failed' | null;
|
||||||
|
error?: unknown;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
isPublishing: boolean;
|
isPublishing: boolean;
|
||||||
handleApprove: () => Promise<void>;
|
handleApprove: () => Promise<void>;
|
||||||
@@ -91,6 +94,7 @@ interface ModQueueActionState {
|
|||||||
|
|
||||||
interface ModQueueActionsProps {
|
interface ModQueueActionsProps {
|
||||||
status: 'approved' | 'rejected' | 'failed' | null;
|
status: 'approved' | 'rejected' | 'failed' | null;
|
||||||
|
error?: unknown;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
isPublishing: boolean;
|
isPublishing: boolean;
|
||||||
handleApprove: () => Promise<void>;
|
handleApprove: () => Promise<void>;
|
||||||
@@ -98,8 +102,9 @@ interface ModQueueActionsProps {
|
|||||||
variant: 'row' | 'card';
|
variant: 'row' | 'card';
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModQueueActions = ({ status, errorMessage, isPublishing, handleApprove, handleReject, variant }: ModQueueActionsProps) => {
|
const ModQueueActions = ({ status, error, errorMessage, isPublishing, handleApprove, handleReject, variant }: ModQueueActionsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const displayError = error || errorMessage;
|
||||||
|
|
||||||
if (status === 'approved') {
|
if (status === 'approved') {
|
||||||
const content = <span className={`${styles.button} ${styles.approve}`}>{t('approved')}</span>;
|
const content = <span className={`${styles.button} ${styles.approve}`}>{t('approved')}</span>;
|
||||||
@@ -110,11 +115,10 @@ const ModQueueActions = ({ status, errorMessage, isPublishing, handleApprove, ha
|
|||||||
return variant === 'card' ? <div className={styles.cardActions}>{content}</div> : content;
|
return variant === 'card' ? <div className={styles.cardActions}>{content}</div> : content;
|
||||||
}
|
}
|
||||||
if (status === 'failed') {
|
if (status === 'failed') {
|
||||||
const content = (
|
const content = displayError ? (
|
||||||
<span className={`${styles.button} ${styles.reject}`}>
|
<ErrorDisplay error={displayError} displayMessage={t('failed')} inline={true} showImmediately={true} />
|
||||||
{t('failed')}
|
) : (
|
||||||
{errorMessage ? `: ${errorMessage}` : ''}
|
<span className={`${styles.button} ${styles.reject}`}>{t('failed')}</span>
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
return variant === 'card' ? <div className={styles.cardActions}>{content}</div> : content;
|
return variant === 'card' ? <div className={styles.cardActions}>{content}</div> : content;
|
||||||
}
|
}
|
||||||
@@ -178,8 +182,8 @@ const useModQueueActions = (comment: Comment): ModQueueActionState => {
|
|||||||
onChallengeVerification: async (challengeVerification, comment) => {
|
onChallengeVerification: async (challengeVerification, comment) => {
|
||||||
alertChallengeVerificationFailed(challengeVerification, comment);
|
alertChallengeVerificationFailed(challengeVerification, comment);
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error & { details?: unknown }) => {
|
||||||
console.error('Approve failed:', error);
|
console.error('Approve failed:', error, error.details);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -197,8 +201,8 @@ const useModQueueActions = (comment: Comment): ModQueueActionState => {
|
|||||||
onChallengeVerification: async (challengeVerification, comment) => {
|
onChallengeVerification: async (challengeVerification, comment) => {
|
||||||
alertChallengeVerificationFailed(challengeVerification, comment);
|
alertChallengeVerificationFailed(challengeVerification, comment);
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error & { details?: unknown }) => {
|
||||||
console.error('Reject failed:', error);
|
console.error('Reject failed:', error, error.details);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -241,9 +245,10 @@ const useModQueueActions = (comment: Comment): ModQueueActionState => {
|
|||||||
const rejectFailed = initiatedAction === 'reject' && rejectState === 'failed';
|
const rejectFailed = initiatedAction === 'reject' && rejectState === 'failed';
|
||||||
|
|
||||||
const status = alreadyApproved || approveSucceeded ? 'approved' : alreadyRejected || rejectSucceeded ? 'rejected' : approveFailed || rejectFailed ? 'failed' : null;
|
const status = alreadyApproved || approveSucceeded ? 'approved' : alreadyRejected || rejectSucceeded ? 'rejected' : approveFailed || rejectFailed ? 'failed' : null;
|
||||||
const errorMessage = approveFailed ? approveError?.message : rejectFailed ? rejectError?.message : undefined;
|
const error = approveFailed ? approveError : rejectFailed ? rejectError : undefined;
|
||||||
|
const errorMessage = formatErrorForDisplay(error);
|
||||||
|
|
||||||
return { status, errorMessage, isPublishing, handleApprove, handleReject };
|
return { status, error, errorMessage, isPublishing, handleApprove, handleReject };
|
||||||
};
|
};
|
||||||
|
|
||||||
const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath, boardDisplayPath }: ModQueueRowProps) => {
|
const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath, boardDisplayPath }: ModQueueRowProps) => {
|
||||||
@@ -255,16 +260,16 @@ const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath
|
|||||||
const { editedComment } = useEditedComment({ comment });
|
const { editedComment } = useEditedComment({ comment });
|
||||||
const displayComment = editedComment || comment;
|
const displayComment = editedComment || comment;
|
||||||
|
|
||||||
const { content, title, timestamp, cid, threadCid, link, thumbnailUrl, linkWidth, linkHeight, number, parentCid } = displayComment;
|
const { content, title, timestamp, cid, link, thumbnailUrl, linkWidth, linkHeight, number, parentCid } = displayComment;
|
||||||
|
|
||||||
const timeWaiting = currentTime - timestamp;
|
const timeWaiting = currentTime - timestamp;
|
||||||
const alertThresholdSeconds = getAlertThresholdSeconds();
|
const alertThresholdSeconds = getAlertThresholdSeconds();
|
||||||
const isOverThreshold = timeWaiting > alertThresholdSeconds;
|
const isOverThreshold = timeWaiting > alertThresholdSeconds;
|
||||||
|
|
||||||
// Only show alert animation for comments awaiting approval (not approved or rejected)
|
// Only show alert animation for comments awaiting approval (not approved or rejected)
|
||||||
const isAwaitingApproval = isPendingApprovalAwaiting(displayComment);
|
const isAwaitingApproval = isPendingApprovalAwaiting(comment);
|
||||||
|
|
||||||
const { status, errorMessage, isPublishing, handleApprove, handleReject } = useModQueueActions(displayComment);
|
const { status, error, errorMessage, isPublishing, handleApprove, handleReject } = useModQueueActions(comment);
|
||||||
const hasTitle = title && title.trim().length > 0;
|
const hasTitle = title && title.trim().length > 0;
|
||||||
const hasContent = content && content.trim().length > 0;
|
const hasContent = content && content.trim().length > 0;
|
||||||
const hasLink = link && link.length > 0;
|
const hasLink = link && link.length > 0;
|
||||||
@@ -281,8 +286,8 @@ const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath
|
|||||||
).trim();
|
).trim();
|
||||||
// Only truncate excerpt on desktop, allow wrapping on mobile
|
// Only truncate excerpt on desktop, allow wrapping on mobile
|
||||||
const excerpt = !isMobile && rawExcerpt.length > 101 ? rawExcerpt.slice(0, 98) + '...' : rawExcerpt;
|
const excerpt = !isMobile && rawExcerpt.length > 101 ? rawExcerpt.slice(0, 98) + '...' : rawExcerpt;
|
||||||
const threadTargetCid = threadCid || cid;
|
const postUrl = getModQueueCommentRoute(boardPath, comment.cid || cid);
|
||||||
const postUrl = boardPath && threadTargetCid ? `/${boardPath}/thread/${threadTargetCid}` : undefined;
|
const postUrlState = getQueuedCommentRouteState(comment);
|
||||||
|
|
||||||
const modQueueUrl = boardPath ? `/${boardPath}/mod/queue` : undefined;
|
const modQueueUrl = boardPath ? `/${boardPath}/mod/queue` : undefined;
|
||||||
|
|
||||||
@@ -294,7 +299,7 @@ const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath
|
|||||||
)}
|
)}
|
||||||
<div className={styles.excerpt}>
|
<div className={styles.excerpt}>
|
||||||
{postUrl ? (
|
{postUrl ? (
|
||||||
<Link to={postUrl} title={excerpt}>
|
<Link to={postUrl} state={postUrlState} title={excerpt}>
|
||||||
{excerpt}
|
{excerpt}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
@@ -331,6 +336,7 @@ const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath
|
|||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<ModQueueActions
|
<ModQueueActions
|
||||||
status={status}
|
status={status}
|
||||||
|
error={error}
|
||||||
errorMessage={errorMessage}
|
errorMessage={errorMessage}
|
||||||
isPublishing={isPublishing}
|
isPublishing={isPublishing}
|
||||||
handleApprove={handleApprove}
|
handleApprove={handleApprove}
|
||||||
@@ -360,14 +366,14 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplay
|
|||||||
const { editedComment } = useEditedComment({ comment });
|
const { editedComment } = useEditedComment({ comment });
|
||||||
const displayComment = editedComment || comment;
|
const displayComment = editedComment || comment;
|
||||||
|
|
||||||
const { content, title, timestamp, cid, threadCid, link, thumbnailUrl, linkWidth, linkHeight, number, parentCid } = displayComment;
|
const { content, title, timestamp, cid, link, thumbnailUrl, linkWidth, linkHeight, number, parentCid } = displayComment;
|
||||||
|
|
||||||
const timeWaiting = currentTime - timestamp;
|
const timeWaiting = currentTime - timestamp;
|
||||||
const alertThresholdSeconds = getAlertThresholdSeconds();
|
const alertThresholdSeconds = getAlertThresholdSeconds();
|
||||||
const isOverThreshold = timeWaiting > alertThresholdSeconds;
|
const isOverThreshold = timeWaiting > alertThresholdSeconds;
|
||||||
const isAwaitingApproval = isPendingApprovalAwaiting(displayComment);
|
const isAwaitingApproval = isPendingApprovalAwaiting(comment);
|
||||||
|
|
||||||
const { status, errorMessage, isPublishing, handleApprove, handleReject } = useModQueueActions(displayComment);
|
const { status, error, errorMessage, isPublishing, handleApprove, handleReject } = useModQueueActions(comment);
|
||||||
const hasTitle = title && title.trim().length > 0;
|
const hasTitle = title && title.trim().length > 0;
|
||||||
const hasContent = content && content.trim().length > 0;
|
const hasContent = content && content.trim().length > 0;
|
||||||
const hasLink = link && link.length > 0;
|
const hasLink = link && link.length > 0;
|
||||||
@@ -383,8 +389,8 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplay
|
|||||||
t('no_content')
|
t('no_content')
|
||||||
).trim();
|
).trim();
|
||||||
const excerpt = rawExcerpt.length > 140 ? rawExcerpt.slice(0, 137) + '...' : rawExcerpt;
|
const excerpt = rawExcerpt.length > 140 ? rawExcerpt.slice(0, 137) + '...' : rawExcerpt;
|
||||||
const threadTargetCid = threadCid || cid;
|
const postUrl = getModQueueCommentRoute(boardPath, comment.cid || cid);
|
||||||
const postUrl = boardPath && threadTargetCid ? `/${boardPath}/thread/${threadTargetCid}` : undefined;
|
const postUrlState = getQueuedCommentRouteState(comment);
|
||||||
|
|
||||||
const modQueueUrl = boardPath ? `/${boardPath}/mod/queue` : undefined;
|
const modQueueUrl = boardPath ? `/${boardPath}/mod/queue` : undefined;
|
||||||
|
|
||||||
@@ -413,7 +419,7 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplay
|
|||||||
<div className={styles.cardContent}>
|
<div className={styles.cardContent}>
|
||||||
{t('excerpt')}:{' '}
|
{t('excerpt')}:{' '}
|
||||||
{postUrl ? (
|
{postUrl ? (
|
||||||
<Link to={postUrl} title={excerpt}>
|
<Link to={postUrl} state={postUrlState} title={excerpt}>
|
||||||
{excerpt}
|
{excerpt}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
@@ -421,7 +427,15 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplay
|
|||||||
)}{' '}
|
)}{' '}
|
||||||
/ {t('type')}: {isReply ? t('reply') : t('post')} / {capitalize(t('image'))}: {hasThumbnail ? lowerCase(t('yes')) : lowerCase(t('no'))}
|
/ {t('type')}: {isReply ? t('reply') : t('post')} / {capitalize(t('image'))}: {hasThumbnail ? lowerCase(t('yes')) : lowerCase(t('no'))}
|
||||||
</div>
|
</div>
|
||||||
<ModQueueActions status={status} errorMessage={errorMessage} isPublishing={isPublishing} handleApprove={handleApprove} handleReject={handleReject} variant='card' />
|
<ModQueueActions
|
||||||
|
status={status}
|
||||||
|
error={error}
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
isPublishing={isPublishing}
|
||||||
|
handleApprove={handleApprove}
|
||||||
|
handleReject={handleReject}
|
||||||
|
variant='card'
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -430,7 +444,7 @@ ModQueueCard.displayName = 'ModQueueCard';
|
|||||||
const ModQueueFeedPost = ({ comment }: { comment: Comment }) => {
|
const ModQueueFeedPost = ({ comment }: { comment: Comment }) => {
|
||||||
const { editedComment } = useEditedComment({ comment });
|
const { editedComment } = useEditedComment({ comment });
|
||||||
const displayComment = editedComment || comment;
|
const displayComment = editedComment || comment;
|
||||||
const { status, errorMessage, isPublishing, handleApprove, handleReject } = useModQueueActions(displayComment);
|
const { status, error, errorMessage, isPublishing, handleApprove, handleReject } = useModQueueActions(comment);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Post
|
<Post
|
||||||
@@ -439,7 +453,7 @@ const ModQueueFeedPost = ({ comment }: { comment: Comment }) => {
|
|||||||
showReplies={false}
|
showReplies={false}
|
||||||
isModQueue={true}
|
isModQueue={true}
|
||||||
modQueueStatus={status}
|
modQueueStatus={status}
|
||||||
modQueueError={errorMessage}
|
modQueueError={error || errorMessage}
|
||||||
isPublishing={isPublishing}
|
isPublishing={isPublishing}
|
||||||
onApprove={handleApprove}
|
onApprove={handleApprove}
|
||||||
onReject={handleReject}
|
onReject={handleReject}
|
||||||
@@ -810,10 +824,7 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
|
|||||||
);
|
);
|
||||||
const { feed, hasMore, loadMore, reset } = useFeed(feedOptions);
|
const { feed, hasMore, loadMore, reset } = useFeed(feedOptions);
|
||||||
|
|
||||||
const filteredFeed = useMemo(() => {
|
const filteredFeed = useMemo(() => filterVisibleModQueueFeed(feed, selectedBoardFilter), [feed, selectedBoardFilter]);
|
||||||
if (!selectedBoardFilter) return feed;
|
|
||||||
return feed.filter((item) => getCommentCommunityAddress(item) === selectedBoardFilter);
|
|
||||||
}, [feed, selectedBoardFilter]);
|
|
||||||
|
|
||||||
const addressToPathMap = useMemo(() => {
|
const addressToPathMap = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import useThreadLiveUpdatesStore from '../../../stores/use-thread-live-updates-s
|
|||||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||||
|
|
||||||
type TestComment = {
|
type TestComment = {
|
||||||
|
approved?: boolean;
|
||||||
cid?: string;
|
cid?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
error?: Error;
|
error?: Error;
|
||||||
@@ -18,9 +19,12 @@ type TestComment = {
|
|||||||
};
|
};
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
number?: number;
|
number?: number;
|
||||||
|
pendingApproval?: boolean;
|
||||||
parentCid?: string;
|
parentCid?: string;
|
||||||
pinned?: boolean;
|
pinned?: boolean;
|
||||||
postCid?: string;
|
postCid?: string;
|
||||||
|
postNumber?: number;
|
||||||
|
reason?: string;
|
||||||
replyCount?: number;
|
replyCount?: number;
|
||||||
replies?: unknown[];
|
replies?: unknown[];
|
||||||
state?: string;
|
state?: string;
|
||||||
@@ -37,6 +41,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
editedCommentsByCid: {} as Record<string, TestComment | undefined>,
|
editedCommentsByCid: {} as Record<string, TestComment | undefined>,
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
navigateMock: vi.fn(),
|
navigateMock: vi.fn(),
|
||||||
|
repliesByCommentCid: {} as Record<string, TestComment[]>,
|
||||||
resolvedCommunityAddress: 'music-posting.eth' as string | undefined,
|
resolvedCommunityAddress: 'music-posting.eth' as string | undefined,
|
||||||
community: {
|
community: {
|
||||||
error: undefined as Error | undefined,
|
error: undefined as Error | undefined,
|
||||||
@@ -73,6 +78,16 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
|||||||
useEditedComment: ({ comment }: { comment?: TestComment }) => ({
|
useEditedComment: ({ comment }: { comment?: TestComment }) => ({
|
||||||
editedComment: comment?.cid ? testState.editedCommentsByCid[comment.cid] : undefined,
|
editedComment: comment?.cid ? testState.editedCommentsByCid[comment.cid] : undefined,
|
||||||
}),
|
}),
|
||||||
|
useReplies: ({ comment }: { comment?: TestComment }) => {
|
||||||
|
const replies = comment?.cid ? testState.repliesByCommentCid[comment.cid] || [] : [];
|
||||||
|
return {
|
||||||
|
hasMore: false,
|
||||||
|
loadMore: vi.fn(),
|
||||||
|
replies,
|
||||||
|
reset: vi.fn(),
|
||||||
|
updatedReplies: replies,
|
||||||
|
};
|
||||||
|
},
|
||||||
useCommunity: () => testState.community,
|
useCommunity: () => testState.community,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -139,10 +154,26 @@ vi.mock('../../../components/footer', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../components/post-desktop', () => ({
|
vi.mock('../../../components/post-desktop', () => ({
|
||||||
default: ({ post, roles, targetReplyCid }: { post?: TestComment; roles?: Record<string, unknown>; targetReplyCid?: string }) =>
|
default: ({
|
||||||
|
post,
|
||||||
|
roles,
|
||||||
|
targetReplyCid,
|
||||||
|
replyPaginationOverride,
|
||||||
|
}: {
|
||||||
|
post?: TestComment;
|
||||||
|
roles?: Record<string, unknown>;
|
||||||
|
targetReplyCid?: string;
|
||||||
|
replyPaginationOverride?: { replies?: TestComment[] };
|
||||||
|
}) =>
|
||||||
createElement(
|
createElement(
|
||||||
'div',
|
'div',
|
||||||
{ 'data-testid': 'post-desktop' },
|
{
|
||||||
|
'data-testid': 'post-desktop',
|
||||||
|
'data-approved': post?.approved === undefined ? '' : String(post.approved),
|
||||||
|
'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(',') || '',
|
||||||
|
},
|
||||||
createElement('div', { 'data-thread-container-cid': post?.cid }),
|
createElement('div', { 'data-thread-container-cid': post?.cid }),
|
||||||
createElement('div', { 'data-post-info-cid': post?.cid }),
|
createElement('div', { 'data-post-info-cid': post?.cid }),
|
||||||
`${post?.cid || 'missing'}:${targetReplyCid || 'none'}:${Object.keys(roles || {}).length}`,
|
`${post?.cid || 'missing'}:${targetReplyCid || 'none'}:${Object.keys(roles || {}).length}`,
|
||||||
@@ -150,10 +181,26 @@ vi.mock('../../../components/post-desktop', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../components/post-mobile', () => ({
|
vi.mock('../../../components/post-mobile', () => ({
|
||||||
default: ({ post, roles, targetReplyCid }: { post?: TestComment; roles?: Record<string, unknown>; targetReplyCid?: string }) =>
|
default: ({
|
||||||
|
post,
|
||||||
|
roles,
|
||||||
|
targetReplyCid,
|
||||||
|
replyPaginationOverride,
|
||||||
|
}: {
|
||||||
|
post?: TestComment;
|
||||||
|
roles?: Record<string, unknown>;
|
||||||
|
targetReplyCid?: string;
|
||||||
|
replyPaginationOverride?: { replies?: TestComment[] };
|
||||||
|
}) =>
|
||||||
createElement(
|
createElement(
|
||||||
'div',
|
'div',
|
||||||
{ 'data-testid': 'post-mobile' },
|
{
|
||||||
|
'data-testid': 'post-mobile',
|
||||||
|
'data-approved': post?.approved === undefined ? '' : String(post.approved),
|
||||||
|
'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(',') || '',
|
||||||
|
},
|
||||||
createElement('div', { 'data-thread-container-cid': post?.cid }),
|
createElement('div', { 'data-thread-container-cid': post?.cid }),
|
||||||
createElement('div', { 'data-post-info-cid': post?.cid }),
|
createElement('div', { 'data-post-info-cid': post?.cid }),
|
||||||
`${post?.cid || 'missing'}:${targetReplyCid || 'none'}:${Object.keys(roles || {}).length}`,
|
`${post?.cid || 'missing'}:${targetReplyCid || 'none'}:${Object.keys(roles || {}).length}`,
|
||||||
@@ -200,6 +247,7 @@ describe('Post', () => {
|
|||||||
testState.editedCommentsByCid = {};
|
testState.editedCommentsByCid = {};
|
||||||
testState.isMobile = false;
|
testState.isMobile = false;
|
||||||
testState.resolvedCommunityAddress = 'music-posting.eth';
|
testState.resolvedCommunityAddress = 'music-posting.eth';
|
||||||
|
testState.repliesByCommentCid = {};
|
||||||
testState.useCommentCalls = [];
|
testState.useCommentCalls = [];
|
||||||
useThreadLiveUpdatesStore.getState().resetState();
|
useThreadLiveUpdatesStore.getState().resetState();
|
||||||
testState.community = {
|
testState.community = {
|
||||||
@@ -253,6 +301,7 @@ describe('Post', () => {
|
|||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
document.title = 'before';
|
document.title = 'before';
|
||||||
|
window.history.replaceState(null, '', '/');
|
||||||
|
|
||||||
container = document.createElement('div');
|
container = document.createElement('div');
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
@@ -281,6 +330,62 @@ describe('Post', () => {
|
|||||||
expect(container.querySelector('[data-testid="post-mobile"]')?.textContent).toBe('post-2:none:1');
|
expect(container.querySelector('[data-testid="post-mobile"]')?.textContent).toBe('post-2:none:1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps renderable post data when edited comments only resolve to a loading shell', async () => {
|
||||||
|
testState.editedCommentsByCid = {
|
||||||
|
'post-shell': {
|
||||||
|
cid: 'post-shell',
|
||||||
|
state: 'updating',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(createElement(Post, { post: { cid: 'post-shell', communityAddress: 'music-posting.eth', content: 'body' } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-testid="post-desktop"]')?.textContent).toBe('post-shell:none:1');
|
||||||
|
expect(testState.communityFieldAddress).toBe('music-posting.eth');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rerenders posts when pending approval turns into an approved numbered post', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
createElement(Post, {
|
||||||
|
post: {
|
||||||
|
cid: 'post-approval',
|
||||||
|
communityAddress: 'music-posting.eth',
|
||||||
|
pendingApproval: true,
|
||||||
|
replyCount: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const desktopPresenter = container.querySelector('[data-testid="post-desktop"]');
|
||||||
|
expect(desktopPresenter?.getAttribute('data-number')).toBe('');
|
||||||
|
expect(desktopPresenter?.getAttribute('data-pending-approval')).toBe('true');
|
||||||
|
expect(desktopPresenter?.getAttribute('data-approved')).toBe('');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
createElement(Post, {
|
||||||
|
post: {
|
||||||
|
approved: true,
|
||||||
|
cid: 'post-approval',
|
||||||
|
communityAddress: 'music-posting.eth',
|
||||||
|
number: 2,
|
||||||
|
pendingApproval: false,
|
||||||
|
postNumber: 2,
|
||||||
|
replyCount: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(desktopPresenter?.getAttribute('data-number')).toBe('2');
|
||||||
|
expect(desktopPresenter?.getAttribute('data-pending-approval')).toBe('false');
|
||||||
|
expect(desktopPresenter?.getAttribute('data-approved')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
it('hydrates thread pages from cached feed data, sets the document title, and renders thread footers', async () => {
|
it('hydrates thread pages from cached feed data, sets the document title, and renders thread footers', async () => {
|
||||||
testState.commentsByCid = {
|
testState.commentsByCid = {
|
||||||
'cached-cid': {
|
'cached-cid': {
|
||||||
@@ -429,6 +534,149 @@ describe('Post', () => {
|
|||||||
expect(container.textContent).toContain('thread failed');
|
expect(container.textContent).toContain('thread failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders pending reply routes from queued mod-queue state when the reply CID only resolves to a loading shell', async () => {
|
||||||
|
testState.commentsByCid = {
|
||||||
|
'pending-reply-cid': {
|
||||||
|
cid: 'pending-reply-cid',
|
||||||
|
state: 'updating',
|
||||||
|
communityAddress: 'music-posting.eth',
|
||||||
|
},
|
||||||
|
'root-cid': {
|
||||||
|
cid: 'root-cid',
|
||||||
|
number: 33,
|
||||||
|
replyCount: 1,
|
||||||
|
communityAddress: 'music-posting.eth',
|
||||||
|
title: 'Root thread',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
testState.repliesByCommentCid = {
|
||||||
|
'root-cid': [
|
||||||
|
{
|
||||||
|
cid: 'approved-reply-cid',
|
||||||
|
content: 'approved body',
|
||||||
|
communityAddress: 'music-posting.eth',
|
||||||
|
parentCid: 'root-cid',
|
||||||
|
postCid: 'root-cid',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await renderPostPage({
|
||||||
|
pathname: '/mu/thread/pending-reply-cid',
|
||||||
|
state: {
|
||||||
|
queuedComment: {
|
||||||
|
cid: 'pending-reply-cid',
|
||||||
|
content: 'pending body',
|
||||||
|
communityAddress: 'music-posting.eth',
|
||||||
|
parentCid: 'root-cid',
|
||||||
|
pendingApproval: true,
|
||||||
|
postCid: 'root-cid',
|
||||||
|
timestamp: 123,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-testid="post-desktop"]')?.textContent).toBe('root-cid:pending-reply-cid:1');
|
||||||
|
expect(container.querySelector('[data-testid="post-desktop"]')?.getAttribute('data-replies')).toBe('approved-reply-cid,pending-reply-cid');
|
||||||
|
expect(container.querySelector('[data-testid="thread-footer-first-row"]')?.textContent).toBe('root-cid:33:music-posting.eth:false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders pending thread routes from queued mod-queue state when the thread CID only resolves to a loading shell', async () => {
|
||||||
|
testState.commentsByCid = {
|
||||||
|
'pending-thread-cid': {
|
||||||
|
cid: 'pending-thread-cid',
|
||||||
|
state: 'updating',
|
||||||
|
communityAddress: 'music-posting.eth',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await renderPostPage({
|
||||||
|
pathname: '/mu/thread/pending-thread-cid',
|
||||||
|
state: {
|
||||||
|
queuedComment: {
|
||||||
|
cid: 'pending-thread-cid',
|
||||||
|
communityAddress: 'music-posting.eth',
|
||||||
|
content: 'pending thread body',
|
||||||
|
number: 71,
|
||||||
|
pendingApproval: true,
|
||||||
|
replyCount: 0,
|
||||||
|
timestamp: 321,
|
||||||
|
title: 'Pending thread',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-testid="post-desktop"]')?.textContent).toBe('pending-thread-cid:none:1');
|
||||||
|
expect(container.querySelector('[data-testid="thread-footer-first-row"]')?.textContent).toBe('pending-thread-cid:71:music-posting.eth:false');
|
||||||
|
expect(document.title).toBe('/mu/ - Pending thread... - 5chan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unwraps queued mod-queue route state from the router usr wrapper', async () => {
|
||||||
|
testState.commentsByCid = {
|
||||||
|
'wrapped-thread-cid': {
|
||||||
|
cid: 'wrapped-thread-cid',
|
||||||
|
state: 'updating',
|
||||||
|
communityAddress: 'music-posting.eth',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await renderPostPage({
|
||||||
|
pathname: '/mu/thread/wrapped-thread-cid',
|
||||||
|
state: {
|
||||||
|
usr: {
|
||||||
|
queuedComment: {
|
||||||
|
cid: 'wrapped-thread-cid',
|
||||||
|
communityAddress: 'music-posting.eth',
|
||||||
|
content: 'wrapped pending body',
|
||||||
|
number: 72,
|
||||||
|
pendingApproval: true,
|
||||||
|
replyCount: 0,
|
||||||
|
timestamp: 654,
|
||||||
|
title: 'Wrapped pending thread',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-testid="post-desktop"]')?.textContent).toBe('wrapped-thread-cid:none:1');
|
||||||
|
expect(container.querySelector('[data-testid="thread-footer-first-row"]')?.textContent).toBe('wrapped-thread-cid:72:music-posting.eth:false');
|
||||||
|
expect(document.title).toBe('/mu/ - Wrapped pending thread... - 5chan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to browser history state when router location state is missing on first navigation', async () => {
|
||||||
|
testState.commentsByCid = {
|
||||||
|
'history-thread-cid': {
|
||||||
|
cid: 'history-thread-cid',
|
||||||
|
state: 'waiting retry',
|
||||||
|
communityAddress: 'music-posting.eth',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
window.history.replaceState(
|
||||||
|
{
|
||||||
|
usr: {
|
||||||
|
queuedComment: {
|
||||||
|
cid: 'history-thread-cid',
|
||||||
|
communityAddress: 'music-posting.eth',
|
||||||
|
content: 'history pending body',
|
||||||
|
number: 73,
|
||||||
|
pendingApproval: true,
|
||||||
|
replyCount: 0,
|
||||||
|
timestamp: 987,
|
||||||
|
title: 'History pending thread',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'',
|
||||||
|
'/',
|
||||||
|
);
|
||||||
|
|
||||||
|
await renderPostPage('/mu/thread/history-thread-cid');
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-testid="post-desktop"]')?.textContent).toBe('history-thread-cid:none:1');
|
||||||
|
expect(container.querySelector('[data-testid="thread-footer-first-row"]')?.textContent).toBe('history-thread-cid:73:music-posting.eth:false');
|
||||||
|
expect(document.title).toBe('/mu/ - History pending thread... - 5chan');
|
||||||
|
});
|
||||||
|
|
||||||
it('shows missing-comment and board-load errors when no thread can be resolved', async () => {
|
it('shows missing-comment and board-load errors when no thread can be resolved', async () => {
|
||||||
testState.commentsByCid = {
|
testState.commentsByCid = {
|
||||||
'missing-cid': {
|
'missing-cid': {
|
||||||
|
|||||||
+114
-9
@@ -1,6 +1,6 @@
|
|||||||
import { memo, useEffect, useMemo, useRef } from 'react';
|
import { memo, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Comment, Role, useComment, useEditedComment, useCommunity } from '@bitsocialnet/bitsocial-react-hooks';
|
import { Comment, Role, useComment, useEditedComment, useCommunity, useReplies } from '@bitsocialnet/bitsocial-react-hooks';
|
||||||
import useCommunitiesPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities-pages';
|
import useCommunitiesPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities-pages';
|
||||||
import { useCommunityField } from '../../hooks/use-stable-community';
|
import { useCommunityField } from '../../hooks/use-stable-community';
|
||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||||
@@ -17,7 +17,9 @@ import { PageFooterDesktop, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFo
|
|||||||
import PostDesktop from '../../components/post-desktop';
|
import PostDesktop from '../../components/post-desktop';
|
||||||
import PostMobile from '../../components/post-mobile';
|
import PostMobile from '../../components/post-mobile';
|
||||||
import { getRequestedThreadTopCid, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
|
import { getRequestedThreadTopCid, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
|
||||||
|
import { REPLIES_PER_PAGE } from '../../lib/constants';
|
||||||
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
||||||
|
import type { QueuedCommentRouteState } from '../../lib/utils/mod-queue-utils';
|
||||||
import type { ReplyVirtualizationMode } from '../../lib/utils/pretext-height-estimates';
|
import type { ReplyVirtualizationMode } from '../../lib/utils/pretext-height-estimates';
|
||||||
import styles from './post.module.css';
|
import styles from './post.module.css';
|
||||||
|
|
||||||
@@ -28,6 +30,25 @@ type CommentWithRefresh = Comment & {
|
|||||||
errors?: Error[];
|
errors?: Error[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getRouteUserState = (state: unknown): QueuedCommentRouteState | undefined => {
|
||||||
|
if (!state || typeof state !== 'object') return undefined;
|
||||||
|
if ('queuedComment' in state || 'scrollThreadContainerCid' in state) {
|
||||||
|
return state as QueuedCommentRouteState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrappedState = (state as { usr?: unknown }).usr;
|
||||||
|
if (!wrappedState || typeof wrappedState !== 'object') return undefined;
|
||||||
|
if (!('queuedComment' in wrappedState) && !('scrollThreadContainerCid' in wrappedState)) return undefined;
|
||||||
|
return wrappedState as QueuedCommentRouteState;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEffectiveRouteUserState = (state: unknown): QueuedCommentRouteState | undefined => {
|
||||||
|
const routeState = getRouteUserState(state);
|
||||||
|
if (routeState) return routeState;
|
||||||
|
if (typeof window === 'undefined') return undefined;
|
||||||
|
return getRouteUserState(window.history.state);
|
||||||
|
};
|
||||||
|
|
||||||
export interface ReplyPaginationOverride {
|
export interface ReplyPaginationOverride {
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
loadMore?: () => void;
|
loadMore?: () => void;
|
||||||
@@ -54,6 +75,60 @@ const useCommentWithFeedCache = (options: { commentCid: string | undefined; auto
|
|||||||
}, [comment, cachedComment]);
|
}, [comment, cachedComment]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getQueuedCommentFromRouteState = (state: unknown, commentCid: string | undefined): CommentWithRefresh | undefined => {
|
||||||
|
if (!commentCid) return undefined;
|
||||||
|
|
||||||
|
const queuedComment = getRouteUserState(state)?.queuedComment;
|
||||||
|
if (!queuedComment || typeof queuedComment !== 'object') return undefined;
|
||||||
|
return queuedComment.cid === commentCid ? queuedComment : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeCommentFallback = (comment: CommentWithRefresh | undefined, fallback: CommentWithRefresh | undefined): CommentWithRefresh | undefined => {
|
||||||
|
if (!fallback) return comment;
|
||||||
|
if (!comment) return fallback;
|
||||||
|
if (comment.cid && fallback.cid && comment.cid !== fallback.cid) return comment;
|
||||||
|
|
||||||
|
const hasRenderableData =
|
||||||
|
comment.timestamp !== undefined ||
|
||||||
|
comment.number !== undefined ||
|
||||||
|
comment.replyCount !== undefined ||
|
||||||
|
!!comment.content ||
|
||||||
|
!!comment.title ||
|
||||||
|
!!comment.link ||
|
||||||
|
!!comment.thumbnailUrl ||
|
||||||
|
!!comment.error ||
|
||||||
|
!!comment.deleted ||
|
||||||
|
!!comment.removed;
|
||||||
|
|
||||||
|
if (hasRenderableData) return comment;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...fallback,
|
||||||
|
error: comment.error,
|
||||||
|
errors: comment.errors,
|
||||||
|
refresh: comment.refresh,
|
||||||
|
state: comment.state,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeRepliesWithQueuedReply = (replies: Comment[], queuedReply: CommentWithRefresh | undefined): Comment[] => {
|
||||||
|
if (!queuedReply?.cid) {
|
||||||
|
return replies;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queuedReplyIndex = replies.findIndex((reply) => reply?.cid === queuedReply.cid);
|
||||||
|
if (queuedReplyIndex === -1) {
|
||||||
|
return [...replies, queuedReply];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextReplies = [...replies];
|
||||||
|
nextReplies[queuedReplyIndex] = {
|
||||||
|
...nextReplies[queuedReplyIndex],
|
||||||
|
...queuedReply,
|
||||||
|
};
|
||||||
|
return nextReplies;
|
||||||
|
};
|
||||||
|
|
||||||
export interface PostProps {
|
export interface PostProps {
|
||||||
feedVirtualizationModeOverride?: ReplyVirtualizationMode;
|
feedVirtualizationModeOverride?: ReplyVirtualizationMode;
|
||||||
index?: number;
|
index?: number;
|
||||||
@@ -71,7 +146,7 @@ export interface PostProps {
|
|||||||
threadNumber?: number;
|
threadNumber?: number;
|
||||||
isModQueue?: boolean;
|
isModQueue?: boolean;
|
||||||
modQueueStatus?: 'approved' | 'rejected' | 'failed' | null;
|
modQueueStatus?: 'approved' | 'rejected' | 'failed' | null;
|
||||||
modQueueError?: string;
|
modQueueError?: unknown;
|
||||||
isPublishing?: boolean;
|
isPublishing?: boolean;
|
||||||
onApprove?: () => void;
|
onApprove?: () => void;
|
||||||
onReject?: () => void;
|
onReject?: () => void;
|
||||||
@@ -103,9 +178,7 @@ export const Post = memo(
|
|||||||
|
|
||||||
// handle pending mod or author edit
|
// handle pending mod or author edit
|
||||||
const { editedComment } = useEditedComment({ comment });
|
const { editedComment } = useEditedComment({ comment });
|
||||||
if (editedComment) {
|
comment = mergeCommentFallback(editedComment as CommentWithRefresh | undefined, comment as CommentWithRefresh | undefined);
|
||||||
comment = editedComment;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.thread}>
|
<div className={styles.thread}>
|
||||||
@@ -154,13 +227,18 @@ export const Post = memo(
|
|||||||
const next = nextProps.post;
|
const next = nextProps.post;
|
||||||
return (
|
return (
|
||||||
prev?.cid === next?.cid &&
|
prev?.cid === next?.cid &&
|
||||||
|
prev?.number === next?.number &&
|
||||||
|
prev?.postNumber === next?.postNumber &&
|
||||||
prev?.replyCount === next?.replyCount &&
|
prev?.replyCount === next?.replyCount &&
|
||||||
prev?.updatedAt === next?.updatedAt &&
|
prev?.updatedAt === next?.updatedAt &&
|
||||||
|
prev?.approved === next?.approved &&
|
||||||
prev?.locked === next?.locked &&
|
prev?.locked === next?.locked &&
|
||||||
prev?.pinned === next?.pinned &&
|
prev?.pinned === next?.pinned &&
|
||||||
|
prev?.pendingApproval === next?.pendingApproval &&
|
||||||
isCommentArchived(prev) === isCommentArchived(next) &&
|
isCommentArchived(prev) === isCommentArchived(next) &&
|
||||||
prev?.removed === next?.removed &&
|
prev?.removed === next?.removed &&
|
||||||
prev?.deleted === next?.deleted &&
|
prev?.deleted === next?.deleted &&
|
||||||
|
prev?.reason === next?.reason &&
|
||||||
prev?.commentModeration?.purged === next?.commentModeration?.purged &&
|
prev?.commentModeration?.purged === next?.commentModeration?.purged &&
|
||||||
prevProps.showAllReplies === nextProps.showAllReplies &&
|
prevProps.showAllReplies === nextProps.showAllReplies &&
|
||||||
prevProps.showReplies === nextProps.showReplies &&
|
prevProps.showReplies === nextProps.showReplies &&
|
||||||
@@ -190,8 +268,11 @@ const PostPage = () => {
|
|||||||
const resetThreadLiveUpdates = useThreadLiveUpdatesStore((state) => state.resetState);
|
const resetThreadLiveUpdates = useThreadLiveUpdatesStore((state) => state.resetState);
|
||||||
const resolvedCommunityAddress = useResolvedCommunityAddress();
|
const resolvedCommunityAddress = useResolvedCommunityAddress();
|
||||||
const isInAllView = isAllView(location.pathname);
|
const isInAllView = isAllView(location.pathname);
|
||||||
|
const routeState = useMemo(() => getEffectiveRouteUserState(location.state), [location.key, location.pathname, location.state]);
|
||||||
|
|
||||||
const comment = useCommentWithFeedCache({ commentCid, autoUpdate: autoUpdateEnabled });
|
const resolvedComment = useCommentWithFeedCache({ commentCid, autoUpdate: autoUpdateEnabled });
|
||||||
|
const queuedComment = useMemo(() => getQueuedCommentFromRouteState(routeState, commentCid), [routeState, commentCid]);
|
||||||
|
const comment = useMemo(() => mergeCommentFallback(resolvedComment, queuedComment), [resolvedComment, queuedComment]);
|
||||||
const commentCommunityAddress = getCommentCommunityAddress(comment);
|
const commentCommunityAddress = getCommentCommunityAddress(comment);
|
||||||
const communityAddress = resolvedCommunityAddress ?? commentCommunityAddress;
|
const communityAddress = resolvedCommunityAddress ?? commentCommunityAddress;
|
||||||
const communityIdentifier = useCommunityIdentifier(communityAddress);
|
const communityIdentifier = useCommunityIdentifier(communityAddress);
|
||||||
@@ -212,8 +293,8 @@ const PostPage = () => {
|
|||||||
|
|
||||||
// if the comment is a reply, return the post comment instead, then the reply will be highlighted in the thread
|
// if the comment is a reply, return the post comment instead, then the reply will be highlighted in the thread
|
||||||
const postComment = useCommentWithFeedCache({ commentCid: comment?.postCid, autoUpdate: autoUpdateEnabled });
|
const postComment = useCommentWithFeedCache({ commentCid: comment?.postCid, autoUpdate: autoUpdateEnabled });
|
||||||
const post = comment?.parentCid ? postComment : comment;
|
const post = useMemo(() => (comment?.parentCid ? mergeCommentFallback(postComment, comment) : comment), [comment, postComment]);
|
||||||
const requestedThreadTopCid = getRequestedThreadTopCid(location.state);
|
const requestedThreadTopCid = getRequestedThreadTopCid(routeState);
|
||||||
|
|
||||||
const { error } = post || {};
|
const { error } = post || {};
|
||||||
|
|
||||||
@@ -263,6 +344,30 @@ const PostPage = () => {
|
|||||||
const shouldShowCommunityError = communityError?.message && !post?.cid;
|
const shouldShowCommunityError = communityError?.message && !post?.cid;
|
||||||
|
|
||||||
const targetReplyCid = comment?.parentCid ? comment?.cid : undefined;
|
const targetReplyCid = comment?.parentCid ? comment?.cid : undefined;
|
||||||
|
const queuedReply = comment?.parentCid && post?.cid && comment.cid !== post.cid ? comment : undefined;
|
||||||
|
const queuedReplyRepliesResult = useReplies({
|
||||||
|
comment: queuedReply && post?.cid ? post : undefined,
|
||||||
|
sortType: 'old',
|
||||||
|
flat: true,
|
||||||
|
repliesPerPage: REPLIES_PER_PAGE,
|
||||||
|
accountComments: { newerThan: Infinity, append: true },
|
||||||
|
});
|
||||||
|
const queuedReplyHasMore = queuedReplyRepliesResult.hasMore;
|
||||||
|
const queuedReplyLoadMore = queuedReplyRepliesResult.loadMore;
|
||||||
|
const queuedReplyReset = (queuedReplyRepliesResult as { reset?: () => Promise<void> }).reset;
|
||||||
|
const queuedReplyReplies =
|
||||||
|
((queuedReplyRepliesResult as { updatedReplies?: Comment[] }).updatedReplies?.length
|
||||||
|
? (queuedReplyRepliesResult as { updatedReplies?: Comment[] }).updatedReplies
|
||||||
|
: queuedReplyRepliesResult.replies) || [];
|
||||||
|
const replyPaginationOverride = useMemo(() => {
|
||||||
|
if (!queuedReply || !post?.cid) return undefined;
|
||||||
|
return {
|
||||||
|
hasMore: queuedReplyHasMore,
|
||||||
|
loadMore: queuedReplyLoadMore,
|
||||||
|
replies: mergeRepliesWithQueuedReply(queuedReplyReplies, queuedReply),
|
||||||
|
reset: queuedReplyReset,
|
||||||
|
};
|
||||||
|
}, [post?.cid, queuedReply, queuedReplyHasMore, queuedReplyLoadMore, queuedReplyReplies, queuedReplyReset]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -321,7 +426,7 @@ const PostPage = () => {
|
|||||||
<ErrorDisplay error={error} />
|
<ErrorDisplay error={error} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Post post={post} showAllReplies={true} targetReplyCid={targetReplyCid} />
|
<Post post={post} showAllReplies={true} targetReplyCid={targetReplyCid} replyPaginationOverride={replyPaginationOverride} />
|
||||||
{shouldShowCommunityError && (
|
{shouldShowCommunityError && (
|
||||||
<div className={styles.error}>
|
<div className={styles.error}>
|
||||||
<ErrorDisplay error={communityError} />
|
<ErrorDisplay error={communityError} />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ __metadata:
|
|||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "5chan@workspace:."
|
resolution: "5chan@workspace:."
|
||||||
dependencies:
|
dependencies:
|
||||||
"@bitsocialnet/bitsocial-react-hooks": "https://codeload.github.com/bitsocialnet/bitsocial-react-hooks/tar.gz/e25b593aa05d505a0eb62cbde0505a55523d1998"
|
"@bitsocialnet/bitsocial-react-hooks": "https://codeload.github.com/bitsocialnet/bitsocial-react-hooks/tar.gz/f8af7b652dc924b4392105cc4166ab01486bd79f"
|
||||||
"@capacitor/android": "npm:7.4.5"
|
"@capacitor/android": "npm:7.4.5"
|
||||||
"@capacitor/app": "npm:7.0.1"
|
"@capacitor/app": "npm:7.0.1"
|
||||||
"@capacitor/cli": "npm:7.4.5"
|
"@capacitor/cli": "npm:7.4.5"
|
||||||
@@ -1530,9 +1530,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@bitsocialnet/bitsocial-react-hooks@https://codeload.github.com/bitsocialnet/bitsocial-react-hooks/tar.gz/e25b593aa05d505a0eb62cbde0505a55523d1998":
|
"@bitsocialnet/bitsocial-react-hooks@https://codeload.github.com/bitsocialnet/bitsocial-react-hooks/tar.gz/f8af7b652dc924b4392105cc4166ab01486bd79f":
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
resolution: "@bitsocialnet/bitsocial-react-hooks@https://codeload.github.com/bitsocialnet/bitsocial-react-hooks/tar.gz/e25b593aa05d505a0eb62cbde0505a55523d1998"
|
resolution: "@bitsocialnet/bitsocial-react-hooks@https://codeload.github.com/bitsocialnet/bitsocial-react-hooks/tar.gz/f8af7b652dc924b4392105cc4166ab01486bd79f"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@bitsocial/bso-resolver": "npm:0.0.4"
|
"@bitsocial/bso-resolver": "npm:0.0.4"
|
||||||
"@pkc/pkc-logger": "https://github.com/pkcprotocol/pkc-logger.git"
|
"@pkc/pkc-logger": "https://github.com/pkcprotocol/pkc-logger.git"
|
||||||
@@ -1551,7 +1551,7 @@ __metadata:
|
|||||||
zustand: "npm:4.0.0"
|
zustand: "npm:4.0.0"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ">=16.8"
|
react: ">=16.8"
|
||||||
checksum: 10c0/27290778536cea4f6b3e9981f092f64ea934f3499c709a4ab7470a27b53451f35cc366ff7c0a3979010ef89fbffe50484593bcf249c0eae4f0cc8bda3e1af458
|
checksum: 10c0/95b6d1ec6e851890a3f74194bc0ebadb791a018a98062e592f6265f0bcd13c9ac946a1bd429d7ab6e195ce857afc08a5cdcf4c172d6188fa5d718f7ec5953930
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user