diff --git a/src/components/challenge-modal/__tests__/challenge-modal.test.tsx b/src/components/challenge-modal/__tests__/challenge-modal.test.tsx index 08ebe0a3..3f49a4ad 100644 --- a/src/components/challenge-modal/__tests__/challenge-modal.test.tsx +++ b/src/components/challenge-modal/__tests__/challenge-modal.test.tsx @@ -15,6 +15,7 @@ const testState = vi.hoisted(() => ({ address: '0xabc123', }, } as Record, + capacitorPlatform: 'web', challenges: [] as Array<{ challenge: any; id: number }>, commentsByCid: {} as Record, 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 = [ diff --git a/src/components/challenge-modal/challenge-modal.module.css b/src/components/challenge-modal/challenge-modal.module.css index 662b46b5..59582ee1 100644 --- a/src/components/challenge-modal/challenge-modal.module.css +++ b/src/components/challenge-modal/challenge-modal.module.css @@ -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%; diff --git a/src/components/challenge-modal/challenge-modal.tsx b/src/components/challenge-modal/challenge-modal.tsx index d12a0f93..619ea4dd 100644 --- a/src/components/challenge-modal/challenge-modal.tsx +++ b/src/components/challenge-modal/challenge-modal.tsx @@ -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(); +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 }) =>
{challenge}
; @@ -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(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 ( + <> +
+
{inlineErrorMessage || confirmMessage}
+
+
+ + {!inlineErrorMessage && ( + + )} + + +
+ + ); + } 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')} /> ) : ( <>