fix(publish): retry failed comments without duplicate replies

This commit is contained in:
Tommaso Casaburi
2026-04-10 16:02:10 +07:00
parent 02f8d3467e
commit 5f9e603b62
8 changed files with 385 additions and 17 deletions
@@ -0,0 +1,155 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import useDeleteFailedPost, { getFailedPostRetryPublishOptions } from '../use-delete-failed-post';
import useChallengesStore from '../../stores/use-challenges-store';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
abandonPublishMock: vi.fn(async () => undefined),
alertChallengeVerificationFailedMock: vi.fn(),
alertMock: vi.fn(),
deleteCommentMock: vi.fn(async (_targetComment: string | number) => undefined),
lastPublishOptions: undefined as Record<string, any> | undefined,
publishCommentMock: vi.fn(async () => undefined),
}));
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
deleteComment: (targetComment: string | number) => testState.deleteCommentMock(targetComment),
usePublishComment: (options: Record<string, any> | undefined) => {
testState.lastPublishOptions = options;
return {
abandonPublish: testState.abandonPublishMock,
publishComment: testState.publishCommentMock,
};
},
}));
vi.mock('../../lib/utils/challenge-utils', () => ({
alertChallengeVerificationFailed: (...args: any[]) => testState.alertChallengeVerificationFailedMock(...args),
}));
let container: HTMLDivElement;
let latestValue: ReturnType<typeof useDeleteFailedPost>;
let root: Root;
const failedPost = {
accountId: 'account-1',
author: {
address: '0x123',
displayName: 'Alice',
shortAddress: '0x1234',
},
cid: 'failed-cid',
clients: { ipfs: { gateway: { state: 'failed' } } },
communityAddress: 'music.eth',
content: 'retry me',
depth: 1,
error: new Error('boom'),
errors: [new Error('boom')],
index: 7,
link: 'https://example.com/file.png',
parentCid: 'parent-1',
postCid: 'post-1',
publishingState: 'failed',
quotedCids: ['quoted-1'],
shortCommunityAddress: 'music…eth',
spoiler: true,
state: 'failed',
timestamp: 1_735_689_600,
title: 'Hello',
};
const HookHarness = ({ post }: { post: typeof failedPost }) => {
latestValue = useDeleteFailedPost(post);
return null;
};
const renderHook = (post = failedPost) => {
act(() => {
root.render(createElement(HookHarness, { post }));
});
};
describe('useDeleteFailedPost', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.lastPublishOptions = undefined;
vi.stubGlobal('alert', testState.alertMock);
useChallengesStore.setState({ challenges: [] });
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderHook();
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
it('extracts retry-safe publish options from a failed comment', () => {
expect(getFailedPostRetryPublishOptions(failedPost)).toEqual({
author: {
address: '0x123',
displayName: 'Alice',
},
communityAddress: 'music.eth',
content: 'retry me',
link: 'https://example.com/file.png',
parentCid: 'parent-1',
postCid: 'post-1',
quotedCids: ['quoted-1'],
spoiler: true,
title: 'Hello',
});
});
it('deletes the failed row and republishes with the stored publish payload', async () => {
expect(testState.lastPublishOptions).toMatchObject({
author: {
address: '0x123',
displayName: 'Alice',
},
communityAddress: 'music.eth',
content: 'retry me',
link: 'https://example.com/file.png',
parentCid: 'parent-1',
postCid: 'post-1',
quotedCids: ['quoted-1'],
spoiler: true,
title: 'Hello',
});
expect(typeof testState.lastPublishOptions?.onChallenge).toBe('function');
expect(typeof testState.lastPublishOptions?.onChallengeVerification).toBe('function');
expect(typeof testState.lastPublishOptions?.onError).toBe('function');
await act(async () => {
await latestValue.onRetryFailedPost();
});
expect(testState.deleteCommentMock).toHaveBeenCalledWith('failed-cid');
expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
});
it('routes retry challenges through the challenge store and preserves abandon behavior', async () => {
await act(async () => {
await testState.lastPublishOptions?.onChallenge('captcha', 'nonce');
});
const challenges = useChallengesStore.getState().challenges;
expect(challenges).toHaveLength(1);
expect(challenges[0]?.challenge).toEqual(['captcha', 'nonce']);
await act(async () => {
await useChallengesStore.getState().abandonCurrentChallenge();
});
expect(testState.abandonPublishMock).toHaveBeenCalledTimes(1);
});
});
@@ -115,4 +115,33 @@ describe('useFreshReplies', () => {
expect(latestValue[0]?.number).toBe(28);
});
it('dedupes retried local replies when a stale reply and fresh reply share index 0', () => {
testState.replies = [
{
content: 'stale failed reply',
index: 0,
subplebbitAddress: 'music.eth',
},
{
content: 'duplicate stale failed reply',
index: 0,
subplebbitAddress: 'music.eth',
},
];
testState.accountComments = [
{
content: 'retried pending reply',
index: 0,
number: 99,
subplebbitAddress: 'music.eth',
},
];
renderHook();
expect(latestValue).toHaveLength(1);
expect(latestValue[0]).toBe(testState.accountComments[0] as never);
expect(testState.accountCommentsCalls).toContainEqual({ commentIndices: [0] });
});
});
+125 -6
View File
@@ -1,17 +1,108 @@
import { useCallback, useState } from 'react';
import { deleteComment } from '@bitsocialnet/bitsocial-react-hooks';
import { useCallback, useMemo, useRef, useState } from 'react';
import { ChallengeVerification, Comment, PublishCommentOptions, deleteComment, usePublishComment } from '@bitsocialnet/bitsocial-react-hooks';
import { alertChallengeVerificationFailed } from '../lib/utils/challenge-utils';
import useChallengesStore from '../stores/use-challenges-store';
const useDeleteFailedPost = (post?: { cid?: string; index?: number; state?: string }) => {
const retryExcludedFields = new Set([
'accountId',
'cid',
'clients',
'depth',
'error',
'errors',
'index',
'publishingState',
'shortCommunityAddress',
'state',
'timestamp',
]);
type FailedPost = Partial<Comment> & {
cid?: string;
index?: number;
state?: string;
};
const cloneRetryValue = (value: unknown) => {
if (Array.isArray(value)) {
return [...value];
}
if (value && typeof value === 'object') {
return { ...(value as Record<string, unknown>) };
}
return value;
};
const getDeleteTarget = (post?: FailedPost) => post?.cid ?? post?.index;
export const getFailedPostRetryPublishOptions = (post?: FailedPost): PublishCommentOptions | undefined => {
if (post?.state !== 'failed') {
return undefined;
}
const retryOptions = Object.entries(post).reduce((acc, [key, value]) => {
if (retryExcludedFields.has(key) || typeof value === 'undefined') {
return acc;
}
acc[key] = cloneRetryValue(value);
return acc;
}, {} as PublishCommentOptions);
if (retryOptions.author && typeof retryOptions.author === 'object' && !Array.isArray(retryOptions.author)) {
const author = { ...retryOptions.author };
delete author.shortAddress;
retryOptions.author = author;
}
if (!retryOptions.communityAddress && !retryOptions.subplebbitAddress) {
return undefined;
}
return retryOptions;
};
const useDeleteFailedPost = (post?: FailedPost) => {
const [isDeletingFailedPost, setIsDeletingFailedPost] = useState(false);
const [isRetryingFailedPost, setIsRetryingFailedPost] = useState(false);
const addChallenge = useChallengesStore((state) => state.addChallenge);
const abandonPublishRef = useRef<(() => Promise<void>) | undefined>();
const abandonCurrentPublish = useCallback(async () => {
await abandonPublishRef.current?.();
}, []);
const canDeleteFailedPost = post?.state === 'failed' && typeof post?.index === 'number';
const retryPublishOptions = useMemo(() => getFailedPostRetryPublishOptions(post), [post]);
const publishOptionsWithCallbacks = useMemo<PublishCommentOptions | undefined>(
() =>
retryPublishOptions
? {
...retryPublishOptions,
onChallenge: async (...args: any[]) => {
addChallenge(args, abandonCurrentPublish);
},
onChallengeVerification: async (challengeVerification: ChallengeVerification, comment: Comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
onError: (error: Error) => {
console.error('Failed to retry failed post:', error);
alert(`Failed to retry post: ${error.message}`);
},
}
: undefined,
[abandonCurrentPublish, addChallenge, retryPublishOptions],
);
const { abandonPublish, publishComment } = usePublishComment(publishOptionsWithCallbacks);
abandonPublishRef.current = abandonPublish;
const onDeleteFailedPost = useCallback(() => {
if (isDeletingFailedPost || !canDeleteFailedPost) {
if (isDeletingFailedPost || isRetryingFailedPost || !canDeleteFailedPost) {
return;
}
const targetComment = post?.cid ?? post?.index;
const targetComment = getDeleteTarget(post);
if (typeof targetComment === 'undefined') {
return;
}
@@ -26,12 +117,40 @@ const useDeleteFailedPost = (post?: { cid?: string; index?: number; state?: stri
alert(`Failed to delete post: ${error instanceof Error ? error.message : 'Unknown error'}`);
setIsDeletingFailedPost(false);
});
}, [canDeleteFailedPost, isDeletingFailedPost, post?.cid, post?.index]);
}, [canDeleteFailedPost, isDeletingFailedPost, isRetryingFailedPost, post]);
const canRetryFailedPost = canDeleteFailedPost && Boolean(retryPublishOptions);
const onRetryFailedPost = useCallback(async () => {
if (isDeletingFailedPost || isRetryingFailedPost || !canRetryFailedPost) {
return;
}
const targetComment = getDeleteTarget(post);
if (typeof targetComment === 'undefined') {
return;
}
setIsRetryingFailedPost(true);
try {
await deleteComment(targetComment);
await publishComment();
} catch (error) {
console.error('Failed to retry failed post:', error);
alert(`Failed to retry post: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsRetryingFailedPost(false);
}
}, [canRetryFailedPost, isDeletingFailedPost, isRetryingFailedPost, post, publishComment]);
return {
canDeleteFailedPost,
canRetryFailedPost,
isDeletingFailedPost,
isRetryingFailedPost,
onDeleteFailedPost,
onRetryFailedPost,
};
};
+21 -1
View File
@@ -39,7 +39,27 @@ const useFreshReplies = (replies: Comment[] = []) => {
return freshReply;
});
return hasFreshReplies ? nextReplies : replies;
if (!hasFreshReplies) {
return replies;
}
const seenReplyIndices = new Set<number>();
let hasDuplicateReplyIndices = false;
const dedupedReplies = nextReplies.filter((reply) => {
if (typeof reply?.index !== 'number') {
return true;
}
if (seenReplyIndices.has(reply.index)) {
hasDuplicateReplyIndices = true;
return false;
}
seenReplyIndices.add(reply.index);
return true;
});
return hasDuplicateReplyIndices ? dedupedReplies : nextReplies;
}, [accountComments, replies]);
};