fix(publishing): abandon publications when challenges close (#829)

* fix(publishing): abandon publications when challenges close

* refactor(publishing): share challenge abandon hook
This commit is contained in:
Tommaso Casaburi
2026-07-23 18:49:24 +07:00
committed by GitHub
parent 8cf73d7728
commit 4e0bfc49b3
13 changed files with 464 additions and 63 deletions

View File

@@ -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>) => void | Promise<void> }).act as (callback: () => void | Promise<void>) => void | Promise<void>;
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => (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<HTMLInputElement>('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<HTMLInputElement>('input')?.value).toBe('');
});
});

View File

@@ -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
</div>
<div className={styles.challengeFooter}>
<span className={styles.buttons}>
<button onClick={handleLoadIframe}>{t('open', { defaultValue: 'open' })}</button>
<button onClick={closeModal}>{t('cancel')}</button>
<button type='button' onClick={handleLoadIframe}>
{t('open', { defaultValue: 'open' })}
</button>
<button type='button' onClick={abandonModal}>
{t('cancel')}
</button>
</span>
</div>
</>
@@ -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.' })}
</div>
<div className={styles.iframeCloseButton}>
<button onClick={onIframeClose}>{t('done', { defaultValue: 'done' })}</button>
<button type='button' onClick={onIframeDone}>
{t('done', { defaultValue: 'done' })}
</button>
</div>
</div>
</>
@@ -299,42 +292,52 @@ const RegularChallengeContent = ({ challenge, closeModal }: RegularChallengeCont
<div className={styles.counter}>{t('challenge_counter', { index: currentChallengeIndex + 1, total: challenges?.length })}</div>
<span className={styles.buttons}>
{!challenges?.[currentChallengeIndex + 1] && (
<button onClick={onSubmit} disabled={!isValidAnswer(currentChallengeIndex)}>
<button type='button' onClick={onSubmit} disabled={!isValidAnswer(currentChallengeIndex)}>
{t('submit')}
</button>
)}
<button onClick={closeModal}>{t('cancel')}</button>
<button type='button' onClick={abandonModal}>
{t('cancel')}
</button>
{challenges && challenges.length > 1 && (
<button disabled={!challenges[currentChallengeIndex - 1]} onClick={() => setCurrentChallengeIndex((prev) => prev - 1)}>
<button type='button' disabled={!challenges[currentChallengeIndex - 1]} onClick={() => setCurrentChallengeIndex((prev) => prev - 1)}>
{t('previous')}
</button>
)}
{challenges?.[currentChallengeIndex + 1] && <button onClick={() => setCurrentChallengeIndex((prev) => prev + 1)}>{t('next')}</button>}
{challenges?.[currentChallengeIndex + 1] && (
<button type='button' onClick={() => setCurrentChallengeIndex((prev) => prev + 1)}>
{t('next')}
</button>
)}
</span>
</div>
</>
);
};
const ChallengeContent = ({ challenge, closeModal }: { challenge?: ChallengeType; closeModal: () => void }) => {
const ChallengeContent = ({ challenge, closeModal, abandonModal }: { challenge?: ChallengeType; closeModal: () => void; abandonModal: () => void }) => {
if (challenge) {
return <RegularChallengeContent challenge={challenge} closeModal={closeModal} />;
return <RegularChallengeContent challenge={challenge} closeModal={closeModal} abandonModal={abandonModal} />;
}
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 && (
<FloatingFocusManager context={context} modal={false}>
<div className={styles.modal} ref={refs.setFloating} aria-labelledby={headingId} {...getFloatingProps()}>
<div className={styles.container}>
<ChallengeContent challenge={challenges[0]} closeModal={closeModal} />
<ChallengeContent key={currentChallenge.id} challenge={currentChallenge.challenge} closeModal={closeModal} abandonModal={abandonModal} />
</div>
</div>
</FloatingFocusManager>

View File

@@ -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<void>) | 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;

View File

@@ -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>) => void | Promise<void> }).act as (callback: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
abandonPublish: vi.fn().mockResolvedValue(undefined),
lastOptions: undefined as Record<string, any> | undefined,
}));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
usePublishComment: (options: Record<string, any>) => {
testState.lastOptions = options;
return { abandonPublish: testState.abandonPublish, index: undefined, publishComment: vi.fn() };
},
}));
let container: HTMLDivElement;
let latestValue: ReturnType<typeof usePublishReply>;
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();
});
});

View File

@@ -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 };
};

View File

@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import useChallengesStore from './use-challenges-store';
const createChallenge = (publisher: Record<string, unknown> = {}) => [{ 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<void>((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([]);
});
});

View File

@@ -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> | void;
}
const useChallengesStore = create<State>((set) => ({
let nextChallengeId = 0;
interface State {
challenges: ChallengeEntry[];
addChallenge: (challenge: Challenge, onAbandon?: () => Promise<void> | void) => void;
removeChallenge: () => void;
abandonCurrentChallenge: () => Promise<void>;
}
const useChallengesStore = create<State>((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<State>((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;

View File

@@ -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']);
});
});

View File

@@ -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<SubmitState>((set) => ({
link: nextState.link,
spoiler: nextState.spoiler,
nsfw: nextState.nsfw,
onChallenge: (...args: any) => addChallenge(args),
onChallengeVerification: alertChallengeVerificationFailed,
onError: (error: Error) => {
console.error(error);

View File

@@ -26,7 +26,6 @@ describe('usePublishReplyStore', () => {
'content',
'link',
'nsfw',
'onChallenge',
'onChallengeVerification',
'onError',
'parentCid',

View File

@@ -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<ReplyState>((set) => ({
content: {},
link: {},
@@ -37,7 +34,6 @@ const usePublishReplyStore = create<ReplyState>((set) => ({
link,
spoiler,
nsfw,
onChallenge: (...args: any) => addChallenge(args),
onChallengeVerification: (challengeVerification: ChallengeVerification, comment: Comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},

View File

@@ -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>) => void | Promise<void> }).act as (callback: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
abandonPublish: vi.fn().mockResolvedValue(undefined),
lastOptions: undefined as Record<string, any> | undefined,
}));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useAccount: () => ({ subscriptions: [] }),
useCommunity: () => ({ rules: [], title: 'Example' }),
usePublishComment: (options: Record<string, any>) => {
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();
});
});

View File

@@ -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) {