mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(challenge modal): avoid native iframe consent on WebKit
This commit is contained in:
@@ -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')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user