mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Merge branch 'codex/fix/mod-queue-approved-stuck'
# Conflicts: # package.json # yarn.lock
This commit is contained in:
@@ -24,6 +24,8 @@ import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||
import useChallengesStore from '../../stores/use-challenges-store';
|
||||
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-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 { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
|
||||
import { useCommunityIdentifier, useCommunityIdentifiers } from '../../hooks/use-community-identifiers';
|
||||
@@ -83,6 +85,7 @@ type ModerationAction = 'approve' | 'reject' | null;
|
||||
|
||||
interface ModQueueActionState {
|
||||
status: 'approved' | 'rejected' | 'failed' | null;
|
||||
error?: unknown;
|
||||
errorMessage?: string;
|
||||
isPublishing: boolean;
|
||||
handleApprove: () => Promise<void>;
|
||||
@@ -91,6 +94,7 @@ interface ModQueueActionState {
|
||||
|
||||
interface ModQueueActionsProps {
|
||||
status: 'approved' | 'rejected' | 'failed' | null;
|
||||
error?: unknown;
|
||||
errorMessage?: string;
|
||||
isPublishing: boolean;
|
||||
handleApprove: () => Promise<void>;
|
||||
@@ -98,8 +102,9 @@ interface ModQueueActionsProps {
|
||||
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 displayError = error || errorMessage;
|
||||
|
||||
if (status === 'approved') {
|
||||
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;
|
||||
}
|
||||
if (status === 'failed') {
|
||||
const content = (
|
||||
<span className={`${styles.button} ${styles.reject}`}>
|
||||
{t('failed')}
|
||||
{errorMessage ? `: ${errorMessage}` : ''}
|
||||
</span>
|
||||
const content = displayError ? (
|
||||
<ErrorDisplay error={displayError} displayMessage={t('failed')} inline={true} showImmediately={true} />
|
||||
) : (
|
||||
<span className={`${styles.button} ${styles.reject}`}>{t('failed')}</span>
|
||||
);
|
||||
return variant === 'card' ? <div className={styles.cardActions}>{content}</div> : content;
|
||||
}
|
||||
@@ -178,8 +182,8 @@ const useModQueueActions = (comment: Comment): ModQueueActionState => {
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -197,8 +201,8 @@ const useModQueueActions = (comment: Comment): ModQueueActionState => {
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -241,9 +245,10 @@ const useModQueueActions = (comment: Comment): ModQueueActionState => {
|
||||
const rejectFailed = initiatedAction === 'reject' && rejectState === 'failed';
|
||||
|
||||
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) => {
|
||||
@@ -255,16 +260,16 @@ const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath
|
||||
const { editedComment } = useEditedComment({ 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 alertThresholdSeconds = getAlertThresholdSeconds();
|
||||
const isOverThreshold = timeWaiting > alertThresholdSeconds;
|
||||
|
||||
// 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 hasContent = content && content.trim().length > 0;
|
||||
const hasLink = link && link.length > 0;
|
||||
@@ -281,8 +286,8 @@ const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath
|
||||
).trim();
|
||||
// Only truncate excerpt on desktop, allow wrapping on mobile
|
||||
const excerpt = !isMobile && rawExcerpt.length > 101 ? rawExcerpt.slice(0, 98) + '...' : rawExcerpt;
|
||||
const threadTargetCid = threadCid || cid;
|
||||
const postUrl = boardPath && threadTargetCid ? `/${boardPath}/thread/${threadTargetCid}` : undefined;
|
||||
const postUrl = getModQueueCommentRoute(boardPath, comment.cid || cid);
|
||||
const postUrlState = getQueuedCommentRouteState(comment);
|
||||
|
||||
const modQueueUrl = boardPath ? `/${boardPath}/mod/queue` : undefined;
|
||||
|
||||
@@ -294,7 +299,7 @@ const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath
|
||||
)}
|
||||
<div className={styles.excerpt}>
|
||||
{postUrl ? (
|
||||
<Link to={postUrl} title={excerpt}>
|
||||
<Link to={postUrl} state={postUrlState} title={excerpt}>
|
||||
{excerpt}
|
||||
</Link>
|
||||
) : (
|
||||
@@ -331,6 +336,7 @@ const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath
|
||||
<div className={styles.actions}>
|
||||
<ModQueueActions
|
||||
status={status}
|
||||
error={error}
|
||||
errorMessage={errorMessage}
|
||||
isPublishing={isPublishing}
|
||||
handleApprove={handleApprove}
|
||||
@@ -360,14 +366,14 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplay
|
||||
const { editedComment } = useEditedComment({ 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 alertThresholdSeconds = getAlertThresholdSeconds();
|
||||
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 hasContent = content && content.trim().length > 0;
|
||||
const hasLink = link && link.length > 0;
|
||||
@@ -383,8 +389,8 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplay
|
||||
t('no_content')
|
||||
).trim();
|
||||
const excerpt = rawExcerpt.length > 140 ? rawExcerpt.slice(0, 137) + '...' : rawExcerpt;
|
||||
const threadTargetCid = threadCid || cid;
|
||||
const postUrl = boardPath && threadTargetCid ? `/${boardPath}/thread/${threadTargetCid}` : undefined;
|
||||
const postUrl = getModQueueCommentRoute(boardPath, comment.cid || cid);
|
||||
const postUrlState = getQueuedCommentRouteState(comment);
|
||||
|
||||
const modQueueUrl = boardPath ? `/${boardPath}/mod/queue` : undefined;
|
||||
|
||||
@@ -413,7 +419,7 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplay
|
||||
<div className={styles.cardContent}>
|
||||
{t('excerpt')}:{' '}
|
||||
{postUrl ? (
|
||||
<Link to={postUrl} title={excerpt}>
|
||||
<Link to={postUrl} state={postUrlState} title={excerpt}>
|
||||
{excerpt}
|
||||
</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'))}
|
||||
</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>
|
||||
);
|
||||
});
|
||||
@@ -430,7 +444,7 @@ ModQueueCard.displayName = 'ModQueueCard';
|
||||
const ModQueueFeedPost = ({ comment }: { comment: Comment }) => {
|
||||
const { editedComment } = useEditedComment({ comment });
|
||||
const displayComment = editedComment || comment;
|
||||
const { status, errorMessage, isPublishing, handleApprove, handleReject } = useModQueueActions(displayComment);
|
||||
const { status, error, errorMessage, isPublishing, handleApprove, handleReject } = useModQueueActions(comment);
|
||||
|
||||
return (
|
||||
<Post
|
||||
@@ -439,7 +453,7 @@ const ModQueueFeedPost = ({ comment }: { comment: Comment }) => {
|
||||
showReplies={false}
|
||||
isModQueue={true}
|
||||
modQueueStatus={status}
|
||||
modQueueError={errorMessage}
|
||||
modQueueError={error || errorMessage}
|
||||
isPublishing={isPublishing}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
@@ -810,10 +824,7 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
|
||||
);
|
||||
const { feed, hasMore, loadMore, reset } = useFeed(feedOptions);
|
||||
|
||||
const filteredFeed = useMemo(() => {
|
||||
if (!selectedBoardFilter) return feed;
|
||||
return feed.filter((item) => getCommentCommunityAddress(item) === selectedBoardFilter);
|
||||
}, [feed, selectedBoardFilter]);
|
||||
const filteredFeed = useMemo(() => filterVisibleModQueueFeed(feed, selectedBoardFilter), [feed, selectedBoardFilter]);
|
||||
|
||||
const addressToPathMap = useMemo(() => {
|
||||
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>;
|
||||
|
||||
type TestComment = {
|
||||
approved?: boolean;
|
||||
cid?: string;
|
||||
content?: string;
|
||||
error?: Error;
|
||||
@@ -18,9 +19,12 @@ type TestComment = {
|
||||
};
|
||||
locked?: boolean;
|
||||
number?: number;
|
||||
pendingApproval?: boolean;
|
||||
parentCid?: string;
|
||||
pinned?: boolean;
|
||||
postCid?: string;
|
||||
postNumber?: number;
|
||||
reason?: string;
|
||||
replyCount?: number;
|
||||
replies?: unknown[];
|
||||
state?: string;
|
||||
@@ -37,6 +41,7 @@ const testState = vi.hoisted(() => ({
|
||||
editedCommentsByCid: {} as Record<string, TestComment | undefined>,
|
||||
isMobile: false,
|
||||
navigateMock: vi.fn(),
|
||||
repliesByCommentCid: {} as Record<string, TestComment[]>,
|
||||
resolvedCommunityAddress: 'music-posting.eth' as string | undefined,
|
||||
community: {
|
||||
error: undefined as Error | undefined,
|
||||
@@ -73,6 +78,16 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
||||
useEditedComment: ({ comment }: { comment?: TestComment }) => ({
|
||||
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,
|
||||
}));
|
||||
|
||||
@@ -139,10 +154,26 @@ vi.mock('../../../components/footer', () => ({
|
||||
}));
|
||||
|
||||
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(
|
||||
'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-post-info-cid': post?.cid }),
|
||||
`${post?.cid || 'missing'}:${targetReplyCid || 'none'}:${Object.keys(roles || {}).length}`,
|
||||
@@ -150,10 +181,26 @@ vi.mock('../../../components/post-desktop', () => ({
|
||||
}));
|
||||
|
||||
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(
|
||||
'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-post-info-cid': post?.cid }),
|
||||
`${post?.cid || 'missing'}:${targetReplyCid || 'none'}:${Object.keys(roles || {}).length}`,
|
||||
@@ -200,6 +247,7 @@ describe('Post', () => {
|
||||
testState.editedCommentsByCid = {};
|
||||
testState.isMobile = false;
|
||||
testState.resolvedCommunityAddress = 'music-posting.eth';
|
||||
testState.repliesByCommentCid = {};
|
||||
testState.useCommentCalls = [];
|
||||
useThreadLiveUpdatesStore.getState().resetState();
|
||||
testState.community = {
|
||||
@@ -253,6 +301,7 @@ describe('Post', () => {
|
||||
writable: true,
|
||||
});
|
||||
document.title = 'before';
|
||||
window.history.replaceState(null, '', '/');
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
@@ -281,6 +330,62 @@ describe('Post', () => {
|
||||
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 () => {
|
||||
testState.commentsByCid = {
|
||||
'cached-cid': {
|
||||
@@ -429,6 +534,149 @@ describe('Post', () => {
|
||||
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 () => {
|
||||
testState.commentsByCid = {
|
||||
'missing-cid': {
|
||||
|
||||
+114
-9
@@ -1,6 +1,6 @@
|
||||
import { memo, useEffect, useMemo, useRef } from 'react';
|
||||
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 { useCommunityField } from '../../hooks/use-stable-community';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
@@ -17,7 +17,9 @@ import { PageFooterDesktop, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFo
|
||||
import PostDesktop from '../../components/post-desktop';
|
||||
import PostMobile from '../../components/post-mobile';
|
||||
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 type { QueuedCommentRouteState } from '../../lib/utils/mod-queue-utils';
|
||||
import type { ReplyVirtualizationMode } from '../../lib/utils/pretext-height-estimates';
|
||||
import styles from './post.module.css';
|
||||
|
||||
@@ -28,6 +30,25 @@ type CommentWithRefresh = Comment & {
|
||||
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 {
|
||||
hasMore?: boolean;
|
||||
loadMore?: () => void;
|
||||
@@ -54,6 +75,60 @@ const useCommentWithFeedCache = (options: { commentCid: string | undefined; auto
|
||||
}, [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 {
|
||||
feedVirtualizationModeOverride?: ReplyVirtualizationMode;
|
||||
index?: number;
|
||||
@@ -71,7 +146,7 @@ export interface PostProps {
|
||||
threadNumber?: number;
|
||||
isModQueue?: boolean;
|
||||
modQueueStatus?: 'approved' | 'rejected' | 'failed' | null;
|
||||
modQueueError?: string;
|
||||
modQueueError?: unknown;
|
||||
isPublishing?: boolean;
|
||||
onApprove?: () => void;
|
||||
onReject?: () => void;
|
||||
@@ -103,9 +178,7 @@ export const Post = memo(
|
||||
|
||||
// handle pending mod or author edit
|
||||
const { editedComment } = useEditedComment({ comment });
|
||||
if (editedComment) {
|
||||
comment = editedComment;
|
||||
}
|
||||
comment = mergeCommentFallback(editedComment as CommentWithRefresh | undefined, comment as CommentWithRefresh | undefined);
|
||||
|
||||
return (
|
||||
<div className={styles.thread}>
|
||||
@@ -154,13 +227,18 @@ export const Post = memo(
|
||||
const next = nextProps.post;
|
||||
return (
|
||||
prev?.cid === next?.cid &&
|
||||
prev?.number === next?.number &&
|
||||
prev?.postNumber === next?.postNumber &&
|
||||
prev?.replyCount === next?.replyCount &&
|
||||
prev?.updatedAt === next?.updatedAt &&
|
||||
prev?.approved === next?.approved &&
|
||||
prev?.locked === next?.locked &&
|
||||
prev?.pinned === next?.pinned &&
|
||||
prev?.pendingApproval === next?.pendingApproval &&
|
||||
isCommentArchived(prev) === isCommentArchived(next) &&
|
||||
prev?.removed === next?.removed &&
|
||||
prev?.deleted === next?.deleted &&
|
||||
prev?.reason === next?.reason &&
|
||||
prev?.commentModeration?.purged === next?.commentModeration?.purged &&
|
||||
prevProps.showAllReplies === nextProps.showAllReplies &&
|
||||
prevProps.showReplies === nextProps.showReplies &&
|
||||
@@ -190,8 +268,11 @@ const PostPage = () => {
|
||||
const resetThreadLiveUpdates = useThreadLiveUpdatesStore((state) => state.resetState);
|
||||
const resolvedCommunityAddress = useResolvedCommunityAddress();
|
||||
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 communityAddress = resolvedCommunityAddress ?? commentCommunityAddress;
|
||||
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
|
||||
const postComment = useCommentWithFeedCache({ commentCid: comment?.postCid, autoUpdate: autoUpdateEnabled });
|
||||
const post = comment?.parentCid ? postComment : comment;
|
||||
const requestedThreadTopCid = getRequestedThreadTopCid(location.state);
|
||||
const post = useMemo(() => (comment?.parentCid ? mergeCommentFallback(postComment, comment) : comment), [comment, postComment]);
|
||||
const requestedThreadTopCid = getRequestedThreadTopCid(routeState);
|
||||
|
||||
const { error } = post || {};
|
||||
|
||||
@@ -263,6 +344,30 @@ const PostPage = () => {
|
||||
const shouldShowCommunityError = communityError?.message && !post?.cid;
|
||||
|
||||
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(() => {
|
||||
return () => {
|
||||
@@ -321,7 +426,7 @@ const PostPage = () => {
|
||||
<ErrorDisplay error={error} />
|
||||
</div>
|
||||
)}
|
||||
<Post post={post} showAllReplies={true} targetReplyCid={targetReplyCid} />
|
||||
<Post post={post} showAllReplies={true} targetReplyCid={targetReplyCid} replyPaginationOverride={replyPaginationOverride} />
|
||||
{shouldShowCommunityError && (
|
||||
<div className={styles.error}>
|
||||
<ErrorDisplay error={communityError} />
|
||||
|
||||
Reference in New Issue
Block a user