From 5f9e603b621856980f99db9b007b86a792452d02 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Fri, 10 Apr 2026 16:02:10 +0700 Subject: [PATCH] fix(publish): retry failed comments without duplicate replies --- .../post-community-address-compat.test.tsx | 3 + src/components/failed-publish-notice.tsx | 16 +- src/components/post-desktop/post-desktop.tsx | 23 ++- src/components/post-mobile/post-mobile.tsx | 23 ++- .../__tests__/use-delete-failed-post.test.tsx | 155 ++++++++++++++++++ .../__tests__/use-fresh-replies.test.tsx | 29 ++++ src/hooks/use-delete-failed-post.ts | 131 ++++++++++++++- src/hooks/use-fresh-replies.ts | 22 ++- 8 files changed, 385 insertions(+), 17 deletions(-) create mode 100644 src/hooks/__tests__/use-delete-failed-post.test.tsx diff --git a/src/components/__tests__/post-community-address-compat.test.tsx b/src/components/__tests__/post-community-address-compat.test.tsx index 964ff64c..3f300ee3 100644 --- a/src/components/__tests__/post-community-address-compat.test.tsx +++ b/src/components/__tests__/post-community-address-compat.test.tsx @@ -356,8 +356,11 @@ vi.mock('../../lib/utils/thread-scroll-utils', () => ({ vi.mock('../../hooks/use-delete-failed-post', () => ({ default: () => ({ canDeleteFailedPost: false, + canRetryFailedPost: false, isDeletingFailedPost: false, + isRetryingFailedPost: false, onDeleteFailedPost: vi.fn(), + onRetryFailedPost: vi.fn(), }), })); diff --git a/src/components/failed-publish-notice.tsx b/src/components/failed-publish-notice.tsx index f375c7b4..2ff27575 100644 --- a/src/components/failed-publish-notice.tsx +++ b/src/components/failed-publish-notice.tsx @@ -3,11 +3,14 @@ import useIsMobile from '../hooks/use-is-mobile'; interface FailedPublishNoticeProps { isDeleting: boolean; + isRetrying?: boolean; onDelete: () => void; + onRetry?: () => void; } -const FailedPublishNotice = ({ isDeleting, onDelete }: FailedPublishNoticeProps) => { +const FailedPublishNotice = ({ isDeleting, isRetrying = false, onDelete, onRetry }: FailedPublishNoticeProps) => { const isMobile = useIsMobile(); + const isBusy = isDeleting || isRetrying; return ( @@ -18,9 +21,18 @@ const FailedPublishNotice = ({ isDeleting, onDelete }: FailedPublishNoticeProps)
)} + {onRetry && ( + + [ + + ] + + )}{' '} [ - ] diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index edc3cff0..c8b85bf4 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -747,8 +747,15 @@ const Reply = ({ const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight); const hasThumbnail = getHasThumbnail(commentMediaInfo, link); - const { canDeleteFailedPost, isDeletingFailedPost, onDeleteFailedPost } = useDeleteFailedPost(post); - const failedPublishNotice = canDeleteFailedPost ? : undefined; + const { canDeleteFailedPost, canRetryFailedPost, isDeletingFailedPost, isRetryingFailedPost, onDeleteFailedPost, onRetryFailedPost } = useDeleteFailedPost(post); + const failedPublishNotice = canDeleteFailedPost ? ( + + ) : undefined; return (
@@ -927,8 +934,16 @@ const PostDesktop = ({ const stateString = useStateString(resolvedPost) || t('downloading_board'); const hasFailedState = state === 'failed'; - const { canDeleteFailedPost, isDeletingFailedPost, onDeleteFailedPost } = useDeleteFailedPost(resolvedPost); - const failedPublishNotice = canDeleteFailedPost ? : undefined; + const { canDeleteFailedPost, canRetryFailedPost, isDeletingFailedPost, isRetryingFailedPost, onDeleteFailedPost, onRetryFailedPost } = + useDeleteFailedPost(resolvedPost); + const failedPublishNotice = canDeleteFailedPost ? ( + + ) : undefined; const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight); const hasThumbnail = getHasThumbnail(commentMediaInfo, link); diff --git a/src/components/post-mobile/post-mobile.tsx b/src/components/post-mobile/post-mobile.tsx index 9b4a8f37..f621f5ec 100644 --- a/src/components/post-mobile/post-mobile.tsx +++ b/src/components/post-mobile/post-mobile.tsx @@ -519,8 +519,15 @@ const Reply = ({ const route = boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`; const isRouteLinkToReply = cid ? location.pathname.startsWith(route) : false; const { hidden } = useHide({ cid }); - const { canDeleteFailedPost, isDeletingFailedPost, onDeleteFailedPost } = useDeleteFailedPost(post); - const failedPublishNotice = canDeleteFailedPost ? : undefined; + const { canDeleteFailedPost, canRetryFailedPost, isDeletingFailedPost, isRetryingFailedPost, onDeleteFailedPost, onRetryFailedPost } = useDeleteFailedPost(post); + const failedPublishNotice = canDeleteFailedPost ? ( + + ) : undefined; return (
@@ -648,8 +655,16 @@ const PostMobile = ({ const stateString = useStateString(resolvedPost) || t('loading_post'); const hasFailedState = state === 'failed'; const isReply = !!parentCid; - const { canDeleteFailedPost, isDeletingFailedPost, onDeleteFailedPost } = useDeleteFailedPost(resolvedPost); - const failedPublishNotice = canDeleteFailedPost ? : undefined; + const { canDeleteFailedPost, canRetryFailedPost, isDeletingFailedPost, isRetryingFailedPost, onDeleteFailedPost, onRetryFailedPost } = + useDeleteFailedPost(resolvedPost); + const failedPublishNotice = canDeleteFailedPost ? ( + + ) : undefined; // Author-deleted replies are hidden from thread replies; moderator removals still render their placeholder. const filteredReplies = useMemo(() => filterRepliesForDisplay(freshRepliesForRender), [freshRepliesForRender]); diff --git a/src/hooks/__tests__/use-delete-failed-post.test.tsx b/src/hooks/__tests__/use-delete-failed-post.test.tsx new file mode 100644 index 00000000..96f7e73d --- /dev/null +++ b/src/hooks/__tests__/use-delete-failed-post.test.tsx @@ -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 | Promise }).act as (cb: () => void | Promise) => void | Promise; + +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 | undefined, + publishCommentMock: vi.fn(async () => undefined), +})); + +vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ + deleteComment: (targetComment: string | number) => testState.deleteCommentMock(targetComment), + usePublishComment: (options: Record | 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; +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); + }); +}); diff --git a/src/hooks/__tests__/use-fresh-replies.test.tsx b/src/hooks/__tests__/use-fresh-replies.test.tsx index 462c8915..32349fbf 100644 --- a/src/hooks/__tests__/use-fresh-replies.test.tsx +++ b/src/hooks/__tests__/use-fresh-replies.test.tsx @@ -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] }); + }); }); diff --git a/src/hooks/use-delete-failed-post.ts b/src/hooks/use-delete-failed-post.ts index 3a92eb8f..5f8fa6ad 100644 --- a/src/hooks/use-delete-failed-post.ts +++ b/src/hooks/use-delete-failed-post.ts @@ -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 & { + 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) }; + } + + 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) | 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( + () => + 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, }; }; diff --git a/src/hooks/use-fresh-replies.ts b/src/hooks/use-fresh-replies.ts index 9f7f312e..82afdded 100644 --- a/src/hooks/use-fresh-replies.ts +++ b/src/hooks/use-fresh-replies.ts @@ -39,7 +39,27 @@ const useFreshReplies = (replies: Comment[] = []) => { return freshReply; }); - return hasFreshReplies ? nextReplies : replies; + if (!hasFreshReplies) { + return replies; + } + + const seenReplyIndices = new Set(); + 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]); };