mirror of
https://github.com/plebbit/seedit.git
synced 2026-07-31 09:56:04 -04:00
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:
123
src/components/challenge-modal/challenge-modal.test.tsx
Normal file
123
src/components/challenge-modal/challenge-modal.test.tsx
Normal 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('');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
30
src/hooks/use-publish-comment-with-challenge-abandon.ts
Normal file
30
src/hooks/use-publish-comment-with-challenge-abandon.ts
Normal 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;
|
||||
71
src/hooks/use-publish-reply.test.tsx
Normal file
71
src/hooks/use-publish-reply.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
60
src/stores/use-challenges-store.test.ts
Normal file
60
src/stores/use-challenges-store.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -26,7 +26,6 @@ describe('usePublishReplyStore', () => {
|
||||
'content',
|
||||
'link',
|
||||
'nsfw',
|
||||
'onChallenge',
|
||||
'onChallengeVerification',
|
||||
'onError',
|
||||
'parentCid',
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
109
src/views/submit-page/submit-page.test.tsx
Normal file
109
src/views/submit-page/submit-page.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user