mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(publish): retry failed comments without duplicate replies
This commit is contained in:
@@ -356,8 +356,11 @@ vi.mock('../../lib/utils/thread-scroll-utils', () => ({
|
|||||||
vi.mock('../../hooks/use-delete-failed-post', () => ({
|
vi.mock('../../hooks/use-delete-failed-post', () => ({
|
||||||
default: () => ({
|
default: () => ({
|
||||||
canDeleteFailedPost: false,
|
canDeleteFailedPost: false,
|
||||||
|
canRetryFailedPost: false,
|
||||||
isDeletingFailedPost: false,
|
isDeletingFailedPost: false,
|
||||||
|
isRetryingFailedPost: false,
|
||||||
onDeleteFailedPost: vi.fn(),
|
onDeleteFailedPost: vi.fn(),
|
||||||
|
onRetryFailedPost: vi.fn(),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import useIsMobile from '../hooks/use-is-mobile';
|
|||||||
|
|
||||||
interface FailedPublishNoticeProps {
|
interface FailedPublishNoticeProps {
|
||||||
isDeleting: boolean;
|
isDeleting: boolean;
|
||||||
|
isRetrying?: boolean;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onRetry?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FailedPublishNotice = ({ isDeleting, onDelete }: FailedPublishNoticeProps) => {
|
const FailedPublishNotice = ({ isDeleting, isRetrying = false, onDelete, onRetry }: FailedPublishNoticeProps) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const isBusy = isDeleting || isRetrying;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={styles.failedPublishNotice}>
|
<span className={styles.failedPublishNotice}>
|
||||||
@@ -18,9 +21,18 @@ const FailedPublishNotice = ({ isDeleting, onDelete }: FailedPublishNoticeProps)
|
|||||||
<br />
|
<br />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{onRetry && (
|
||||||
|
<span className={styles.failedDeletePostAction}>
|
||||||
|
[
|
||||||
|
<button type='button' className={styles.failedDeletePostButton} disabled={isBusy} onClick={onRetry}>
|
||||||
|
Retry Publish
|
||||||
|
</button>
|
||||||
|
]
|
||||||
|
</span>
|
||||||
|
)}{' '}
|
||||||
<span className={styles.failedDeletePostAction}>
|
<span className={styles.failedDeletePostAction}>
|
||||||
[
|
[
|
||||||
<button type='button' className={styles.failedDeletePostButton} disabled={isDeleting} onClick={onDelete}>
|
<button type='button' className={styles.failedDeletePostButton} disabled={isBusy} onClick={onDelete}>
|
||||||
Delete Post
|
Delete Post
|
||||||
</button>
|
</button>
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -747,8 +747,15 @@ const Reply = ({
|
|||||||
|
|
||||||
const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
||||||
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
||||||
const { canDeleteFailedPost, isDeletingFailedPost, onDeleteFailedPost } = useDeleteFailedPost(post);
|
const { canDeleteFailedPost, canRetryFailedPost, isDeletingFailedPost, isRetryingFailedPost, onDeleteFailedPost, onRetryFailedPost } = useDeleteFailedPost(post);
|
||||||
const failedPublishNotice = canDeleteFailedPost ? <FailedPublishNotice isDeleting={isDeletingFailedPost} onDelete={onDeleteFailedPost} /> : undefined;
|
const failedPublishNotice = canDeleteFailedPost ? (
|
||||||
|
<FailedPublishNotice
|
||||||
|
isDeleting={isDeletingFailedPost}
|
||||||
|
isRetrying={isRetryingFailedPost}
|
||||||
|
onDelete={onDeleteFailedPost}
|
||||||
|
onRetry={canRetryFailedPost ? onRetryFailedPost : undefined}
|
||||||
|
/>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.replyDesktop} ${disableDeferredLayout ? styles.pretextVirtualizedReply : ''}`}>
|
<div className={`${styles.replyDesktop} ${disableDeferredLayout ? styles.pretextVirtualizedReply : ''}`}>
|
||||||
@@ -927,8 +934,16 @@ const PostDesktop = ({
|
|||||||
|
|
||||||
const stateString = useStateString(resolvedPost) || t('downloading_board');
|
const stateString = useStateString(resolvedPost) || t('downloading_board');
|
||||||
const hasFailedState = state === 'failed';
|
const hasFailedState = state === 'failed';
|
||||||
const { canDeleteFailedPost, isDeletingFailedPost, onDeleteFailedPost } = useDeleteFailedPost(resolvedPost);
|
const { canDeleteFailedPost, canRetryFailedPost, isDeletingFailedPost, isRetryingFailedPost, onDeleteFailedPost, onRetryFailedPost } =
|
||||||
const failedPublishNotice = canDeleteFailedPost ? <FailedPublishNotice isDeleting={isDeletingFailedPost} onDelete={onDeleteFailedPost} /> : undefined;
|
useDeleteFailedPost(resolvedPost);
|
||||||
|
const failedPublishNotice = canDeleteFailedPost ? (
|
||||||
|
<FailedPublishNotice
|
||||||
|
isDeleting={isDeletingFailedPost}
|
||||||
|
isRetrying={isRetryingFailedPost}
|
||||||
|
onDelete={onDeleteFailedPost}
|
||||||
|
onRetry={canRetryFailedPost ? onRetryFailedPost : undefined}
|
||||||
|
/>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
||||||
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
||||||
|
|||||||
@@ -519,8 +519,15 @@ const Reply = ({
|
|||||||
const route = boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`;
|
const route = boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`;
|
||||||
const isRouteLinkToReply = cid ? location.pathname.startsWith(route) : false;
|
const isRouteLinkToReply = cid ? location.pathname.startsWith(route) : false;
|
||||||
const { hidden } = useHide({ cid });
|
const { hidden } = useHide({ cid });
|
||||||
const { canDeleteFailedPost, isDeletingFailedPost, onDeleteFailedPost } = useDeleteFailedPost(post);
|
const { canDeleteFailedPost, canRetryFailedPost, isDeletingFailedPost, isRetryingFailedPost, onDeleteFailedPost, onRetryFailedPost } = useDeleteFailedPost(post);
|
||||||
const failedPublishNotice = canDeleteFailedPost ? <FailedPublishNotice isDeleting={isDeletingFailedPost} onDelete={onDeleteFailedPost} /> : undefined;
|
const failedPublishNotice = canDeleteFailedPost ? (
|
||||||
|
<FailedPublishNotice
|
||||||
|
isDeleting={isDeletingFailedPost}
|
||||||
|
isRetrying={isRetryingFailedPost}
|
||||||
|
onDelete={onDeleteFailedPost}
|
||||||
|
onRetry={canRetryFailedPost ? onRetryFailedPost : undefined}
|
||||||
|
/>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.replyMobile} ${disableDeferredLayout ? styles.pretextVirtualizedReply : ''}`}>
|
<div className={`${styles.replyMobile} ${disableDeferredLayout ? styles.pretextVirtualizedReply : ''}`}>
|
||||||
@@ -648,8 +655,16 @@ const PostMobile = ({
|
|||||||
const stateString = useStateString(resolvedPost) || t('loading_post');
|
const stateString = useStateString(resolvedPost) || t('loading_post');
|
||||||
const hasFailedState = state === 'failed';
|
const hasFailedState = state === 'failed';
|
||||||
const isReply = !!parentCid;
|
const isReply = !!parentCid;
|
||||||
const { canDeleteFailedPost, isDeletingFailedPost, onDeleteFailedPost } = useDeleteFailedPost(resolvedPost);
|
const { canDeleteFailedPost, canRetryFailedPost, isDeletingFailedPost, isRetryingFailedPost, onDeleteFailedPost, onRetryFailedPost } =
|
||||||
const failedPublishNotice = canDeleteFailedPost ? <FailedPublishNotice isDeleting={isDeletingFailedPost} onDelete={onDeleteFailedPost} /> : undefined;
|
useDeleteFailedPost(resolvedPost);
|
||||||
|
const failedPublishNotice = canDeleteFailedPost ? (
|
||||||
|
<FailedPublishNotice
|
||||||
|
isDeleting={isDeletingFailedPost}
|
||||||
|
isRetrying={isRetryingFailedPost}
|
||||||
|
onDelete={onDeleteFailedPost}
|
||||||
|
onRetry={canRetryFailedPost ? onRetryFailedPost : undefined}
|
||||||
|
/>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
// Author-deleted replies are hidden from thread replies; moderator removals still render their placeholder.
|
// Author-deleted replies are hidden from thread replies; moderator removals still render their placeholder.
|
||||||
const filteredReplies = useMemo(() => filterRepliesForDisplay(freshRepliesForRender), [freshRepliesForRender]);
|
const filteredReplies = useMemo(() => filterRepliesForDisplay(freshRepliesForRender), [freshRepliesForRender]);
|
||||||
|
|||||||
@@ -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);
|
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] });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,108 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { deleteComment } from '@bitsocialnet/bitsocial-react-hooks';
|
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 [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 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(() => {
|
const onDeleteFailedPost = useCallback(() => {
|
||||||
if (isDeletingFailedPost || !canDeleteFailedPost) {
|
if (isDeletingFailedPost || isRetryingFailedPost || !canDeleteFailedPost) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetComment = post?.cid ?? post?.index;
|
const targetComment = getDeleteTarget(post);
|
||||||
if (typeof targetComment === 'undefined') {
|
if (typeof targetComment === 'undefined') {
|
||||||
return;
|
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'}`);
|
alert(`Failed to delete post: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
setIsDeletingFailedPost(false);
|
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 {
|
return {
|
||||||
canDeleteFailedPost,
|
canDeleteFailedPost,
|
||||||
|
canRetryFailedPost,
|
||||||
isDeletingFailedPost,
|
isDeletingFailedPost,
|
||||||
|
isRetryingFailedPost,
|
||||||
onDeleteFailedPost,
|
onDeleteFailedPost,
|
||||||
|
onRetryFailedPost,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,27 @@ const useFreshReplies = (replies: Comment[] = []) => {
|
|||||||
return freshReply;
|
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]);
|
}, [accountComments, replies]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user