Skip to content

Commit 2398d96

Browse files
committed
refactor captcha modal to fix ios crashes;
1 parent 7f5e981 commit 2398d96

4 files changed

Lines changed: 226 additions & 238 deletions

File tree

packages/mobile/src/App.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ import { UnregisteredUsernameContextMenu } from './components/ContextMenu/menus/
5555
import NewUsernameRequestedScreen from './screens/NewUsernameRequested/NewUsernameRequested.screen'
5656
import { PossibleImpersonationAttackScreen } from './screens/PossibleImpersonationAttack/PossibleImpersonationAttack.screen'
5757
import UsernameTakenScreen from './screens/UsernameTaken/UsernameTaken.screen'
58-
import { CaptchaDrawer } from './components/ModalBottomDrawer/drawers/Captcha.drawer'
58+
import { CaptchaModal } from './components/Captcha/CaptchaModal.component'
5959

6060
const logger = createLogger('app')
6161

@@ -130,7 +130,7 @@ function App(): JSX.Element {
130130
<ChannelContextMenu />
131131
<InvitationContextMenu />
132132
<UnregisteredUsernameContextMenu />
133-
<CaptchaDrawer />
133+
<CaptchaModal />
134134
<ConfirmationBox {...confirmationBox} />
135135
</ThemeProvider>
136136
</MenuProvider>
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import React from 'react'
2+
import { render, act } from '@testing-library/react-native'
3+
import { useDispatch, useSelector } from 'react-redux'
4+
import { captcha } from '@quiet/state-manager'
5+
import CaptchaModal from './CaptchaModal.component'
6+
7+
let mockCaptchaState = {
8+
captchaRequested: true,
9+
siteKey: 'test-site-key',
10+
}
11+
12+
const mockDispatch = jest.fn()
13+
const mockShow = jest.fn()
14+
const mockHide = jest.fn()
15+
let latestOnMessage: ((event: unknown) => void) | null = null
16+
17+
jest.mock('react-redux', () => ({
18+
useDispatch: jest.fn(),
19+
useSelector: jest.fn(),
20+
}))
21+
22+
jest.mock('@quiet/state-manager', () => ({
23+
captcha: {
24+
selectors: {
25+
captchaRequested: (state: typeof mockCaptchaState) => state.captchaRequested,
26+
siteKey: (state: typeof mockCaptchaState) => state.siteKey,
27+
},
28+
actions: {
29+
captchaFormResponse: (payload: unknown) => ({ type: 'captcha/captchaFormResponse', payload }),
30+
},
31+
},
32+
}))
33+
34+
jest.mock('@hcaptcha/react-native-hcaptcha', () => {
35+
const React = require('react')
36+
const { View } = require('react-native')
37+
return {
38+
__esModule: true,
39+
default: React.forwardRef(({ onMessage, siteKey }: any, ref: any) => {
40+
latestOnMessage = onMessage
41+
React.useImperativeHandle(ref, () => ({ show: mockShow, hide: mockHide }))
42+
return <View testID='mock-hcaptcha' accessibilityLabel={siteKey} />
43+
}),
44+
}
45+
})
46+
47+
describe('CaptchaModal', () => {
48+
beforeEach(() => {
49+
jest.useFakeTimers()
50+
mockCaptchaState = { captchaRequested: true, siteKey: 'test-site-key' }
51+
mockDispatch.mockClear()
52+
mockShow.mockClear()
53+
mockHide.mockClear()
54+
latestOnMessage = null
55+
;(useDispatch as jest.Mock).mockReturnValue(mockDispatch)
56+
;(useSelector as jest.Mock).mockImplementation(selector => selector(mockCaptchaState))
57+
})
58+
59+
afterEach(() => {
60+
jest.useRealTimers()
61+
})
62+
63+
it('mounts ConfirmHcaptcha and calls show() when captcha is requested', () => {
64+
render(<CaptchaModal />)
65+
act(() => {
66+
jest.runAllTimers()
67+
})
68+
expect(mockShow).toHaveBeenCalled()
69+
})
70+
71+
it('dispatches the solved token on success message', () => {
72+
render(<CaptchaModal />)
73+
act(() => {
74+
latestOnMessage?.({
75+
nativeEvent: { data: 'mock-solved-token-abcdefghijklmnopqrstuvwxyz123456' },
76+
success: true,
77+
markUsed: jest.fn(),
78+
reset: jest.fn(),
79+
})
80+
})
81+
expect(mockDispatch).toHaveBeenCalledWith(
82+
captcha.actions.captchaFormResponse({ token: 'mock-solved-token-abcdefghijklmnopqrstuvwxyz123456' })
83+
)
84+
})
85+
86+
it('dispatches a cancellation on challenge-closed', () => {
87+
render(<CaptchaModal />)
88+
act(() => {
89+
latestOnMessage?.({
90+
nativeEvent: { data: 'challenge-closed' },
91+
success: false,
92+
reset: jest.fn(),
93+
})
94+
})
95+
expect(mockDispatch).toHaveBeenCalledWith(
96+
captcha.actions.captchaFormResponse({ error: 'Captcha cancelled by user' })
97+
)
98+
})
99+
})
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import React, { FC, useCallback, useEffect, useRef, useState } from 'react'
2+
import { BackHandler } from 'react-native'
3+
import ConfirmHcaptcha from '@hcaptcha/react-native-hcaptcha'
4+
import { useDispatch, useSelector } from 'react-redux'
5+
import { captcha } from '@quiet/state-manager'
6+
import { defaultTheme } from '../../styles/themes/default.theme'
7+
import { createLogger } from '../../utils/logger'
8+
9+
const logger = createLogger('CaptchaModal')
10+
11+
interface HCaptchaMessageEvent {
12+
nativeEvent: { data: string }
13+
success: boolean
14+
markUsed?: () => void
15+
reset: () => void
16+
}
17+
18+
const SHOW_DELAY_MS = 100
19+
20+
// Polyfill: react-native-modal@13 (bundled inside @hcaptcha/react-native-hcaptcha)
21+
// calls BackHandler.removeEventListener which was removed in RN 0.72+. Its
22+
// absence crashes the modal on hide. Install a no-op shim if missing.
23+
// (addEventListener still returns a subscription with .remove() so leak is minor.)
24+
const bhMut = BackHandler as unknown as { removeEventListener?: (...args: unknown[]) => boolean }
25+
if (typeof bhMut.removeEventListener !== 'function') {
26+
bhMut.removeEventListener = () => true
27+
}
28+
29+
export const CaptchaModal: FC = () => {
30+
const dispatch = useDispatch()
31+
const captchaRef = useRef<ConfirmHcaptcha | null>(null)
32+
// Guard against the library's hide()/onMessage emitting repeated cancels
33+
// after we've already dispatched a terminal response for this session.
34+
const respondedRef = useRef(false)
35+
36+
const captchaRequested = useSelector(captcha.selectors.captchaRequested)
37+
const siteKey = useSelector(captcha.selectors.siteKey)
38+
39+
// Latch the first non-empty siteKey so the native modal stays mounted
40+
// across community-leave flows that wipe redux state. Unmounting the
41+
// library's RN modal mid-presentation crashes iOS WebView.
42+
const [mountedSiteKey, setMountedSiteKey] = useState('')
43+
useEffect(() => {
44+
if (siteKey && siteKey !== mountedSiteKey) {
45+
setMountedSiteKey(siteKey)
46+
}
47+
}, [siteKey, mountedSiteKey])
48+
49+
useEffect(() => {
50+
if (!mountedSiteKey) return
51+
if (captchaRequested && siteKey !== '') {
52+
logger.info(`Showing captcha for ${siteKey}`)
53+
respondedRef.current = false
54+
const id = setTimeout(() => captchaRef.current?.show(), SHOW_DELAY_MS)
55+
return () => clearTimeout(id)
56+
}
57+
// Pass no argument: the library synthesizes an onMessage({data:'cancel'})
58+
// when hide() is called with a truthy source, which would re-enter our
59+
// handler and loop.
60+
captchaRef.current?.hide()
61+
return undefined
62+
}, [captchaRequested, siteKey, mountedSiteKey])
63+
64+
const respond = useCallback(
65+
(payload: Parameters<typeof captcha.actions.captchaFormResponse>[0]) => {
66+
if (respondedRef.current) return
67+
respondedRef.current = true
68+
dispatch(captcha.actions.captchaFormResponse(payload))
69+
},
70+
[dispatch]
71+
)
72+
73+
const handleMessage = useCallback(
74+
(event: HCaptchaMessageEvent) => {
75+
const data = event?.nativeEvent?.data
76+
if (!data) return
77+
if (respondedRef.current) return
78+
79+
if (data === 'open') return
80+
81+
if (data === 'cancel' || data === 'challenge-closed') {
82+
logger.info('hCaptcha cancelled')
83+
respond({ error: 'Captcha cancelled by user' })
84+
captchaRef.current?.hide()
85+
return
86+
}
87+
88+
if (data === 'challenge-expired' || data === 'expired') {
89+
logger.warn('hCaptcha expired, resetting')
90+
event.reset?.()
91+
return
92+
}
93+
94+
if (event.success) {
95+
event.markUsed?.()
96+
logger.info('hCaptcha solved')
97+
respond({ token: data })
98+
captchaRef.current?.hide()
99+
return
100+
}
101+
102+
logger.error('hCaptcha verification failed', data)
103+
respond({ error: `Captcha error: ${data}` })
104+
captchaRef.current?.hide()
105+
},
106+
[respond]
107+
)
108+
109+
if (mountedSiteKey === '') return null
110+
111+
return (
112+
<ConfirmHcaptcha
113+
ref={captchaRef}
114+
siteKey={mountedSiteKey}
115+
onMessage={handleMessage}
116+
size='invisible'
117+
orientation='portrait'
118+
languageCode='en'
119+
showLoading
120+
loadingIndicatorColor={defaultTheme.palette.background.lushSky}
121+
/>
122+
)
123+
}
124+
125+
export default CaptchaModal

0 commit comments

Comments
 (0)