fix(challenge modal): avoid native iframe consent on WebKit

This commit is contained in:
Tommaso Casaburi
2026-05-01 15:17:43 +07:00
parent 0cbae87aef
commit 0180c4e59f
3 changed files with 261 additions and 44 deletions
@@ -15,6 +15,7 @@ const testState = vi.hoisted(() => ({
address: '0xabc123',
},
} as Record<string, any>,
capacitorPlatform: 'web',
challenges: [] as Array<{ challenge: any; id: number }>,
commentsByCid: {} as Record<string, { author?: { shortAddress?: string } }>,
publicationPreview: 'preview body',
@@ -44,6 +45,12 @@ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.commentsByCid[commentCid] : undefined),
}));
vi.mock('@capacitor/core', () => ({
Capacitor: {
getPlatform: () => testState.capacitorPlatform,
},
}));
vi.mock('../../../lib/utils/challenge-utils', () => ({
getPublicationPreview: () => testState.publicationPreview,
getPublicationType: () => testState.publicationType,
@@ -139,6 +146,13 @@ const clickButton = async (text: string) => {
});
};
const setNavigatorValue = (key: keyof Navigator, value: unknown) => {
Object.defineProperty(window.navigator, key, {
configurable: true,
value,
});
};
describe('ChallengeModal', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -148,6 +162,7 @@ describe('ChallengeModal', () => {
address: '0xabc123',
},
};
testState.capacitorPlatform = 'web';
testState.challenges = [];
testState.commentsByCid = {
'parent-1': {
@@ -162,6 +177,10 @@ describe('ChallengeModal', () => {
testState.springStartMock.mockReset();
testState.theme = 'dark';
testState.votePreview = 'upvote';
setNavigatorValue('maxTouchPoints', 0);
setNavigatorValue('platform', 'MacIntel');
setNavigatorValue('userAgent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125 Safari/537.36');
setNavigatorValue('vendor', 'Google Inc.');
alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true);
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
@@ -465,6 +484,113 @@ describe('ChallengeModal', () => {
expect(container.querySelector('iframe')).toBeNull();
});
it('uses inline iframe confirmation on Android instead of window.confirm', async () => {
const publication = createPublication();
testState.capacitorPlatform = 'android';
testState.publicationType = 'reply';
testState.challenges = [
createStoredChallenge(
{
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
type: 'url/iframe',
},
{
...publication,
content: '>>17\nreply from android',
title: '',
},
),
];
await renderModal();
expect(confirmSpy).not.toHaveBeenCalled();
expect(container.textContent).toContain('spamblocker.bitsocial.net');
expect(container.textContent).toContain('mu wants to open spamblocker.bitsocial.net.\n\nFor reply: >>17 reply from android');
expect(container.textContent).toContain('Open');
expect(container.textContent).toContain('close');
expect(container.textContent).not.toContain('Challenge for reply');
expect(container.querySelector('iframe')).toBeNull();
await clickButton('Open');
const iframe = container.querySelector('iframe');
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute('src')).toContain('https://spamblocker.bitsocial.net/api/v1/iframe/session-123?theme=dark');
expect(testState.abandonCurrentChallengeMock).not.toHaveBeenCalled();
});
it('abandons Android iframe challenges when inline confirmation is closed', async () => {
testState.capacitorPlatform = 'android';
testState.challenges = [
createStoredChallenge({
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
type: 'url/iframe',
}),
];
await renderModal();
await clickButton('close');
expect(confirmSpy).not.toHaveBeenCalled();
expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledOnce();
expect(container.querySelector('iframe')).toBeNull();
});
it('uses inline iframe confirmation on Safari instead of window.confirm', async () => {
testState.publicationType = 'reply';
setNavigatorValue('userAgent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15');
setNavigatorValue('vendor', 'Apple Computer, Inc.');
testState.challenges = [
createStoredChallenge(
{
challenge: 'https://spamblocker.bitsocial.net/api/v1/iframe/session-123',
type: 'url/iframe',
},
{
...createPublication(),
content: '>>17\nreply from safari',
title: '',
},
),
];
await renderModal();
expect(confirmSpy).not.toHaveBeenCalled();
expect(container.textContent).toContain('mu wants to open spamblocker.bitsocial.net.\n\nFor reply: >>17 reply from safari');
expect(container.querySelector('iframe')).toBeNull();
await clickButton('Open');
const iframe = container.querySelector('iframe');
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute('src')).toContain('https://spamblocker.bitsocial.net/api/v1/iframe/session-123?theme=dark');
});
it('shows iframe challenge errors inline on Safari instead of relying on alert', async () => {
testState.account = { author: { address: '' } };
setNavigatorValue('userAgent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15');
setNavigatorValue('vendor', 'Apple Computer, Inc.');
testState.challenges = [
createStoredChallenge({
challenge: 'https://mintpass.org/auth?user={userAddress}',
type: 'url/iframe',
}),
];
await renderModal();
await clickButton('Open');
expect(alertSpy).not.toHaveBeenCalled();
expect(container.textContent).toContain('Error: Unable to load challenge without your address. Please sign in and try again.');
expect(container.textContent).not.toContain('Open');
expect(container.querySelector('iframe')).toBeNull();
await clickButton('close');
expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledOnce();
});
it('alerts when iframe challenges need a signer address and the account is missing one', async () => {
testState.account = { author: { address: '' } };
testState.challenges = [
@@ -112,6 +112,18 @@
margin: 10px 0;
}
.iframeConsentWrapper {
justify-content: flex-start;
margin: 10px 8px;
}
.iframeConsentMessage {
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: normal;
line-height: 1.35;
}
.iframe {
width: 100%;
height: 100%;
@@ -1,6 +1,7 @@
import { useRef, useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Challenge as ChallengeType, useAccount, useComment } from '@bitsocial/bitsocial-react-hooks';
import { Capacitor } from '@capacitor/core';
import { getPublicationPreview, getPublicationType, getVotePreview, type ChallengePublication } from '../../lib/utils/challenge-utils';
import useIsMobile from '../../hooks/use-is-mobile';
import useChallengesStore from '../../stores/use-challenges-store';
@@ -44,6 +45,23 @@ const getDisplayCommunityAddress = (shortCommunityAddress?: string, communityAdd
shortCommunityAddress || (communityAddress ? getShortAddress(communityAddress) : '') || communityAddress || '';
const iframeChallengeConfirmDecisions = new Map<string, 'accepted' | 'rejected'>();
const isIosWebKitUserAgent = () => {
if (typeof navigator === 'undefined') return false;
return /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
};
const isDesktopSafariUserAgent = () => {
if (typeof navigator === 'undefined') return false;
const isAppleVendor = navigator.vendor === 'Apple Computer, Inc.';
const isSafari = /Safari/.test(navigator.userAgent);
const isOtherBrowser = /Chrome|Chromium|CriOS|FxiOS|Edg|OPR|SamsungBrowser/.test(navigator.userAgent);
return isAppleVendor && isSafari && !isOtherBrowser;
};
const shouldUseInlineIframeConfirm = () => {
const platform = Capacitor.getPlatform();
return platform === 'android' || platform === 'ios' || isIosWebKitUserAgent() || isDesktopSafariUserAgent();
};
const TextChallenge = ({ challenge }: { challenge: string }) => <div className={styles.challengeMedia}>{challenge}</div>;
@@ -112,17 +130,34 @@ interface IframeChallengeProps {
challenge: string;
confirmKey: string;
confirmMessage: string;
inlineConfirm: boolean;
onCancel: () => void;
onDone: () => void;
onAutoComplete: (challengeAnswers: string[]) => void;
onReady: () => void;
openLabel: string;
closeLabel: string;
}
const IframeChallenge = ({ challenge, confirmKey, confirmMessage, onCancel, onDone, onAutoComplete, onReady }: IframeChallengeProps) => {
type IframeConfirmSource = 'native' | 'inline';
const IframeChallenge = ({
challenge,
confirmKey,
confirmMessage,
inlineConfirm,
onCancel,
onDone,
onAutoComplete,
onReady,
openLabel,
closeLabel,
}: IframeChallengeProps) => {
const account = useAccount();
const [theme] = useTheme();
const [iframeUrlState, setIframeUrl] = useState('');
const [iframeOrigin, setIframeOrigin] = useState('');
const [inlineErrorMessage, setInlineErrorMessage] = useState('');
const iframeRef = useRef<HTMLIFrameElement>(null);
const attemptedLoadRef = useRef(false);
const mountedRef = useRef(false);
@@ -148,59 +183,78 @@ const IframeChallenge = ({ challenge, confirmKey, confirmMessage, onCancel, onDo
[onReady],
);
const handleLoadIframe = useCallback(() => {
const iframeUrl = challenge;
if (!iframeUrl) return;
const handleLoadIframe = useCallback(
(confirmSource: IframeConfirmSource) => {
const iframeUrl = challenge;
if (!iframeUrl) return;
const rawUserAddress = account?.author?.address?.trim();
const requiresUserAddress = iframeUrl.includes('{userAddress}');
if (requiresUserAddress && !rawUserAddress) {
alert('Error: Unable to load challenge without your address. Please sign in and try again.');
iframeChallengeConfirmDecisions.set(confirmKey, 'rejected');
onCancel();
return;
}
const encodedAddress = rawUserAddress ? encodeURIComponent(rawUserAddress) : undefined;
const replacedUrl = requiresUserAddress && encodedAddress ? iframeUrl.replace(/\{userAddress\}/g, encodedAddress) : iframeUrl;
const validatedUrl = validateIframeChallengeUrl(replacedUrl, theme);
if (validatedUrl.status === 'unsupported') {
alert('Error: Only HTTPS iframe challenges or localhost HTTP challenges are supported');
iframeChallengeConfirmDecisions.set(confirmKey, 'rejected');
onCancel();
return;
}
if (validatedUrl.status === 'invalid') {
console.error('Invalid iframe challenge URL', { error: validatedUrl.error });
alert('Error: Invalid URL for authentication challenge');
iframeChallengeConfirmDecisions.set(confirmKey, 'rejected');
onCancel();
return;
}
const decision = iframeChallengeConfirmDecisions.get(confirmKey);
if (decision === 'rejected') {
onCancel();
return;
}
if (decision !== 'accepted') {
if (!window.confirm(confirmMessage)) {
const rejectChallenge = (message: string) => {
iframeChallengeConfirmDecisions.set(confirmKey, 'rejected');
if (inlineConfirm) {
setInlineErrorMessage(message);
return;
}
alert(message);
onCancel();
};
const rawUserAddress = account?.author?.address?.trim();
const requiresUserAddress = iframeUrl.includes('{userAddress}');
if (requiresUserAddress && !rawUserAddress) {
rejectChallenge('Error: Unable to load challenge without your address. Please sign in and try again.');
return;
}
const encodedAddress = rawUserAddress ? encodeURIComponent(rawUserAddress) : undefined;
const replacedUrl = requiresUserAddress && encodedAddress ? iframeUrl.replace(/\{userAddress\}/g, encodedAddress) : iframeUrl;
const validatedUrl = validateIframeChallengeUrl(replacedUrl, theme);
if (validatedUrl.status === 'unsupported') {
rejectChallenge('Error: Only HTTPS iframe challenges or localhost HTTP challenges are supported');
return;
}
if (validatedUrl.status === 'invalid') {
console.error('Invalid iframe challenge URL', { error: validatedUrl.error });
rejectChallenge('Error: Invalid URL for authentication challenge');
return;
}
const decision = iframeChallengeConfirmDecisions.get(confirmKey);
if (decision === 'rejected') {
onCancel();
return;
}
iframeChallengeConfirmDecisions.set(confirmKey, 'accepted');
}
openValidatedIframe(validatedUrl);
}, [account, challenge, confirmKey, confirmMessage, onCancel, openValidatedIframe, theme]);
if (decision !== 'accepted') {
if (confirmSource === 'native' && !window.confirm(confirmMessage)) {
iframeChallengeConfirmDecisions.set(confirmKey, 'rejected');
onCancel();
return;
}
iframeChallengeConfirmDecisions.set(confirmKey, 'accepted');
}
openValidatedIframe(validatedUrl);
},
[account, challenge, confirmKey, confirmMessage, inlineConfirm, onCancel, openValidatedIframe, theme],
);
useEffect(() => {
if (inlineConfirm) return;
if (attemptedLoadRef.current) return;
attemptedLoadRef.current = true;
handleLoadIframe();
handleLoadIframe('native');
}, [handleLoadIframe, inlineConfirm]);
const handleInlineConfirm = useCallback(() => {
if (attemptedLoadRef.current) return;
attemptedLoadRef.current = true;
handleLoadIframe('inline');
}, [handleLoadIframe]);
const handleInlineCancel = useCallback(() => {
iframeChallengeConfirmDecisions.set(confirmKey, 'rejected');
onCancel();
}, [confirmKey, onCancel]);
const sendThemeToIframe = useCallback(() => {
postThemeToIframe(iframeRef.current, iframeOrigin, theme);
}, [iframeOrigin, theme]);
@@ -240,6 +294,27 @@ const IframeChallenge = ({ challenge, confirmKey, confirmMessage, onCancel, onDo
}, [expectedSessionId, iframeOrigin, onAutoComplete]);
if (!iframeUrlState) {
if (inlineConfirm) {
return (
<>
<div className={`${styles.challengeMediaWrapper} ${styles.iframeConsentWrapper}`}>
<div className={styles.iframeConsentMessage}>{inlineErrorMessage || confirmMessage}</div>
</div>
<div className={`${styles.challengeFooter} ${styles.iframeFooter}`}>
<span>
{!inlineErrorMessage && (
<button type='button' onClick={handleInlineConfirm}>
{openLabel}
</button>
)}
<button type='button' onClick={handleInlineCancel}>
{closeLabel}
</button>
</span>
</div>
</>
);
}
return null;
}
@@ -298,7 +373,8 @@ const Challenge = ({ challenge, challengeId, closeModal, abandonModal }: Challen
const isTextChallenge = currentChallenge?.type === 'text/plain';
const isImageChallenge = currentChallenge?.type === 'image/png';
const isIframeChallenge = currentChallenge?.type === 'url/iframe';
const isIframePending = isIframeChallenge && readyIframeChallengeKey !== iframeChallengeKey;
const inlineIframeConfirm = isIframeChallenge && shouldUseInlineIframeConfirm();
const isIframePending = isIframeChallenge && !inlineIframeConfirm && readyIframeChallengeKey !== iframeChallengeKey;
useEffect(() => {
inputRef.current?.focus();
@@ -463,10 +539,13 @@ const Challenge = ({ challenge, challengeId, closeModal, abandonModal }: Challen
challenge={currentChallenge?.challenge ?? ''}
confirmKey={iframeChallengeKey}
confirmMessage={iframeConfirmMessage}
inlineConfirm={inlineIframeConfirm}
onCancel={abandonModal}
onDone={onIframeDone}
onAutoComplete={onIframeAutoComplete}
onReady={onIframeReady}
openLabel={capitalize(t('open'))}
closeLabel={t('close')}
/>
) : (
<>