diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx index 020d0c81..204352d1 100644 --- a/src/components/post-form/__tests__/post-form.test.tsx +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -806,6 +806,39 @@ describe('PostForm', () => { expect(testState.publishedPostOptions?.content).toBe(`${youtubeLink}\nVideo body`); }); + it('ignores duplicate post clicks while publish is pending', async () => { + let resolvePublish: () => void = () => {}; + const publishPromise = new Promise((resolve) => { + resolvePublish = resolve; + }); + testState.publishPostMock.mockReturnValue(publishPromise); + + await renderPostForm('/all'); + await clickByText(container, 'start_new_thread'); + + const table = container.querySelector('table') as HTMLTableElement; + const select = table.querySelector('select') as HTMLSelectElement; + const textarea = table.querySelector('textarea') as HTMLTextAreaElement; + const postButton = Array.from(table.querySelectorAll('button')).find((button) => button.textContent === 'post') as HTMLButtonElement; + + await dispatchChange(select, 'music-posting.eth'); + await dispatchInput(textarea, 'Thread body'); + + await clickByText(table, 'post'); + expect(postButton.disabled).toBe(true); + + await clickByText(table, 'post'); + expect(testState.publishPostMock).toHaveBeenCalledTimes(1); + + await act(async () => { + resolvePublish(); + await publishPromise; + }); + await flushEffects(); + + expect(postButton.disabled).toBe(false); + }); + it('publishes known twimg query-format post links with a path extension without editing the field', async () => { const twimgLink = 'https://pbs.twimg.com/media/HJxnhNKWMAAhqFU?format=jpg&name=medium'; const publishLink = 'https://pbs.twimg.com/media/HJxnhNKWMAAhqFU.jpg'; diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index 8209bc10..5650f535 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -713,7 +713,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid: }; nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null; - publishPost({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...publishOptions }); + await publishPost({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...publishOptions }); }); // redirect to pending page when pending comment is created diff --git a/src/hooks/__tests__/use-publish-post.test.tsx b/src/hooks/__tests__/use-publish-post.test.tsx index 640703b8..085ae024 100644 --- a/src/hooks/__tests__/use-publish-post.test.tsx +++ b/src/hooks/__tests__/use-publish-post.test.tsx @@ -156,6 +156,50 @@ describe('usePublishPost', () => { expect(testState.publishCommentMock).toHaveBeenCalledTimes(1); }); + it('keeps a one-shot publish pending and ignores duplicate publish calls until it settles', async () => { + let resolvePublish: () => void = () => {}; + const publishPromise = new Promise((resolve) => { + resolvePublish = resolve; + }); + testState.publishCommentMock.mockReturnValue(publishPromise); + + let firstPublish: Promise | undefined; + let secondPublish: Promise | undefined; + + await act(async () => { + firstPublish = latestValue.publishPost({ + content: 'Fresh body', + } as never); + secondPublish = latestValue.publishPost({ + content: 'Duplicate body', + } as never); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(firstPublish).toBe(secondPublish); + expect(testState.lastPublishOptions?.content).toBe('Fresh body'); + expect(testState.publishCommentMock).toHaveBeenCalledTimes(1); + + let isSettled = false; + firstPublish?.then(() => { + isSettled = true; + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(isSettled).toBe(false); + + await act(async () => { + resolvePublish(); + await publishPromise; + await firstPublish; + }); + + expect(isSettled).toBe(true); + }); + it('clears stale flag data from one-shot publish options', async () => { await act(async () => { latestValue.setPublishPostOptions({ diff --git a/src/hooks/__tests__/use-publish-reply.test.tsx b/src/hooks/__tests__/use-publish-reply.test.tsx index 03ff194b..4d1ab45f 100644 --- a/src/hooks/__tests__/use-publish-reply.test.tsx +++ b/src/hooks/__tests__/use-publish-reply.test.tsx @@ -179,7 +179,9 @@ describe('usePublishReply', () => { }); await act(async () => { - await latestValue.publishReply(); + latestValue.publishReply(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -197,7 +199,9 @@ describe('usePublishReply', () => { }); await act(async () => { - await latestValue.publishReply(); + latestValue.publishReply(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -215,17 +219,65 @@ describe('usePublishReply', () => { }); await act(async () => { - await latestValue.publishReply({ + latestValue.publishReply({ content: 'Fresh reply', } as never); await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); }); expect(testState.lastPublishOptions?.content).toBe('Fresh reply'); expect(testState.publishCommentMock).toHaveBeenCalledTimes(1); }); + it('keeps a one-shot reply pending and ignores duplicate publish calls until it settles', async () => { + let resolvePublish: () => void = () => {}; + const publishPromise = new Promise((resolve) => { + resolvePublish = resolve; + }); + testState.publishCommentMock.mockReturnValue(publishPromise); + + let firstPublish: Promise | undefined; + let secondPublish: Promise | undefined; + + await act(async () => { + firstPublish = latestValue.publishReply({ + content: 'Fresh reply', + } as never); + secondPublish = latestValue.publishReply({ + content: 'Duplicate reply', + } as never); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(firstPublish).toBe(secondPublish); + expect(testState.lastPublishOptions?.content).toBe('Fresh reply'); + expect(testState.publishCommentMock).toHaveBeenCalledTimes(1); + + let isSettled = false; + firstPublish?.then(() => { + isSettled = true; + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(isSettled).toBe(false); + + await act(async () => { + resolvePublish(); + await publishPromise; + await firstPublish; + }); + + expect(isSettled).toBe(true); + }); + it('clears stale flag data from one-shot reply options', async () => { await act(async () => { latestValue.setPublishReplyOptions({ @@ -238,13 +290,15 @@ describe('usePublishReply', () => { }); await act(async () => { - await latestValue.publishReply({ + latestValue.publishReply({ content: 'Plain reply', challengeRequest: undefined, flairs: undefined, } as never); await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); }); expect(testState.lastPublishOptions?.content).toBe('Plain reply'); @@ -263,8 +317,9 @@ describe('usePublishReply', () => { }); await act(async () => { - await latestValue.publishReply(); + latestValue.publishReply(); await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); }); expect(latestValue.publishReplyError).toContain('external_quote_publish_missing'); @@ -276,7 +331,8 @@ describe('usePublishReply', () => { renderHook(); await act(async () => { - await latestValue.publishReply(); + latestValue.publishReply(); + await Promise.resolve(); }); expect(latestValue.publishReplyError).toBe('blocked:unresolved'); diff --git a/src/hooks/use-pending-publish-request.ts b/src/hooks/use-pending-publish-request.ts new file mode 100644 index 00000000..a0e1dc6b --- /dev/null +++ b/src/hooks/use-pending-publish-request.ts @@ -0,0 +1,44 @@ +import { useCallback, useRef } from 'react'; + +type PendingPublishRequest = { + id: number; + promise: Promise; + resolve: () => void; +}; + +const usePendingPublishRequest = () => { + const nextRequestIdRef = useRef(0); + const pendingRequestRef = useRef(null); + + const startPendingPublishRequest = useCallback(() => { + if (pendingRequestRef.current) { + return { request: pendingRequestRef.current, started: false }; + } + + const id = nextRequestIdRef.current + 1; + nextRequestIdRef.current = id; + + let resolveRequest: () => void = () => {}; + const promise = new Promise((resolve) => { + resolveRequest = resolve; + }); + const request = { id, promise, resolve: resolveRequest }; + pendingRequestRef.current = request; + + return { request, started: true }; + }, []); + + const finishPendingPublishRequest = useCallback((requestId: number) => { + const request = pendingRequestRef.current; + if (!request || request.id !== requestId) { + return; + } + + pendingRequestRef.current = null; + request.resolve(); + }, []); + + return { finishPendingPublishRequest, startPendingPublishRequest }; +}; + +export default usePendingPublishRequest; diff --git a/src/hooks/use-publish-post.ts b/src/hooks/use-publish-post.ts index 620f121e..96dc40f6 100644 --- a/src/hooks/use-publish-post.ts +++ b/src/hooks/use-publish-post.ts @@ -4,6 +4,7 @@ import { useShallow } from 'zustand/react/shallow'; import usePublishPostStore from '../stores/use-publish-post-store'; import useChallengesStore from '../stores/use-challenges-store'; import usePublishAuthorDomainGuard, { getPublishAuthorDomainErrorMessage } from './use-publish-author-domain-guard'; +import usePendingPublishRequest from './use-pending-publish-request'; type UsePublishPostOptions = { communityAddress?: string; @@ -30,6 +31,7 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => { const [pendingPublishRequestId, setPendingPublishRequestId] = useState(0); const startedPublishRequestIdRef = useRef(0); const { blockedReason } = usePublishAuthorDomainGuard(); + const { finishPendingPublishRequest, startPendingPublishRequest } = usePendingPublishRequest(); const abandonCurrentPublish = useCallback(async () => { await abandonPublishRef.current?.(); }, []); @@ -95,14 +97,14 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => { setPublishPostError(null); }, [author?.displayName, blockedReason, communityAddress, content, flairs, link, spoiler, title]); - const startPublishPost = useCallback(() => { + const startPublishPost = useCallback(async () => { if (blockedReason) { setPublishPostError(getPublishAuthorDomainErrorMessage(blockedReason)); return; } setPublishPostError(null); - return publishComment(); + await publishComment(); }, [blockedReason, publishComment]); useEffect(() => { @@ -110,21 +112,32 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => { return; } - startedPublishRequestIdRef.current = pendingPublishRequestId; - startPublishPost(); - }, [pendingPublishRequestId, startPublishPost]); + const requestId = pendingPublishRequestId; + startedPublishRequestIdRef.current = requestId; + void startPublishPost() + .catch(() => undefined) + .finally(() => finishPendingPublishRequest(requestId)); + }, [finishPendingPublishRequest, pendingPublishRequestId, startPublishPost]); const publishPost = useCallback( (options?: Partial) => { - if (options) { - setPublishPostOptions(options); - setPendingPublishRequestId((requestId) => requestId + 1); - return; + const pendingRequest = startPendingPublishRequest(); + if (!pendingRequest.started) { + return pendingRequest.request.promise; } - return startPublishPost(); + if (options) { + setPublishPostOptions(options); + setPendingPublishRequestId(pendingRequest.request.id); + return pendingRequest.request.promise; + } + + void startPublishPost() + .catch(() => undefined) + .finally(() => finishPendingPublishRequest(pendingRequest.request.id)); + return pendingRequest.request.promise; }, - [setPublishPostOptions, startPublishPost], + [finishPendingPublishRequest, setPublishPostOptions, startPendingPublishRequest, startPublishPost], ); return { diff --git a/src/hooks/use-publish-reply.ts b/src/hooks/use-publish-reply.ts index ea2a12b1..cdb55199 100644 --- a/src/hooks/use-publish-reply.ts +++ b/src/hooks/use-publish-reply.ts @@ -10,6 +10,7 @@ import { extractUnresolvedExternalQuoteReferences, getExternalQuoteStatusMessage import { resolveExternalQuoteTarget } from '../lib/utils/external-quote-resolver'; import useChallengesStore from '../stores/use-challenges-store'; import usePublishAuthorDomainGuard, { getPublishAuthorDomainErrorMessage } from './use-publish-author-domain-guard'; +import usePendingPublishRequest from './use-pending-publish-request'; type UsePublishReplyOptions = { cid: string; @@ -47,6 +48,7 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti const [isResolvingExternalQuotes, setIsResolvingExternalQuotes] = useState(false); const [publishReplyError, setPublishReplyError] = useState(null); const [publishReplyStateMessage, setPublishReplyStateMessage] = useState(null); + const { finishPendingPublishRequest, startPendingPublishRequest } = usePendingPublishRequest(); const abandonCurrentPublish = useCallback(async () => { await abandonPublishRef.current?.(); }, []); @@ -166,35 +168,35 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti return; } - startedPublishRequestIdRef.current = pendingPublishRequestId; - publishComment(); - }, [pendingPublishRequestId, publishComment]); - - const publishReply = useCallback( - async (options?: Partial) => { - if (options) { - setPublishReplyOptions(options); - setPendingSyncedPublishRequestId((requestId) => requestId + 1); - return; - } + const requestId = pendingPublishRequestId; + startedPublishRequestIdRef.current = requestId; + void Promise.resolve() + .then(() => publishComment()) + .catch(() => undefined) + .finally(() => finishPendingPublishRequest(requestId)); + }, [finishPendingPublishRequest, pendingPublishRequestId, publishComment]); + const preparePublishReply = useCallback( + async (requestId: number) => { setPublishReplyError(null); if (blockedReason) { setPublishReplyStateMessage(null); setPublishReplyError(getPublishAuthorDomainErrorMessage(blockedReason)); + finishPendingPublishRequest(requestId); return; } if (publishResolvableQuoteReferences.length === 0) { setResolvedExternalQuotedCids(undefined); setPublishReplyStateMessage(null); - setPendingPublishRequestId((requestId) => requestId + 1); + setPendingPublishRequestId(requestId); return; } if (!account?.id) { setPublishReplyError(t('external_quote_resolution_unavailable')); + finishPendingPublishRequest(requestId); return; } @@ -220,6 +222,7 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti quote: reference.raw, }), ); + finishPendingPublishRequest(requestId); return; } @@ -228,14 +231,34 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti setResolvedExternalQuotedCids(resolvedCids.size > 0 ? [...resolvedCids] : undefined); setPublishReplyStateMessage(null); - setPendingPublishRequestId((requestId) => requestId + 1); + setPendingPublishRequestId(requestId); } catch { setPublishReplyError(t('external_quote_resolution_unavailable')); + finishPendingPublishRequest(requestId); } finally { setIsResolvingExternalQuotes(false); } }, - [account, blockedReason, directories, publishResolvableQuoteReferences, setPublishReplyOptions, t], + [account, blockedReason, directories, finishPendingPublishRequest, publishResolvableQuoteReferences, t], + ); + + const publishReply = useCallback( + (options?: Partial) => { + const pendingRequest = startPendingPublishRequest(); + if (!pendingRequest.started) { + return pendingRequest.request.promise; + } + + if (options) { + setPublishReplyOptions(options); + setPendingSyncedPublishRequestId(pendingRequest.request.id); + return pendingRequest.request.promise; + } + + void preparePublishReply(pendingRequest.request.id).catch(() => finishPendingPublishRequest(pendingRequest.request.id)); + return pendingRequest.request.promise; + }, + [finishPendingPublishRequest, preparePublishReply, setPublishReplyOptions, startPendingPublishRequest], ); useEffect(() => { @@ -243,9 +266,10 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti return; } - startedSyncedPublishRequestIdRef.current = pendingSyncedPublishRequestId; - publishReply(); - }, [pendingSyncedPublishRequestId, publishReply]); + const requestId = pendingSyncedPublishRequestId; + startedSyncedPublishRequestIdRef.current = requestId; + void preparePublishReply(requestId).catch(() => finishPendingPublishRequest(requestId)); + }, [finishPendingPublishRequest, pendingSyncedPublishRequestId, preparePublishReply]); return { isResolvingExternalQuotes,