feat: auto-complete iframe challenges

This commit is contained in:
Tommaso Casaburi
2026-04-16 14:07:57 +07:00
parent 35a5cb2411
commit b288c62c06
2 changed files with 121 additions and 1 deletions
@@ -325,6 +325,70 @@ describe('ChallengeModal', () => {
);
});
it('auto-submits iframe challenges when the iframe posts a completion message', async () => {
const publication = createPublication();
testState.challenges = [
createStoredChallenge(
{
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
type: 'url/iframe',
},
publication,
),
];
await renderModal();
await clickButton('Open');
await act(async () => {
window.dispatchEvent(
new MessageEvent('message', {
data: {
type: 'challengeAnswer',
challengeAnswers: [''],
sessionId: 'session-123',
},
origin: 'https://spamblocker.bitsocial.net',
}),
);
});
expect(publication.publishChallengeAnswers).toHaveBeenCalledWith(['']);
expect(testState.removeChallengeMock).toHaveBeenCalledOnce();
});
it('ignores iframe completion messages with the wrong session id', async () => {
const publication = createPublication();
testState.challenges = [
createStoredChallenge(
{
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
type: 'url/iframe',
},
publication,
),
];
await renderModal();
await clickButton('Open');
await act(async () => {
window.dispatchEvent(
new MessageEvent('message', {
data: {
type: 'challengeAnswer',
challengeAnswers: [''],
sessionId: 'session-999',
},
origin: 'https://spamblocker.bitsocial.net',
}),
);
});
expect(publication.publishChallengeAnswers).not.toHaveBeenCalled();
expect(testState.removeChallengeMock).not.toHaveBeenCalled();
});
it('uses the shortened community address when shortCommunityAddress is unavailable', async () => {
const longCommunityAddress = '12D3KooWS6yKc5N7o6JcAYHZpaQwAwyh1VddYatarU75Se3HXEeD';
const publication = {
@@ -37,6 +37,16 @@ const getReadableIframeUrl = (challengeUrl: string) => {
}
};
const getIframeSessionId = (challengeUrl: string) => {
try {
const url = new URL(challengeUrl);
const match = url.pathname.match(/\/iframe\/([^/?#]+)/);
return match?.[1] ?? '';
} catch {
return '';
}
};
interface IframeChallengeProps {
challenge: string;
shortCommunityAddress?: string;
@@ -44,17 +54,29 @@ interface IframeChallengeProps {
readableUrl: string;
onCancel: () => void;
onDone: () => void;
onAutoComplete: (challengeAnswers: string[]) => void;
publicationDetails: React.ReactNode;
}
const IframeChallenge = ({ challenge, shortCommunityAddress, communityAddress, readableUrl, onCancel, onDone, publicationDetails }: IframeChallengeProps) => {
const IframeChallenge = ({
challenge,
shortCommunityAddress,
communityAddress,
readableUrl,
onCancel,
onDone,
onAutoComplete,
publicationDetails,
}: IframeChallengeProps) => {
const account = useAccount();
const [theme] = useTheme();
const [showIframeConfirmation, setShowIframeConfirmation] = useState(true);
const [iframeUrlState, setIframeUrl] = useState('');
const [iframeOrigin, setIframeOrigin] = useState('');
const iframeRef = useRef<HTMLIFrameElement>(null);
const handledAutoCompleteRef = useRef(false);
const displayCommunityAddress = shortCommunityAddress || (communityAddress ? getShortAddress(communityAddress) : '') || communityAddress || 'unknown board';
const expectedSessionId = getIframeSessionId(challenge);
const handleLoadIframe = useCallback(() => {
const iframeUrl = challenge;
@@ -110,6 +132,30 @@ const IframeChallenge = ({ challenge, shortCommunityAddress, communityAddress, r
}
}, [iframeOrigin, iframeUrlState, sendThemeToIframe, showIframeConfirmation]);
useEffect(() => {
if (showIframeConfirmation || !iframeOrigin || !expectedSessionId) {
handledAutoCompleteRef.current = false;
return;
}
const handleMessage = (event: MessageEvent) => {
if (event.origin !== iframeOrigin) return;
if (handledAutoCompleteRef.current) return;
const data = event.data;
if (!data || typeof data !== 'object') return;
if ((data as { type?: string }).type !== 'challengeAnswer') return;
const challengeAnswers = (data as { challengeAnswers?: unknown }).challengeAnswers;
if (!Array.isArray(challengeAnswers)) return;
const sessionId = (data as { sessionId?: unknown }).sessionId;
if (sessionId !== expectedSessionId) return;
handledAutoCompleteRef.current = true;
onAutoComplete(challengeAnswers.filter((answer): answer is string => typeof answer === 'string'));
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [expectedSessionId, iframeOrigin, onAutoComplete, showIframeConfirmation]);
if (showIframeConfirmation) {
return (
<>
@@ -229,6 +275,15 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
closeModal();
}, [closeModal, publication]);
const onIframeAutoComplete = useCallback(
(challengeAnswers: string[]) => {
if (!publication) return;
publication.publishChallengeAnswers(challengeAnswers);
closeModal();
},
[closeModal, publication],
);
const onEnterKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== 'Enter') return;
if (!isValidAnswer(currentChallengeIndex)) return;
@@ -323,6 +378,7 @@ const Challenge = ({ challenge, closeModal, abandonModal }: ChallengeProps) => {
readableUrl={readableUrl}
onCancel={abandonModal}
onDone={onIframeDone}
onAutoComplete={onIframeAutoComplete}
publicationDetails={publicationDetails}
/>
) : (