diff --git a/src/components/challenge-modal/challenge-modal.test.tsx b/src/components/challenge-modal/challenge-modal.test.tsx new file mode 100644 index 00000000..f94ca631 --- /dev/null +++ b/src/components/challenge-modal/challenge-modal.test.tsx @@ -0,0 +1,123 @@ +// @vitest-environment jsdom + +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 ChallengeModal from './challenge-modal'; +import useChallengesStore from '../../stores/use-challenges-store'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (callback: () => void | Promise) => void | Promise }).act as (callback: () => void | Promise) => void | Promise; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => (key === 'challenge_counter' ? `${options?.index}/${options?.total}` : key), + }), +})); + +vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ + useAccount: () => ({ author: { address: '0xabc' } }), + useComment: () => undefined, +})); + +vi.mock('../../hooks/use-theme', () => ({ + default: () => ['light'], +})); + +let container: HTMLDivElement; +let root: Root; + +const createChallenge = (prompt: string, publishChallengeAnswers = vi.fn()) => + [{ challenges: [{ challenge: prompt, type: 'text/plain' }] }, { communityAddress: 'example.bso', content: prompt, publishChallengeAnswers }] as never; + +const renderModal = async () => { + await act(async () => { + root.render(createElement(ChallengeModal)); + }); +}; + +const clickButton = async (text: string) => { + const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text); + expect(button).toBeDefined(); + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); +}; + +const enterAnswer = async (value: string) => { + const input = container.querySelector('input'); + expect(input).not.toBeNull(); + await act(async () => { + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + valueSetter?.call(input, value); + input?.dispatchEvent(new Event('input', { bubbles: true })); + input?.dispatchEvent(new Event('change', { bubbles: true })); + }); +}; + +describe('ChallengeModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + useChallengesStore.setState({ challenges: [] }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + useChallengesStore.setState({ challenges: [] }); + }); + + it('abandons exactly the current challenge when Cancel is clicked', async () => { + const firstAbandon = vi.fn().mockResolvedValue(undefined); + const secondAbandon = vi.fn().mockResolvedValue(undefined); + useChallengesStore.getState().addChallenge(createChallenge('first prompt'), firstAbandon); + useChallengesStore.getState().addChallenge(createChallenge('second prompt'), secondAbandon); + await renderModal(); + + await clickButton('cancel'); + + expect(firstAbandon).toHaveBeenCalledOnce(); + expect(secondAbandon).not.toHaveBeenCalled(); + expect(useChallengesStore.getState().challenges).toHaveLength(1); + expect(container.textContent).toContain('second prompt'); + }); + + it('abandons exactly the current challenge once when Escape is pressed', async () => { + const firstAbandon = vi.fn().mockResolvedValue(undefined); + const secondAbandon = vi.fn().mockResolvedValue(undefined); + useChallengesStore.getState().addChallenge(createChallenge('first prompt'), firstAbandon); + useChallengesStore.getState().addChallenge(createChallenge('second prompt'), secondAbandon); + await renderModal(); + + await act(async () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await Promise.resolve(); + }); + + expect(firstAbandon).toHaveBeenCalledOnce(); + expect(secondAbandon).not.toHaveBeenCalled(); + expect(useChallengesStore.getState().challenges).toHaveLength(1); + }); + + it('submits a text answer without abandoning and resets state for the next queue entry', async () => { + const publishChallengeAnswers = vi.fn(); + const firstAbandon = vi.fn().mockResolvedValue(undefined); + useChallengesStore.getState().addChallenge(createChallenge('first prompt', publishChallengeAnswers), firstAbandon); + useChallengesStore.getState().addChallenge(createChallenge('second prompt')); + await renderModal(); + + await enterAnswer('four'); + await clickButton('submit'); + + expect(publishChallengeAnswers).toHaveBeenCalledWith(['four']); + expect(firstAbandon).not.toHaveBeenCalled(); + expect(useChallengesStore.getState().challenges).toHaveLength(1); + expect(container.textContent).toContain('second prompt'); + expect(container.querySelector('input')?.value).toBe(''); + }); +}); diff --git a/src/components/challenge-modal/challenge-modal.tsx b/src/components/challenge-modal/challenge-modal.tsx index 6d66b69e..a72137e4 100644 --- a/src/components/challenge-modal/challenge-modal.tsx +++ b/src/components/challenge-modal/challenge-modal.tsx @@ -41,9 +41,10 @@ const ChallengeHeader = ({ publicationType, votePreview, parentCid, parentAddres interface RegularChallengeContentProps { challenge: ChallengeType; closeModal: () => void; + abandonModal: () => void; } -const RegularChallengeContent = ({ challenge, closeModal }: RegularChallengeContentProps) => { +const RegularChallengeContent = ({ challenge, closeModal, abandonModal }: RegularChallengeContentProps) => { const { t } = useTranslation(); const account = useAccount(); const [theme] = useTheme(); @@ -92,7 +93,7 @@ const RegularChallengeContent = ({ challenge, closeModal }: RegularChallengeCont closeModal(); }, [challenge, answers, closeModal]); - const onIframeClose = useCallback(() => { + const onIframeDone = useCallback(() => { // Submit empty string as answer for iframe challenges challenge[1].publishChallengeAnswers(['']); closeModal(); @@ -163,7 +164,7 @@ const RegularChallengeContent = ({ challenge, closeModal }: RegularChallengeCont } catch (error) { console.error('Invalid iframe challenge URL', { error }); alert('Error: Invalid URL for authentication challenge'); - closeModal(); + abandonModal(); } }; @@ -196,20 +197,6 @@ const RegularChallengeContent = ({ challenge, closeModal }: RegularChallengeCont } }, [iframeOrigin, iframeUrlState, sendThemeToIframe, showIframeConfirmation]); - useEffect(() => { - const onEscapeKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - if (isIframeChallenge) { - onIframeClose(); - } else { - closeModal(); - } - } - }; - document.addEventListener('keydown', onEscapeKey); - return () => document.removeEventListener('keydown', onEscapeKey); - }, [isIframeChallenge, onIframeClose, closeModal]); - const communityShortAddress = getDisplayAddress(shortCommunityAddress || shortSubplebbitAddress || communityAddress || subplebbitAddress || ''); // Render iframe challenge @@ -238,8 +225,12 @@ const RegularChallengeContent = ({ challenge, closeModal }: RegularChallengeCont
- - + +
@@ -260,7 +251,9 @@ const RegularChallengeContent = ({ challenge, closeModal }: RegularChallengeCont {t('iframe_challenge_keep_open', { defaultValue: 'Complete the challenge in the box above. Keep this window open until done.' })}
- +
@@ -299,42 +292,52 @@ const RegularChallengeContent = ({ challenge, closeModal }: RegularChallengeCont
{t('challenge_counter', { index: currentChallengeIndex + 1, total: challenges?.length })}
{!challenges?.[currentChallengeIndex + 1] && ( - )} - + {challenges && challenges.length > 1 && ( - )} - {challenges?.[currentChallengeIndex + 1] && } + {challenges?.[currentChallengeIndex + 1] && ( + + )} ); }; -const ChallengeContent = ({ challenge, closeModal }: { challenge?: ChallengeType; closeModal: () => void }) => { +const ChallengeContent = ({ challenge, closeModal, abandonModal }: { challenge?: ChallengeType; closeModal: () => void; abandonModal: () => void }) => { if (challenge) { - return ; + return ; } return null; }; const ChallengeModal = () => { - const { challenges, removeChallenge } = useChallengesStore(); + const { challenges, removeChallenge, abandonCurrentChallenge } = useChallengesStore(); const isOpen = !!challenges.length; - const closeModal = () => { - removeChallenge(); + const closeModal = () => removeChallenge(); + const abandonModal = () => { + void abandonCurrentChallenge(); }; + const currentChallenge = challenges[0]; const { refs, context } = useFloating({ open: isOpen, - onOpenChange: closeModal, + onOpenChange: (open) => { + if (!open) abandonModal(); + }, }); const click = useClick(context); const dismiss = useDismiss(context, { outsidePress: false }); @@ -344,11 +347,11 @@ const ChallengeModal = () => { return ( <> - {isOpen && ( + {isOpen && currentChallenge && (
- +
diff --git a/src/hooks/use-publish-comment-with-challenge-abandon.ts b/src/hooks/use-publish-comment-with-challenge-abandon.ts new file mode 100644 index 00000000..acfe18a2 --- /dev/null +++ b/src/hooks/use-publish-comment-with-challenge-abandon.ts @@ -0,0 +1,30 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { PublishCommentOptions, usePublishComment } from '@bitsocial/bitsocial-react-hooks'; +import useChallengesStore from '../stores/use-challenges-store'; + +const usePublishCommentWithChallengeAbandon = (publishCommentOptions: PublishCommentOptions) => { + const addChallenge = useChallengesStore((state) => state.addChallenge); + const abandonPublishRef = useRef<(() => Promise) | undefined>(undefined); + const abandonCurrentPublish = useCallback(async () => { + await abandonPublishRef.current?.(); + }, []); + + const publishOptionsWithAbandon = useMemo( + () => ({ + ...publishCommentOptions, + onChallenge: async (...args: any[]) => { + addChallenge(args, abandonCurrentPublish); + }, + }), + [abandonCurrentPublish, addChallenge, publishCommentOptions], + ); + + const publishResult = usePublishComment(publishOptionsWithAbandon); + useEffect(() => { + abandonPublishRef.current = publishResult.abandonPublish; + }, [publishResult.abandonPublish]); + + return publishResult; +}; + +export default usePublishCommentWithChallengeAbandon; diff --git a/src/hooks/use-publish-reply.test.tsx b/src/hooks/use-publish-reply.test.tsx new file mode 100644 index 00000000..d61ce046 --- /dev/null +++ b/src/hooks/use-publish-reply.test.tsx @@ -0,0 +1,71 @@ +// @vitest-environment jsdom + +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 usePublishReply from './use-publish-reply'; +import useChallengesStore from '../stores/use-challenges-store'; +import usePublishReplyStore from '../stores/use-publish-reply-store'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (callback: () => void | Promise) => void | Promise }).act as (callback: () => void | Promise) => void | Promise; + +const testState = vi.hoisted(() => ({ + abandonPublish: vi.fn().mockResolvedValue(undefined), + lastOptions: undefined as Record | undefined, +})); + +vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ + usePublishComment: (options: Record) => { + testState.lastOptions = options; + return { abandonPublish: testState.abandonPublish, index: undefined, publishComment: vi.fn() }; + }, +})); + +let container: HTMLDivElement; +let latestValue: ReturnType; +let root: Root; + +const HookHarness = () => { + latestValue = usePublishReply({ cid: 'parent-cid', communityAddress: 'example.bso', postCid: 'post-cid' }); + return null; +}; + +describe('usePublishReply', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.lastOptions = undefined; + useChallengesStore.setState({ challenges: [] }); + usePublishReplyStore.getState().resetReplyStore('parent-cid'); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => root.render(createElement(HookHarness))); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + useChallengesStore.setState({ challenges: [] }); + usePublishReplyStore.getState().resetReplyStore('parent-cid'); + }); + + it('routes challenge cancellation to the current usePublishComment abandonPublish', async () => { + await act(async () => { + latestValue.setPublishReplyOptions.content('Reply body'); + }); + + await act(async () => { + await testState.lastOptions?.onChallenge({ challenges: [] }, { content: 'Reply body' }); + }); + + expect(useChallengesStore.getState().challenges).toHaveLength(1); + + await act(async () => { + await useChallengesStore.getState().abandonCurrentChallenge(); + }); + + expect(testState.abandonPublish).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/hooks/use-publish-reply.ts b/src/hooks/use-publish-reply.ts index 59697817..23ee8c9d 100644 --- a/src/hooks/use-publish-reply.ts +++ b/src/hooks/use-publish-reply.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import { usePublishComment } from '@bitsocial/bitsocial-react-hooks'; import usePublishReplyStore from '../stores/use-publish-reply-store'; +import usePublishCommentWithChallengeAbandon from './use-publish-comment-with-challenge-abandon'; const usePublishReply = ({ cid, communityAddress, postCid }: { cid: string; communityAddress: string; postCid: string | undefined }) => { const parentCid = cid; @@ -64,7 +64,7 @@ const usePublishReply = ({ cid, communityAddress, postCid }: { cid: string; comm const resetPublishReplyOptions = useMemo(() => () => resetReplyStore(parentCid), [parentCid, resetReplyStore]); - const { index, publishComment } = usePublishComment(publishCommentOptions); + const { index, publishComment } = usePublishCommentWithChallengeAbandon(publishCommentOptions); return { setPublishReplyOptions, resetPublishReplyOptions, replyIndex: index, publishReply: publishComment, publishReplyOptions }; }; diff --git a/src/stores/use-challenges-store.test.ts b/src/stores/use-challenges-store.test.ts new file mode 100644 index 00000000..1c1aa68a --- /dev/null +++ b/src/stores/use-challenges-store.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import useChallengesStore from './use-challenges-store'; + +const createChallenge = (publisher: Record = {}) => [{ challenges: [] }, publisher] as never; + +afterEach(() => { + useChallengesStore.setState({ challenges: [] }); + vi.restoreAllMocks(); +}); + +describe('useChallengesStore', () => { + it('queues unique entries FIFO and removes the head before awaiting abandonment', async () => { + let finishAbandon: (() => void) | undefined; + const onAbandon = vi.fn( + () => + new Promise((resolve) => { + finishAbandon = resolve; + }), + ); + const firstChallenge = createChallenge(); + const secondChallenge = createChallenge(); + + useChallengesStore.getState().addChallenge(firstChallenge, onAbandon); + useChallengesStore.getState().addChallenge(secondChallenge); + + const [first, second] = useChallengesStore.getState().challenges; + expect(first.challenge).toBe(firstChallenge); + expect(second.challenge).toBe(secondChallenge); + expect(first.id).not.toBe(second.id); + + const abandonment = useChallengesStore.getState().abandonCurrentChallenge(); + + expect(onAbandon).toHaveBeenCalledOnce(); + expect(useChallengesStore.getState().challenges.map((entry) => entry.challenge)).toEqual([secondChallenge]); + + finishAbandon?.(); + await abandonment; + }); + + it('falls back to stopping the live publisher when no callback is supplied', async () => { + const stop = vi.fn().mockResolvedValue(undefined); + useChallengesStore.getState().addChallenge(createChallenge({ stop })); + + await useChallengesStore.getState().abandonCurrentChallenge(); + + expect(stop).toHaveBeenCalledOnce(); + expect(useChallengesStore.getState().challenges).toEqual([]); + }); + + it('logs abandonment failures after removing the challenge', async () => { + const error = new Error('stop failed'); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + useChallengesStore.getState().addChallenge(createChallenge(), vi.fn().mockRejectedValue(error)); + + await expect(useChallengesStore.getState().abandonCurrentChallenge()).resolves.toBeUndefined(); + + expect(consoleError).toHaveBeenCalledWith('Failed to abandon challenge publication:', error); + expect(useChallengesStore.getState().challenges).toEqual([]); + }); +}); diff --git a/src/stores/use-challenges-store.ts b/src/stores/use-challenges-store.ts index d4e5a9ad..44f90cb1 100644 --- a/src/stores/use-challenges-store.ts +++ b/src/stores/use-challenges-store.ts @@ -1,16 +1,25 @@ import { create } from 'zustand'; import { Challenge } from '@bitsocial/bitsocial-react-hooks'; -interface State { - challenges: Challenge[]; - addChallenge: (challenge: Challenge) => void; - removeChallenge: () => void; +export interface ChallengeEntry { + challenge: Challenge; + id: number; + onAbandon?: () => Promise | void; } -const useChallengesStore = create((set) => ({ +let nextChallengeId = 0; + +interface State { + challenges: ChallengeEntry[]; + addChallenge: (challenge: Challenge, onAbandon?: () => Promise | void) => void; + removeChallenge: () => void; + abandonCurrentChallenge: () => Promise; +} + +const useChallengesStore = create((set, get) => ({ challenges: [], - addChallenge: (challenge: Challenge) => { - set((state) => ({ challenges: [...state.challenges, challenge] })); + addChallenge: (challenge, onAbandon) => { + set((state) => ({ challenges: [...state.challenges, { challenge, id: nextChallengeId++, onAbandon }] })); }, removeChallenge: () => { set((state) => { @@ -19,6 +28,20 @@ const useChallengesStore = create((set) => ({ return { challenges }; }); }, + abandonCurrentChallenge: async () => { + const currentChallenge = get().challenges[0]; + get().removeChallenge(); + + try { + if (currentChallenge?.onAbandon) { + await currentChallenge.onAbandon(); + } else if (typeof currentChallenge?.challenge?.[1]?.stop === 'function') { + await currentChallenge.challenge[1].stop(); + } + } catch (error) { + console.error('Failed to abandon challenge publication:', error); + } + }, })); export default useChallengesStore; diff --git a/src/stores/use-publish-post-store.test.ts b/src/stores/use-publish-post-store.test.ts index 515a6eaa..4e478686 100644 --- a/src/stores/use-publish-post-store.test.ts +++ b/src/stores/use-publish-post-store.test.ts @@ -14,16 +14,6 @@ describe('usePublishPostStore', () => { communityAddress: 'interestingasfuck.bso', title: 'Test post', }); - expect(Object.keys(publishCommentOptions).sort()).toEqual([ - 'communityAddress', - 'content', - 'link', - 'nsfw', - 'onChallenge', - 'onChallengeVerification', - 'onError', - 'spoiler', - 'title', - ]); + expect(Object.keys(publishCommentOptions).sort()).toEqual(['communityAddress', 'content', 'link', 'nsfw', 'onChallengeVerification', 'onError', 'spoiler', 'title']); }); }); diff --git a/src/stores/use-publish-post-store.ts b/src/stores/use-publish-post-store.ts index 69bfbd2f..a5e7feb7 100644 --- a/src/stores/use-publish-post-store.ts +++ b/src/stores/use-publish-post-store.ts @@ -1,11 +1,8 @@ import { create } from 'zustand'; -import challengesStore from './use-challenges-store'; import { Comment, PublishCommentOptions } from '@bitsocial/bitsocial-react-hooks'; import { alertChallengeVerificationFailed } from '../lib/utils/challenge-utils'; import { getCommentCommunityAddress } from '../lib/utils/comment-utils'; -const { addChallenge } = challengesStore.getState(); - type SubmitState = { communityAddress: string | undefined; title: string | undefined; @@ -46,7 +43,6 @@ const usePublishPostStore = create((set) => ({ link: nextState.link, spoiler: nextState.spoiler, nsfw: nextState.nsfw, - onChallenge: (...args: any) => addChallenge(args), onChallengeVerification: alertChallengeVerificationFailed, onError: (error: Error) => { console.error(error); diff --git a/src/stores/use-publish-reply-store.test.ts b/src/stores/use-publish-reply-store.test.ts index e8d006ca..86ad65c1 100644 --- a/src/stores/use-publish-reply-store.test.ts +++ b/src/stores/use-publish-reply-store.test.ts @@ -26,7 +26,6 @@ describe('usePublishReplyStore', () => { 'content', 'link', 'nsfw', - 'onChallenge', 'onChallengeVerification', 'onError', 'parentCid', diff --git a/src/stores/use-publish-reply-store.ts b/src/stores/use-publish-reply-store.ts index 1b57dda9..aa981bfe 100644 --- a/src/stores/use-publish-reply-store.ts +++ b/src/stores/use-publish-reply-store.ts @@ -1,7 +1,6 @@ import { PublishCommentOptions } from '@bitsocial/bitsocial-react-hooks'; import { ChallengeVerification, Comment } from '@bitsocial/bitsocial-react-hooks'; import { create } from 'zustand'; -import useChallengesStore from './use-challenges-store'; import { alertChallengeVerificationFailed } from '../lib/utils/challenge-utils'; import { getCommentCommunityAddress } from '../lib/utils/comment-utils'; @@ -16,8 +15,6 @@ type ReplyState = { resetReplyStore: (parentCid: string) => void; }; -const { addChallenge } = useChallengesStore.getState(); - const usePublishReplyStore = create((set) => ({ content: {}, link: {}, @@ -37,7 +34,6 @@ const usePublishReplyStore = create((set) => ({ link, spoiler, nsfw, - onChallenge: (...args: any) => addChallenge(args), onChallengeVerification: (challengeVerification: ChallengeVerification, comment: Comment) => { alertChallengeVerificationFailed(challengeVerification, comment); }, diff --git a/src/views/submit-page/submit-page.test.tsx b/src/views/submit-page/submit-page.test.tsx new file mode 100644 index 00000000..6b5030a4 --- /dev/null +++ b/src/views/submit-page/submit-page.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment jsdom + +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 SubmitPage from './submit-page'; +import useChallengesStore from '../../stores/use-challenges-store'; +import usePublishPostStore from '../../stores/use-publish-post-store'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (callback: () => void | Promise) => void | Promise }).act as (callback: () => void | Promise) => void | Promise; + +const testState = vi.hoisted(() => ({ + abandonPublish: vi.fn().mockResolvedValue(undefined), + lastOptions: undefined as Record | undefined, +})); + +vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ + useAccount: () => ({ subscriptions: [] }), + useCommunity: () => ({ rules: [], title: 'Example' }), + usePublishComment: (options: Record) => { + testState.lastOptions = options; + return { abandonPublish: testState.abandonPublish, index: undefined, publishComment: vi.fn() }; + }, +})); + +vi.mock('@capacitor/core', () => ({ + Capacitor: { getPlatform: () => 'web', isNativePlatform: () => false }, + registerPlugin: vi.fn(), +})); + +vi.mock('../../plugins/file-uploader', () => ({ + default: { pickMedia: vi.fn(), uploadMedia: vi.fn() }, +})); + +vi.mock('react-dropzone', () => ({ + useDropzone: () => ({ getInputProps: () => ({}), getRootProps: () => ({}), isDragActive: false }), +})); + +vi.mock('react-i18next', () => ({ + Trans: ({ values }: { values?: { link?: string } }) => createElement('span', null, values?.link), + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('react-router-dom', () => ({ + Link: ({ children }: { children?: React.ReactNode }) => createElement('a', null, children), + useNavigate: () => vi.fn(), +})); + +vi.mock('../../hooks/use-default-subscriptions', () => ({ + useDefaultSubscriptionAddresses: () => [], +})); + +vi.mock('../../hooks/use-is-community-offline', () => ({ + default: () => ({ isOffline: false, offlineTitle: '' }), +})); + +vi.mock('../../hooks/use-resolved-community-route', () => ({ + default: () => ({ communityAddress: 'example.bso' }), +})); + +vi.mock('../../lib/utils/media-utils', () => ({ getLinkMediaInfo: () => undefined })); +vi.mock('../../components/info-tooltip', () => ({ default: () => null })); +vi.mock('../../components/loading-ellipsis', () => ({ default: () => null })); +vi.mock('../../components/markdown', () => ({ default: () => null })); +vi.mock('../../components/post/embed', () => ({ default: () => null })); +vi.mock('../../components/reply-form/reply-form', () => ({ FormattingHelpTable: () => null })); + +let container: HTMLDivElement; +let root: Root; + +describe('SubmitPage challenge cancellation', () => { + beforeEach(async () => { + vi.clearAllMocks(); + testState.lastOptions = undefined; + useChallengesStore.setState({ challenges: [] }); + usePublishPostStore.getState().resetPublishPostStore(); + vi.stubGlobal('scrollTo', vi.fn()); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root.render(createElement(SubmitPage)); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + useChallengesStore.setState({ challenges: [] }); + usePublishPostStore.getState().resetPublishPostStore(); + vi.unstubAllGlobals(); + }); + + it('routes challenge cancellation to the current usePublishComment abandonPublish', async () => { + await act(async () => { + await testState.lastOptions?.onChallenge({ challenges: [] }, { title: 'Test post' }); + }); + + expect(useChallengesStore.getState().challenges).toHaveLength(1); + + await act(async () => { + await useChallengesStore.getState().abandonCurrentChallenge(); + }); + + expect(testState.abandonPublish).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/views/submit-page/submit-page.tsx b/src/views/submit-page/submit-page.tsx index 074dcec4..7215479b 100644 --- a/src/views/submit-page/submit-page.tsx +++ b/src/views/submit-page/submit-page.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useDropzone } from 'react-dropzone'; import { Trans, useTranslation } from 'react-i18next'; import { Link, useNavigate } from 'react-router-dom'; -import { useAccount, usePublishComment, useCommunity } from '@bitsocial/bitsocial-react-hooks'; +import { useAccount, useCommunity } from '@bitsocial/bitsocial-react-hooks'; import { Capacitor } from '@capacitor/core'; import FileUploader from '../../plugins/file-uploader'; import { getLinkMediaInfo } from '../../lib/utils/media-utils'; @@ -14,6 +14,7 @@ import { useDefaultSubscriptionAddresses } from '../../hooks/use-default-subscri import useIsCommunityOffline from '../../hooks/use-is-community-offline'; import useResolvedCommunityRoute from '../../hooks/use-resolved-community-route'; import { getCommunityIdentifier } from '../../hooks/use-community-identifier'; +import usePublishCommentWithChallengeAbandon from '../../hooks/use-publish-comment-with-challenge-abandon'; import LoadingEllipsis from '../../components/loading-ellipsis'; import Markdown from '../../components/markdown'; import Embed from '../../components/post/embed'; @@ -481,7 +482,7 @@ const SubmitPage = () => { const shortAddress = communityAddress && getShortDisplayAddress(communityAddress); const { isOffline, offlineTitle } = useIsCommunityOffline(selectedCommunityData); - const { index, publishComment } = usePublishComment(publishCommentOptions); + const { index, publishComment } = usePublishCommentWithChallengeAbandon(publishCommentOptions); const onPublish = () => { if (!title) {