diff --git a/Example.App.js b/Example.App.js index 3d1f31e..bda46e0 100644 --- a/Example.App.js +++ b/Example.App.js @@ -1,11 +1,15 @@ import React, { useState, useRef } from 'react'; import { Text, View, StyleSheet, TouchableOpacity } from 'react-native'; import ConfirmHcaptcha from '@hcaptcha/react-native-hcaptcha'; +// import ConfirmHcaptcha, { initJourneyTracking } from '@hcaptcha/react-native-hcaptcha'; // demo sitekey const siteKey = '00000000-0000-0000-0000-000000000000'; const baseUrl = 'https://hcaptcha.com'; +// Uncomment to enable automatic User Journeys collection for this example app. +// initJourneyTracking(); + const App = () => { const [code, setCode] = useState(null); const captchaForm = useRef(null); @@ -38,6 +42,8 @@ const App = () => { baseUrl={baseUrl} languageCode="en" onMessage={onMessage} + // Uncomment to attach the buffered User Journey to each verification request. + // userJourney={true} /> { diff --git a/Example.UserJourneys.App.js b/Example.UserJourneys.App.js new file mode 100644 index 0000000..2257483 --- /dev/null +++ b/Example.UserJourneys.App.js @@ -0,0 +1,113 @@ +import React, { useRef, useState } from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import ConfirmHcaptcha, { initJourneyTracking } from '@hcaptcha/react-native-hcaptcha'; + +// demo sitekey +const siteKey = '00000000-0000-0000-0000-000000000000'; +const baseUrl = 'https://hcaptcha.com'; + +initJourneyTracking({ + debug: true, + onStats: (stats) => { + console.log('[journey-stats]', JSON.stringify(stats)); + }, +}); + +const App = () => { + const [code, setCode] = useState(null); + const [warmups, setWarmups] = useState(0); + const captchaForm = useRef(null); + + const onMessage = (event) => { + if (!event?.nativeEvent?.data) { + return; + } + + if (event.nativeEvent.data === 'open') { + console.log('Visual challenge opened'); + return; + } + + if (event.success) { + setCode(event.nativeEvent.data); + captchaForm.current.hide(); + event.markUsed(); + console.log('Verified code from hCaptcha', event.nativeEvent.data); + return; + } + + if (event.nativeEvent.data === 'challenge-expired') { + event.reset(); + console.log('Visual challenge expired, reset...', event.nativeEvent.data); + return; + } + + setCode(event.nativeEvent.data); + captchaForm.current.hide(); + console.log('Verification failed', event.nativeEvent.data); + }; + + return ( + + + { + setWarmups((value) => value + 1); + }} + testID="warmup-touch" + > + Preverify: {warmups} taps here + + { + captchaForm.current.show(); + }} + testID="launch-captcha" + > + Tap to launch + + {code && ( + + {'passcode or status: '} + + {code} + + + )} + + ); +}; + +const styles = StyleSheet.create({ + codeContainer: { + alignSelf: 'center', + }, + codeText: { + color: 'darkviolet', + fontSize: 6, + fontWeight: 'bold', + }, + container: { + backgroundColor: '#ecf0f1', + flex: 1, + justifyContent: 'center', + padding: 8, + }, + paragraph: { + fontSize: 18, + fontWeight: 'bold', + margin: 24, + textAlign: 'center', + }, +}); + +export default App; diff --git a/Hcaptcha.d.ts b/Hcaptcha.d.ts index d29298d..8268b70 100644 --- a/Hcaptcha.d.ts +++ b/Hcaptcha.d.ts @@ -2,11 +2,17 @@ import React from 'react'; import { StyleProp, ViewStyle } from 'react-native'; import { WebViewMessageEvent } from 'react-native-webview'; -type HcaptchaProps = { +export type HCaptchaVerifyParams = { + rqdata?: string; + phonePrefix?: string; + phoneNumber?: string; +}; + +export type HcaptchaProps = { /** * The callback function that runs after receiving a response, error, or when user cancels. */ - onMessage?: (event: CustomWebViewMessageEvent) => void; + onMessage: (event: CustomWebViewMessageEvent) => void; /** * The size of the checkbox. */ @@ -52,6 +58,10 @@ type HcaptchaProps = { * Hcaptcha execution options (see Enterprise docs) */ rqdata?: string; + /** + * Verification payload overrides. Values here take precedence over deprecated top-level fields. + */ + verifyParams?: HCaptchaVerifyParams; /** * Enable / Disable sentry error reporting. */ @@ -99,6 +109,10 @@ type HcaptchaProps = { * Optional full phone number in E.164 format ("+44123..."), for use in MFA. */ phoneNumber?: string; + /** + * Enable automatic user journey injection. + */ + userJourney?: boolean; } interface CustomWebViewMessageEvent extends WebViewMessageEvent { diff --git a/Hcaptcha.js b/Hcaptcha.js index dc5615e..f4cb9d3 100644 --- a/Hcaptcha.js +++ b/Hcaptcha.js @@ -1,10 +1,16 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import WebView from 'react-native-webview'; -import { ActivityIndicator, Linking, StyleSheet, TouchableWithoutFeedback, View } from 'react-native'; +import { ActivityIndicator, Linking, Platform, StyleSheet, TouchableWithoutFeedback, View } from 'react-native'; import ReactNativeVersion from 'react-native/Libraries/Core/ReactNativeVersion'; import md5 from './md5'; import hcaptchaPackage from './package.json'; +import { + clearJourneyEvents, + disableJourneyConsumer, + enableJourneyConsumer, + peekJourneyEvents, +} from './journey'; const patchPostMessageJsCode = `(${String(function () { var originalPostMessage = window.ReactNativeWebView.postMessage; @@ -20,6 +26,8 @@ const patchPostMessageJsCode = `(${String(function () { window.ReactNativeWebView.postMessage = patchedPostMessage; })})();`; +const HCAPTCHA_READY_EVENT = '__hcaptcha_ready__'; + const serializeForInlineScript = (value) => JSON.stringify(value) .replace(/ { return value === 'checkbox' ? 'normal' : value; }; +const getVersionPart = (value) => ( + typeof value === 'number' && Number.isFinite(value) && value >= 0 && value < 100 + ? value + : null +); + +const parseReactNativeVersion = (value) => { + const candidate = value && typeof value === 'object' && value.version ? value.version : value; + const major = getVersionPart(candidate?.major); + const minor = getVersionPart(candidate?.minor); + const patch = getVersionPart(candidate?.patch); + + if (major == null || minor == null || patch == null) { + return null; + } + + return { major, minor, patch }; +}; + +const getReactNativeVersion = (value = Platform?.constants?.reactNativeVersion) => + parseReactNativeVersion(value) || parseReactNativeVersion(ReactNativeVersion?.version); + +const buildDebugInfo = (debug, reactNativeVersion = Platform?.constants?.reactNativeVersion) => { + const result = { ...(debug || {}) }; + + try { + const version = getReactNativeVersion(reactNativeVersion); + if (version) { + result[`rnver_${version.major}_${version.minor}_${version.patch}`] = true; + } + result['dep_' + md5(Object.keys(global).join(''))] = true; + result['sdk_' + hcaptchaPackage.version.toString().replace(/\./g, '_')] = true; + } catch (e) { + console.log(e); + } + + return result; +}; + +const buildVerifyData = ({ + phoneNumber, + phonePrefix, + rqdata, + userJourney, + verifyParams, +}) => { + const normalizedVerifyParams = verifyParams || {}; + const data = {}; + const finalRqdata = normalizedVerifyParams.rqdata ?? rqdata ?? undefined; + const finalPhonePrefix = normalizedVerifyParams.phonePrefix ?? phonePrefix ?? undefined; + const finalPhoneNumber = normalizedVerifyParams.phoneNumber ?? phoneNumber ?? undefined; + + if (finalRqdata) { + data.rqdata = finalRqdata; + } + if (finalPhonePrefix) { + data.mfa_phoneprefix = finalPhonePrefix; + } + if (finalPhoneNumber) { + data.mfa_phone = finalPhoneNumber; + } + if (Array.isArray(userJourney) && userJourney.length > 0) { + data.userjourney = userJourney; + } + + return data; +}; + +const buildVerifyInjectionScript = (payload, resetFirst = false) => + `try { ${resetFirst ? 'reset(); ' : ''}setData(${serializeForInlineScript(payload)}); execute(); } catch (e) { window.ReactNativeWebView.postMessage((e && e.name) || 'error'); } true;`; + const buildHcaptchaApiUrl = (jsSrc, siteKey, hl, theme, host, sentry, endpoint, assethost, imghost, reportapi, orientation) => { var url = `${jsSrc || 'https://hcaptcha.com/1/api.js'}?render=explicit&onload=onloadCallback`; @@ -100,6 +179,8 @@ const buildHcaptchaApiUrl = (jsSrc, siteKey, hl, theme, host, sentry, endpoint, * @param {string} orientation: hCaptcha challenge orientation * @param {string} phonePrefix: Optional phone country calling code (without '+'), e.g., "44". Used in MFA flows. * @param {string} phoneNumber: Optional full phone number in E.164 format ("+44123..."), for use in MFA. + * @param {boolean} userJourney: Enable automatic user journey injection + * @param {object} verifyParams: Verification payload overrides */ const Hcaptcha = ({ onMessage, @@ -125,10 +206,15 @@ const Hcaptcha = ({ orientation, phonePrefix, phoneNumber, + userJourney, + verifyParams, + _journeyManagedExternally, }) => { const tokenTimeout = 120000; const loadingTimeout = 15000; const [isLoading, setIsLoading] = useState(true); + const journeyEnabled = Boolean(userJourney); + const hasJourneyConsumerRef = useRef(false); const normalizedTheme = useMemo(() => normalizeTheme(theme), [theme]); const normalizedSize = useMemo(() => normalizeSize(size), [size]); const apiUrl = useMemo( @@ -137,19 +223,7 @@ const Hcaptcha = ({ ); const debugInfo = useMemo( - () => { - var result = debug || {}; - try { - const {major, minor, patch} = ReactNativeVersion.version; - result[`rnver_${major}_${minor}_${patch}`] = true; - result['dep_' + md5(Object.keys(global).join(''))] = true; - result['sdk_' + hcaptchaPackage.version.toString().replace(/\./g, '_')] = true; - } catch (e) { - console.log(e); - } finally { - return result; - } - }, + () => buildDebugInfo(debug), [debug] ); @@ -188,23 +262,27 @@ const Hcaptcha = ({ script.src = hcaptchaConfig.apiUrl; document.head.appendChild(script); }; + var hcaptchaWidgetId = null; + var setData = function(data) { + hcaptcha.setData(hcaptchaWidgetId, data || {}); + }; + var execute = function() { + hcaptcha.execute(hcaptchaWidgetId); + }; + var reset = function() { + hcaptcha.reset(hcaptchaWidgetId); + }; var onloadCallback = function() { try { console.log("challenge onload starting"); - hcaptcha.render("hcaptcha-container", getRenderConfig(hcaptchaConfig.siteKey, hcaptchaConfig.theme, hcaptchaConfig.size)); + hcaptchaWidgetId = hcaptcha.render("hcaptcha-container", getRenderConfig(hcaptchaConfig.siteKey, hcaptchaConfig.theme, hcaptchaConfig.size)); + window.ReactNativeWebView.postMessage("${HCAPTCHA_READY_EVENT}"); // have loaded by this point; render is sync. console.log("challenge render complete"); } catch (e) { console.log("challenge failed to render:", e); window.ReactNativeWebView.postMessage(e.name); } - try { - console.log("showing challenge"); - hcaptcha.execute(getExecuteOpts()); - } catch (e) { - console.log("failed to show challenge:", e); - window.ReactNativeWebView.postMessage(e.name); - } }; var onDataCallback = function(response) { window.ReactNativeWebView.postMessage(response); @@ -239,23 +317,6 @@ const Hcaptcha = ({ } return config; }; - const getExecuteOpts = function() { - var opts = {}; - const rqdata = hcaptchaConfig.rqdata; - const phonePrefix = hcaptchaConfig.phonePrefix; - const phoneNumber = hcaptchaConfig.phoneNumber; - - if (rqdata) { - opts.rqdata = rqdata; - } - if (phonePrefix) { - opts.mfa_phoneprefix = phonePrefix; - } - if (phoneNumber) { - opts.mfa_phone = phoneNumber; - } - return opts; - }; loadApiScript(); @@ -266,6 +327,22 @@ const Hcaptcha = ({ [serializedWebViewConfig] ); + useEffect(() => { + if (_journeyManagedExternally || !journeyEnabled || hasJourneyConsumerRef.current) { + return undefined; + } + + enableJourneyConsumer(); + hasJourneyConsumerRef.current = true; + + return () => { + if (hasJourneyConsumerRef.current) { + disableJourneyConsumer(); + hasJourneyConsumerRef.current = false; + } + }; + }, [_journeyManagedExternally, journeyEnabled]); + useEffect(() => { const timeoutId = setTimeout(() => { if (isLoading) { @@ -277,6 +354,19 @@ const Hcaptcha = ({ }, [isLoading, onMessage]); const webViewRef = useRef(null); + const injectVerifyData = (resetFirst = false) => { + if (!webViewRef.current) { + return; + } + + webViewRef.current.injectJavaScript(buildVerifyInjectionScript(buildVerifyData({ + phoneNumber, + phonePrefix, + rqdata, + userJourney: journeyEnabled ? peekJourneyEvents() : undefined, + verifyParams, + }), resetFirst)); + }; // This shows ActivityIndicator till webview loads hCaptcha images const renderLoading = () => ( @@ -288,9 +378,7 @@ const Hcaptcha = ({ ); const reset = () => { - if (webViewRef.current) { - webViewRef.current.injectJavaScript('onloadCallback();'); - } + injectVerifyData(true); }; return ( @@ -319,6 +407,11 @@ const Hcaptcha = ({ }} mixedContentMode={'always'} onMessage={(e) => { + if (e.nativeEvent.data === HCAPTCHA_READY_EVENT) { + injectVerifyData(); + return; + } + e.reset = reset; e.success = true; if (e.nativeEvent.data === 'open') { @@ -326,6 +419,9 @@ const Hcaptcha = ({ } else if (e.nativeEvent.data.length > 35) { const expiredTokenTimerId = setTimeout(() => onMessage({ nativeEvent: { data: 'expired' }, success: false, reset }), tokenTimeout); e.markUsed = () => clearTimeout(expiredTokenTimerId); + if (journeyEnabled) { + clearJourneyEvents(); + } } else /* error */ { e.success = false; } @@ -360,3 +456,4 @@ const styles = StyleSheet.create({ }); export default Hcaptcha; +export { buildDebugInfo, buildVerifyData, HCAPTCHA_READY_EVENT }; diff --git a/README.md b/README.md index 079d5fd..7ae6765 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,165 @@ This means that if you are an Enterprise user with a 99.9% passive or purely pas Also, please note the following special message strings that can be returned via `onMessage` for [error cases](https://docs.hcaptcha.com/configuration#error-codes) -The even returned by `onMessage` with `success === true` will be a passcode. +The event returned by `onMessage` with `success === true` will be a passcode. + +In practice, you should always provide `onMessage`. Without it, your app will not receive tokens, errors, or cancel events. + +### Basic modal usage + +```js +import React, { useRef } from 'react'; +import ConfirmHcaptcha from '@hcaptcha/react-native-hcaptcha'; + +export default function Example() { + const captchaRef = useRef(null); + + const onMessage = (event) => { + if (!event?.nativeEvent?.data) { + return; + } + + if (event.success) { + const token = event.nativeEvent.data; + event.markUsed?.(); + captchaRef.current?.hide(); + return; + } + + if (event.nativeEvent.data === 'challenge-closed') { + captchaRef.current?.hide(); + } + }; + + return ( + + ); +} +``` + +### Verification payloads with `verifyParams` + +Use `verifyParams` for request data passed to `hcaptcha.setData(...)` immediately before each verification attempt. + +```js + +``` + +Legacy top-level `rqdata`, `phonePrefix`, and `phoneNumber` props still work, but `verifyParams` takes precedence and should be preferred for new code. + +### User Journeys (Enterprise) + +Journey capture is opt-in at the captcha level through `userJourney={true}`. + +By default, a captcha instance with `userJourney={true}` will: + +- attach the current shared journey buffer to its verification payload as `userjourney` +- initialize the journey runtime on first use if needed +- enable automatic app-wide touch capture while at least one `userJourney` captcha instance is mounted + +Use `initJourneyTracking()` when you want to: + +- start journey tracking before the captcha component mounts +- register a React Navigation container for automatic screen tracking +- enable runtime debug/stats hooks +- disable automatic touch capture with `touchCapture: false` while keeping the rest of User Journeys enabled + +```js +import ConfirmHcaptcha, { + initJourneyTracking, + registerJourneyNavigationContainer, +} from '@hcaptcha/react-native-hcaptcha'; + +initJourneyTracking({ + touchCapture: true, +}); + +// If you use React Navigation, register the container once the ref exists. +registerJourneyNavigationContainer(navigationRef); + + +``` + +If you are already holding a navigation container ref at init time, you can pass it directly: + +```js +initJourneyTracking({ + navigationContainerRef: navigationRef, +}); +``` + +If you want to use the inline component with journeys enabled: + +```js +import { Hcaptcha, initJourneyTracking } from '@hcaptcha/react-native-hcaptcha'; + +initJourneyTracking(); + + +``` + +If you want `userJourney` verification payloads and navigation tracking, but do not want automatic app-wide touch capture: + +```js +initJourneyTracking({ + navigationContainerRef: navigationRef, + touchCapture: false, +}); +``` + +Automatic journey capture currently records: + +- React Navigation screen transitions +- Basic taps +- Basic drag gestures +- Scroll-like gestures inferred from axis-dominant touch movement + +Automatic journey capture intentionally does **not** record: + +- text input contents +- search queries +- text lengths or typed characters +- visible button text as control identifiers + +### Inline component usage + +If you want to manage the surrounding UI yourself, use the inline component: + +```js +import { Hcaptcha } from '@hcaptcha/react-native-hcaptcha'; + + +``` ### Handling the post-issuance expiration lifecycle @@ -77,7 +235,9 @@ Once you've utilized hCaptcha's token, call `markUsed` on the event object in `o ### Handling errors and retry -If your app encounters an `error` event, you can reset the hCaptcha SDK flow by calling `event.reset()`` to perform another attempt at verification. +If your app encounters an `error` event, you can reset the hCaptcha SDK flow by calling `event.reset()` to perform another attempt at verification. + +`event.reset()` rebuilds the verification payload from the current props and the current buffered journey immediately before retrying. ## Dependencies @@ -124,9 +284,25 @@ Otherwise, you should pass in the preferred device locale, e.g. fetched from `ge - The UI defaults to the "invisible" mode of the JS SDK, i.e. no checkbox is displayed. - If you need to test displaying the challenge modal, set your sitekey to "Always Challenge" mode in the hCaptcha dashboard. -- You can `import Hcaptcha from '@hcaptcha/react-native-hcaptcha/Hcaptcha';` to customize the UI yourself. +- You can `import { Hcaptcha } from '@hcaptcha/react-native-hcaptcha';` to customize the UI yourself. - hCaptcha loading is restricted to a 15-second timeout; an `error` will be sent via `onMessage` if it fails to load due to network issues. +## Journey Capture Caveats + +- Call `initJourneyTracking()` once and do it early. Events that happen before initialization are not captured. +- Automatic app-wide touch capture is enabled by default while at least one captcha instance with `userJourney={true}` is mounted. +- Automatic touch capture uses React Native's public `AppRegistry.setWrapperComponentProvider(...)` API. +- This SDK composes with wrapper providers registered after it initializes by intercepting future calls to `setWrapperComponentProvider(...)`. +- React Native does not expose a getter for wrapper providers that were already registered before this SDK initialized. If another library installs a wrapper provider first, call `initJourneyTracking()` earlier or use `touchCapture: false` to avoid conflicts. +- If you start with `touchCapture: false`, this SDK deliberately does not patch `AppRegistry`. If another library registers a wrapper provider during that disabled window and you later re-enable touch capture, hCaptcha cannot recover that earlier provider automatically. If you may toggle touch capture on later, initialize hCaptcha first or keep `touchCapture` enabled from the start. +- Navigation capture is only automatic when you register a React Navigation container ref through `initJourneyTracking({ navigationContainerRef })` or `registerJourneyNavigationContainer(ref)`. +- `userJourney={true}` controls whether the current buffered journey is attached to that captcha's verification request. It also enables automatic touch capture by default unless you disable that with `initJourneyTracking({ touchCapture: false })`. +- The journey buffer is app-global and shared across captcha instances in the same process. This is intentional for the single-app, single-user case. +- `ConfirmHcaptcha.stopEvents()` clears the shared buffered journey and detaches that `ConfirmHcaptcha` instance from journey attachment until you explicitly reconfigure it, for example by toggling `userJourney` off and back on. +- Global journey capture can continue after `stopEvents()` if journey tracking was initialized, so later app events may still accumulate in the shared buffer for other consumers. +- Successful verification clears the buffered journey. Error and cancel flows do not. +- Automatic control identifiers are best-effort and prefer non-content metadata like `nativeID`, `testID`, and `accessibilityLabel`. If none are available, events may fall back to native view tags or `"unknown"`. + ## MFA Phone Support The SDK supports phone prefix and phone number parameters for MFA (Multi-Factor Authentication) flows. You can pass these parameters as props: @@ -147,13 +323,27 @@ The SDK supports phone prefix and phone number parameters for MFA (Multi-Factor /> ``` +For new code, prefer: + +```js + +``` + ## Properties | **Name** | **Type** | **Description** | |:---|:---|:---| | siteKey _(required)_ | string | The hCaptcha siteKey | | size | string | The size of the widget, can be 'invisible', 'compact' or 'normal'. `checkbox` is also accepted as a legacy alias for `normal`. Default: 'invisible' | -| onMessage | Function (see [here](https://github.com/react-native-webview/react-native-webview/blob/master/src/WebViewTypes.ts#L299)) | The callback function that runs after receiving a response, error, or when user cancels. | +| onMessage | Function (see [here](https://github.com/react-native-webview/react-native-webview/blob/master/src/WebViewTypes.ts#L299)) | Required. Runs after receiving a response, error, or when user cancels. | | languageCode | string | Default language for hCaptcha; overrides phone defaults. A complete list of supported languages and their codes can be found [here](https://docs.hcaptcha.com/languages/) | | showLoading | boolean | Whether to show a loading indicator while the hCaptcha web content loads | | closableLoading | boolean | Allow user to cancel hcaptcha during loading by touch loader overlay | @@ -161,6 +351,8 @@ The SDK supports phone prefix and phone number parameters for MFA (Multi-Factor | backgroundColor | string | The background color code that will be applied to the main HTML element | | theme | string\|object | The theme can be 'light', 'dark', 'contrast' or a custom theme object (see Enterprise docs) | | rqdata | string | **Deprecated**: Use `rqdata` in `HCaptchaVerifyParams` instead. Will be removed in future releases. See Enterprise docs. | +| verifyParams | object | Verification payload overrides passed to `hcaptcha.setData(...)` immediately before verification. Supports `rqdata`, `phonePrefix`, and `phoneNumber`. | +| userJourney | boolean | When `true`, attaches the current shared journey buffer to the verification payload as `userjourney`. It also enables automatic touch capture by default while a `userJourney` captcha instance is mounted. Use `initJourneyTracking({ touchCapture: false })` to keep User Journeys enabled without automatic touch capture. | | sentry | boolean | sentry error reporting (see Enterprise docs) | | jsSrc | string | The url of api.js. Default: https://js.hcaptcha.com/1/api.js (Override only if using first-party hosting feature.) | | endpoint | string | Point hCaptcha JS Ajax Requests to alternative API Endpoint. Default: https://api.hcaptcha.com (Override only if using first-party hosting feature.) | @@ -177,6 +369,27 @@ The SDK supports phone prefix and phone number parameters for MFA (Multi-Factor | phonePrefix | string | Optional phone country calling code (without '+'), e.g., "44". Used in MFA flows. | | phoneNumber | string | Optional full phone number in E.164 format ("+44123..."), for use in MFA. | +## Journey API + +### `initJourneyTracking(options?)` + +Installs automatic journey collection once for the app process. + +Options: + +- `navigationContainerRef`: optional React Navigation container ref to register immediately +- `touchCapture`: optional boolean, defaults to `true`. When `false`, keeps User Journeys enabled but disables automatic app-wide touch capture +- `debug`: optional boolean to log emitted journey events to `console.debug` +- `onStats`: optional callback for runtime diagnostics in tests or app instrumentation + +### `registerJourneyNavigationContainer(ref)` + +Registers or replaces the React Navigation container used for automatic screen tracking. + +### `ConfirmHcaptcha.stopEvents()` + +Clears the current shared journey buffer and detaches that `ConfirmHcaptcha` instance from journey attachment until it is explicitly reconfigured. A simple way to re-enable it is to toggle `userJourney` off and back on. + ## Status diff --git a/__tests__/ConfirmHcaptcha.test.js b/__tests__/ConfirmHcaptcha.test.js index ccfeba9..cb75a00 100644 --- a/__tests__/ConfirmHcaptcha.test.js +++ b/__tests__/ConfirmHcaptcha.test.js @@ -4,6 +4,12 @@ import { Modal, SafeAreaView } from 'react-native'; import Hcaptcha from '../Hcaptcha'; import ConfirmHcaptcha from '../index'; +import { + __unsafeResetJourneyRuntime, + emitJourneyEvent, + initJourneyTracking, + peekJourneyEvents, +} from '../journey'; describe('ConfirmHcaptcha', () => { const getModal = (component) => component.UNSAFE_getByType(Modal); @@ -13,6 +19,7 @@ describe('ConfirmHcaptcha', () => { beforeEach(() => { jest.restoreAllMocks(); jest.clearAllMocks(); + __unsafeResetJourneyRuntime(); }); it('renders ConfirmHcaptcha with minimum props after show() is called', () => { @@ -258,6 +265,186 @@ describe('ConfirmHcaptcha', () => { expect(onMessage).toHaveBeenCalledWith({ nativeEvent: { data: 'cancel' } }); }); + it('stopEvents() clears the current shared buffer without tearing down global capture', () => { + initJourneyTracking(); + const component = render( + + ); + const instance = getInstance(component); + + act(() => { + instance.show(); + }); + + emitJourneyEvent('click', 'View', { id: 'before-stop', ac: 'tap' }); + expect(peekJourneyEvents()).toHaveLength(1); + + act(() => { + instance.stopEvents(); + }); + + expect(getHcaptchaChild(component).props.userJourney).toBe(false); + expect(peekJourneyEvents()).toEqual([]); + + emitJourneyEvent('click', 'View', { id: 'after-stop', ac: 'tap' }); + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'click', + v: 'View', + m: { id: 'after-stop', ac: 'tap' }, + }), + ]); + }); + + it('re-enables journeys after stopEvents() when the prop is explicitly reconfigured', () => { + initJourneyTracking(); + const component = render( + + ); + const instance = getInstance(component); + + act(() => { + instance.show(); + }); + + expect(getHcaptchaChild(component).props.userJourney).toBe(true); + + act(() => { + instance.stopEvents(); + }); + + expect(getHcaptchaChild(component).props.userJourney).toBe(false); + + component.rerender( + + ); + component.rerender( + + ); + + expect(getHcaptchaChild(component).props.userJourney).toBe(true); + }); + + it('keeps the shared buffer active when one of two simultaneous consumers unmounts', () => { + const onStats = jest.fn(); + const firstRef = React.createRef(); + const secondRef = React.createRef(); + + initJourneyTracking({ onStats }); + const component = render( + <> + + + + ); + + expect(onStats).toHaveBeenLastCalledWith(expect.objectContaining({ + activeConsumers: 2, + })); + + emitJourneyEvent('click', 'View', { id: 'before-unmount', ac: 'tap' }); + + component.rerender( + + ); + + expect(onStats).toHaveBeenLastCalledWith(expect.objectContaining({ + activeConsumers: 1, + })); + + emitJourneyEvent('click', 'View', { id: 'after-unmount', ac: 'tap' }); + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + m: { id: 'before-unmount', ac: 'tap' }, + }), + expect.objectContaining({ + m: { id: 'after-unmount', ac: 'tap' }, + }), + ]); + }); + + it('clears the shared buffer when one of two simultaneous consumers calls stopEvents, but capture continues for the other', () => { + const onStats = jest.fn(); + const firstRef = React.createRef(); + const secondRef = React.createRef(); + + initJourneyTracking({ onStats }); + render( + <> + + + + ); + + emitJourneyEvent('click', 'View', { id: 'before-stop', ac: 'tap' }); + + act(() => { + firstRef.current.stopEvents(); + }); + + expect(onStats).toHaveBeenLastCalledWith(expect.objectContaining({ + activeConsumers: 1, + })); + expect(peekJourneyEvents()).toEqual([]); + + emitJourneyEvent('click', 'View', { id: 'after-stop', ac: 'tap' }); + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + m: { id: 'after-stop', ac: 'tap' }, + }), + ]); + }); + it('backdrop and back-button handlers call hide(source) and emit cancel events', () => { const onMessage = jest.fn(); const component = render( diff --git a/__tests__/Hcaptcha.test.js b/__tests__/Hcaptcha.test.js index a43171d..781cd81 100644 --- a/__tests__/Hcaptcha.test.js +++ b/__tests__/Hcaptcha.test.js @@ -3,7 +3,8 @@ import vm from 'vm'; import { act, render, waitFor } from '@testing-library/react-native'; import { ActivityIndicator, Linking, TouchableWithoutFeedback } from 'react-native'; -import Hcaptcha from '../Hcaptcha'; +import Hcaptcha, { HCAPTCHA_READY_EVENT } from '../Hcaptcha'; +import { __unsafeResetJourneyRuntime, emitJourneyEvent, initJourneyTracking, peekJourneyEvents } from '../journey'; import { getLastInjectJavaScriptMock, resetWebViewMockState, @@ -33,6 +34,7 @@ describe('Hcaptcha', () => { jest.clearAllMocks(); jest.useRealTimers(); resetWebViewMockState(); + __unsafeResetJourneyRuntime(); }); it('renders Hcaptcha with minimum props', () => { @@ -97,9 +99,9 @@ describe('Hcaptcha', () => { expect(config.rqdata).toBe('{"some":"data"}'); expect(config.phonePrefix).toBe('44'); expect(config.phoneNumber).toBe('+441234567890'); + expect(config.verifyData).toBeUndefined(); expect(config.debugInfo).toMatchObject({ customDebug: 'enabled', - rnver_0_0_0: true, 'dep_mocked-md5': true, sdk_3_0_2: true, }); @@ -157,7 +159,7 @@ describe('Hcaptcha', () => { }); }); - it('loads the external api script dynamically and preserves the onload render/execute flow', () => { + it('loads the external api script dynamically and signals RN when the widget is ready', () => { const component = render( { }, }, hcaptcha: { - render: renderMock, execute: executeMock, + render: renderMock, + setData: jest.fn(), }, window: null, }; @@ -227,11 +230,9 @@ describe('Hcaptcha', () => { 'chalexpired-callback': expect.any(Function), 'error-callback': expect.any(Function), })); - expect(executeMock).toHaveBeenCalledWith({ - rqdata: '{"some":"data"}', - mfa_phoneprefix: '44', - mfa_phone: '+441234567890', - }); + expect(postMessageMock).toHaveBeenCalledWith(HCAPTCHA_READY_EVENT); + expect(sandbox.hcaptcha.setData).not.toHaveBeenCalled(); + expect(executeMock).not.toHaveBeenCalled(); const renderConfig = renderMock.mock.calls[0][1]; renderConfig['open-callback'](); @@ -274,20 +275,16 @@ describe('Hcaptcha', () => { const query = getApiQueryParams(component); expect(html).toContain('var hcaptchaConfig = '); - expect(html).toContain('const rqdata = hcaptchaConfig.rqdata;'); - expect(html).toContain('const phonePrefix = hcaptchaConfig.phonePrefix;'); - expect(html).toContain('const phoneNumber = hcaptchaConfig.phoneNumber;'); + expect(html).toContain('hcaptcha.setData(hcaptchaWidgetId, data || {});'); + expect(html).toContain(`window.ReactNativeWebView.postMessage("${HCAPTCHA_READY_EVENT}");`); expect(html).not.toContain(''); - expect(html).not.toContain('const rqdata = ";window.ReactNativeWebView.postMessage("rqdata")'); expect(html).toContain('\\u003c/script\\u003e\\u003cscript\\u003ealert(\\"site\\")\\u003c/script\\u003e'); expect(html).toContain('\\u003c/script\\u003e\\u003cscript\\u003ealert(\\"debug\\")\\u003c/script\\u003e'); expect(config.siteKey).toBe('site"'); expect(config.backgroundColor).toBe('red\';window.ReactNativeWebView.postMessage("bg");//'); - expect(config.rqdata).toBe('";window.ReactNativeWebView.postMessage("rqdata");//'); - expect(config.phonePrefix).toBe('44";window.ReactNativeWebView.postMessage("prefix");//'); - expect(config.phoneNumber).toBe('+44123\');window.ReactNativeWebView.postMessage("phone");//'); + expect(config.verifyData).toBeUndefined(); expect(config.theme).toEqual(theme); expect(config.debugInfo['']).toBe(''); @@ -424,7 +421,8 @@ describe('Hcaptcha', () => { const [{ reset, markUsed }] = onMessage.mock.calls[0]; reset(); - expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith('onloadCallback();'); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('reset(); setData(')); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('execute();')); act(() => { getWebView(component).props.onMessage({ nativeEvent: { data: 'open' } }); @@ -442,6 +440,28 @@ describe('Hcaptcha', () => { })); }); + it('injects fresh verify data only after the widget signals readiness', () => { + initJourneyTracking(); + emitJourneyEvent('click', 'View', { id: 'before-ready', ac: 'tap', x: 1, y: 2 }); + const component = render( + + ); + + emitJourneyEvent('click', 'View', { id: 'after-mount', ac: 'tap', x: 3, y: 4 }); + + act(() => { + getWebView(component).props.onMessage({ nativeEvent: { data: HCAPTCHA_READY_EVENT } }); + }); + + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"id":"before-ready"')); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"id":"after-mount"')); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.not.stringContaining('reset();')); + }); + it('emits an expired message when a forwarded token is not marked used', async () => { jest.useFakeTimers(); const onMessage = jest.fn(); @@ -495,6 +515,87 @@ describe('Hcaptcha', () => { }); }); + it('uses verifyParams over legacy props and injects buffered journey data', () => { + initJourneyTracking(); + emitJourneyEvent('click', 'View', { id: 'screen', ac: 'tap', x: 1, y: 2 }); + + const component = render( + + ); + + expect(getSerializedConfig(component).verifyData).toBeUndefined(); + + act(() => { + getWebView(component).props.onMessage({ nativeEvent: { data: HCAPTCHA_READY_EVENT } }); + }); + + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"rqdata":"preferred"')); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"mfa_phoneprefix":"44"')); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"mfa_phone":"+44123"')); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"userjourney"')); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"id":"screen"')); + }); + + it('injects legacy rqdata and phone fields into the final verify payload', () => { + const component = render( + + ); + + act(() => { + getWebView(component).props.onMessage({ nativeEvent: { data: HCAPTCHA_READY_EVENT } }); + }); + + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"rqdata":"{\\"some\\":\\"data\\"}"')); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"mfa_phoneprefix":"44"')); + expect(getLastInjectJavaScriptMock()).toHaveBeenCalledWith(expect.stringContaining('"mfa_phone":"+441234567890"')); + }); + + it('preserves buffered journey events after error and cancel messages', () => { + initJourneyTracking(); + emitJourneyEvent('click', 'View', { id: 'preserved', ac: 'tap' }); + const onMessage = jest.fn(); + const component = render( + + ); + + act(() => { + getWebView(component).props.onMessage({ nativeEvent: { data: 'challenge-closed' } }); + getWebView(component).props.onMessage({ nativeEvent: { data: 'webview-error' } }); + }); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'click', + v: 'View', + m: { id: 'preserved', ac: 'tap' }, + }), + ]); + }); + it('opens hcaptcha links externally and blocks navigation in the WebView', () => { const openURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(true); const component = render( diff --git a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap index dfec965..9d4839d 100644 --- a/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap +++ b/__tests__/__snapshots__/ConfirmHcaptcha.test.js.snap @@ -123,23 +123,27 @@ exports[`ConfirmHcaptcha renders ConfirmHcaptcha with minimum props after show() script.src = hcaptchaConfig.apiUrl; document.head.appendChild(script); }; + var hcaptchaWidgetId = null; + var setData = function(data) { + hcaptcha.setData(hcaptchaWidgetId, data || {}); + }; + var execute = function() { + hcaptcha.execute(hcaptchaWidgetId); + }; + var reset = function() { + hcaptcha.reset(hcaptchaWidgetId); + }; var onloadCallback = function() { try { console.log("challenge onload starting"); - hcaptcha.render("hcaptcha-container", getRenderConfig(hcaptchaConfig.siteKey, hcaptchaConfig.theme, hcaptchaConfig.size)); + hcaptchaWidgetId = hcaptcha.render("hcaptcha-container", getRenderConfig(hcaptchaConfig.siteKey, hcaptchaConfig.theme, hcaptchaConfig.size)); + window.ReactNativeWebView.postMessage("__hcaptcha_ready__"); // have loaded by this point; render is sync. console.log("challenge render complete"); } catch (e) { console.log("challenge failed to render:", e); window.ReactNativeWebView.postMessage(e.name); } - try { - console.log("showing challenge"); - hcaptcha.execute(getExecuteOpts()); - } catch (e) { - console.log("failed to show challenge:", e); - window.ReactNativeWebView.postMessage(e.name); - } }; var onDataCallback = function(response) { window.ReactNativeWebView.postMessage(response); @@ -174,23 +178,6 @@ exports[`ConfirmHcaptcha renders ConfirmHcaptcha with minimum props after show() } return config; }; - const getExecuteOpts = function() { - var opts = {}; - const rqdata = hcaptchaConfig.rqdata; - const phonePrefix = hcaptchaConfig.phonePrefix; - const phoneNumber = hcaptchaConfig.phoneNumber; - - if (rqdata) { - opts.rqdata = rqdata; - } - if (phonePrefix) { - opts.mfa_phoneprefix = phonePrefix; - } - if (phoneNumber) { - opts.mfa_phone = phoneNumber; - } - return opts; - }; loadApiScript(); diff --git a/__tests__/__snapshots__/Hcaptcha.test.js.snap b/__tests__/__snapshots__/Hcaptcha.test.js.snap index c4160c6..be1afb0 100644 --- a/__tests__/__snapshots__/Hcaptcha.test.js.snap +++ b/__tests__/__snapshots__/Hcaptcha.test.js.snap @@ -57,23 +57,27 @@ exports[`Hcaptcha renders Hcaptcha with minimum props 1`] = ` script.src = hcaptchaConfig.apiUrl; document.head.appendChild(script); }; + var hcaptchaWidgetId = null; + var setData = function(data) { + hcaptcha.setData(hcaptchaWidgetId, data || {}); + }; + var execute = function() { + hcaptcha.execute(hcaptchaWidgetId); + }; + var reset = function() { + hcaptcha.reset(hcaptchaWidgetId); + }; var onloadCallback = function() { try { console.log("challenge onload starting"); - hcaptcha.render("hcaptcha-container", getRenderConfig(hcaptchaConfig.siteKey, hcaptchaConfig.theme, hcaptchaConfig.size)); + hcaptchaWidgetId = hcaptcha.render("hcaptcha-container", getRenderConfig(hcaptchaConfig.siteKey, hcaptchaConfig.theme, hcaptchaConfig.size)); + window.ReactNativeWebView.postMessage("__hcaptcha_ready__"); // have loaded by this point; render is sync. console.log("challenge render complete"); } catch (e) { console.log("challenge failed to render:", e); window.ReactNativeWebView.postMessage(e.name); } - try { - console.log("showing challenge"); - hcaptcha.execute(getExecuteOpts()); - } catch (e) { - console.log("failed to show challenge:", e); - window.ReactNativeWebView.postMessage(e.name); - } }; var onDataCallback = function(response) { window.ReactNativeWebView.postMessage(response); @@ -108,23 +112,6 @@ exports[`Hcaptcha renders Hcaptcha with minimum props 1`] = ` } return config; }; - const getExecuteOpts = function() { - var opts = {}; - const rqdata = hcaptchaConfig.rqdata; - const phonePrefix = hcaptchaConfig.phonePrefix; - const phoneNumber = hcaptchaConfig.phoneNumber; - - if (rqdata) { - opts.rqdata = rqdata; - } - if (phonePrefix) { - opts.mfa_phoneprefix = phonePrefix; - } - if (phoneNumber) { - opts.mfa_phone = phoneNumber; - } - return opts; - }; loadApiScript(); diff --git a/__tests__/buildVerifyData.test.js b/__tests__/buildVerifyData.test.js new file mode 100644 index 0000000..792bc56 --- /dev/null +++ b/__tests__/buildVerifyData.test.js @@ -0,0 +1,82 @@ +import { buildDebugInfo, buildVerifyData } from '../Hcaptcha'; + +describe('buildVerifyData', () => { + it('maps legacy props to the final wire keys', () => { + expect(buildVerifyData({ + rqdata: '{"some":"data"}', + phonePrefix: '44', + phoneNumber: '+441234567890', + })).toEqual({ + rqdata: '{"some":"data"}', + mfa_phoneprefix: '44', + mfa_phone: '+441234567890', + }); + }); + + it('prefers verifyParams over legacy props', () => { + expect(buildVerifyData({ + rqdata: 'legacy-rqdata', + phonePrefix: '11', + phoneNumber: '+111', + verifyParams: { + rqdata: 'preferred-rqdata', + phonePrefix: '44', + phoneNumber: '+44123', + }, + })).toEqual({ + rqdata: 'preferred-rqdata', + mfa_phoneprefix: '44', + mfa_phone: '+44123', + }); + }); + + it('backfills missing verifyParams fields from legacy props', () => { + expect(buildVerifyData({ + rqdata: 'legacy-rqdata', + phonePrefix: '44', + phoneNumber: '+44123', + verifyParams: { + rqdata: 'preferred-rqdata', + }, + })).toEqual({ + rqdata: 'preferred-rqdata', + mfa_phoneprefix: '44', + mfa_phone: '+44123', + }); + }); + + it('includes userjourney only when events exist', () => { + expect(buildVerifyData({ + userJourney: [], + })).toEqual({}); + + expect(buildVerifyData({ + userJourney: [ + { ts: 123, k: 'click', v: 'View', m: { id: 'screen', ac: 'tap' } }, + ], + })).toEqual({ + userjourney: [ + { ts: 123, k: 'click', v: 'View', m: { id: 'screen', ac: 'tap' } }, + ], + }); + }); +}); + +describe('buildDebugInfo', () => { + it('adds sdk and dependency markers even when the RN version shape is missing', () => { + expect(buildDebugInfo({ custom: true }, {})).toEqual({ + custom: true, + 'dep_mocked-md5': true, + rnver_0_0_0: true, + sdk_3_0_2: true, + }); + }); + + it('adds the normalized RN version marker when available', () => { + expect(buildDebugInfo({}, { version: { major: 1, minor: 2, patch: 3 } })).toEqual({ + rnver_1_2_3: true, + 'dep_mocked-md5': true, + sdk_3_0_2: true, + }); + }); +}); diff --git a/__tests__/journey.test.js b/__tests__/journey.test.js new file mode 100644 index 0000000..ffa03b1 --- /dev/null +++ b/__tests__/journey.test.js @@ -0,0 +1,533 @@ +import React from 'react'; +import { AppRegistry, Text, View } from 'react-native'; +import { fireEvent, render } from '@testing-library/react-native'; +import JourneyWrapper from '../journey/wrapper'; +import { + __unsafeResetJourneyRuntime, + clearJourneyEvents, + disableJourneyConsumer, + drainJourneyEvents, + enableJourneyConsumer, + emitJourneyEvent, + initJourneyTracking, + peekJourneyEvents, + registerJourneyNavigationContainer, + resolveJourneyIdentifier, +} from '../journey'; + +describe('journey runtime', () => { + beforeEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + __unsafeResetJourneyRuntime(); + }); + + it('installs the wrapper provider once and buffers events before the first consumer mounts', () => { + const wrapperSpy = jest.spyOn(AppRegistry, 'setWrapperComponentProvider'); + + initJourneyTracking(); + initJourneyTracking(); + emitJourneyEvent('click', 'View', { id: 'screen', ac: 'tap' }); + + expect(wrapperSpy).toHaveBeenCalledTimes(1); + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'click', + v: 'View', + m: { id: 'screen', ac: 'tap' }, + }), + ]); + }); + + it('composes wrapper providers registered after journey tracking initializes', () => { + const installedProviders = []; + jest.spyOn(AppRegistry, 'setWrapperComponentProvider').mockImplementation((provider) => { + installedProviders.push(provider); + }); + + initJourneyTracking(); + + const ExternalWrapper = ({ children }) => ( + {children} + ); + + AppRegistry.setWrapperComponentProvider(() => ExternalWrapper); + enableJourneyConsumer(); + + const provider = installedProviders[installedProviders.length - 1]; + const ComposedWrapper = provider({ rootTag: 1 }); + const component = render( + + child + + ); + + expect(component.getByTestId('external-wrapper')).toBeTruthy(); + + const wrappers = component.UNSAFE_getAllByType(View); + fireEvent(wrappers[1], 'touchStart', { + nativeEvent: { pageX: 7, pageY: 8, target: 70 }, + }); + fireEvent(wrappers[1], 'touchEnd', { + nativeEvent: { pageX: 7, pageY: 8, target: 70 }, + }); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'click', + v: 'View', + m: { id: '70', ac: 'tap', x: 7, y: 8 }, + }), + ]); + }); + + it('captures initial and subsequent navigation transitions', () => { + const listeners = new Set(); + const route = { current: { key: 'home-key', name: 'Home' } }; + const navigation = { + addListener: jest.fn((eventName, listener) => { + expect(eventName).toBe('state'); + listeners.add(listener); + return () => listeners.delete(listener); + }), + getCurrentRoute: jest.fn(() => route.current), + }; + + initJourneyTracking(); + registerJourneyNavigationContainer(navigation); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'screen', + v: 'Screen', + m: { id: 'screen', sc: 'Home', ac: 'appear' }, + }), + ]); + + clearJourneyEvents(); + route.current = { key: 'settings-key', name: 'Settings' }; + listeners.forEach((listener) => listener()); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + m: { id: 'screen', sc: 'Home', ac: 'disappear' }, + }), + expect.objectContaining({ + m: { id: 'screen', sc: 'Settings', ac: 'appear' }, + }), + ]); + }); + + it('re-emits the current screen when a consumer restarts on the same route', () => { + const navigation = { + addListener: jest.fn((eventName, listener) => { + expect(eventName).toBe('state'); + return () => listener; + }), + getCurrentRoute: jest.fn(() => ({ key: 'home-key', name: 'Home' })), + }; + + initJourneyTracking(); + registerJourneyNavigationContainer(navigation); + clearJourneyEvents(); + + enableJourneyConsumer(); + disableJourneyConsumer(); + enableJourneyConsumer(); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'screen', + v: 'Screen', + m: { id: 'screen', sc: 'Home', ac: 'appear' }, + }), + ]); + }); + + it('does not duplicate the current screen when a consumer restarts and the route is already buffered', () => { + const navigation = { + addListener: jest.fn((eventName, listener) => { + expect(eventName).toBe('state'); + return () => listener; + }), + getCurrentRoute: jest.fn(() => ({ key: 'home-key', name: 'Home' })), + }; + + initJourneyTracking(); + registerJourneyNavigationContainer(navigation); + + expect(peekJourneyEvents()).toHaveLength(1); + + enableJourneyConsumer(); + disableJourneyConsumer(); + enableJourneyConsumer(); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'screen', + v: 'Screen', + m: { id: 'screen', sc: 'Home', ac: 'appear' }, + }), + ]); + }); + + it('does not emit automatic touch events until a journey consumer is enabled', () => { + initJourneyTracking(); + const component = render( + + child + + ); + + const wrapper = component.UNSAFE_getByType(View); + fireEvent(wrapper, 'touchStart', { + nativeEvent: { pageX: 5, pageY: 6, target: 21 }, + }); + fireEvent(wrapper, 'touchEnd', { + nativeEvent: { pageX: 5, pageY: 6, target: 21 }, + }); + + expect(peekJourneyEvents()).toEqual([]); + + enableJourneyConsumer(); + + fireEvent(wrapper, 'touchStart', { + nativeEvent: { pageX: 5, pageY: 6, target: 21 }, + }); + fireEvent(wrapper, 'touchEnd', { + nativeEvent: { pageX: 5, pageY: 6, target: 21 }, + }); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'click', + v: 'View', + m: { id: '21', ac: 'tap', x: 5, y: 6 }, + }), + ]); + }); + + it('does not emit drag events for a stationary long press', () => { + initJourneyTracking(); + enableJourneyConsumer(); + const nowSpy = jest.spyOn(Date, 'now'); + let now = 1000; + nowSpy.mockImplementation(() => now); + const component = render( + + child + + ); + + const wrapper = component.UNSAFE_getByType(View); + fireEvent(wrapper, 'touchStart', { + nativeEvent: { pageX: 5, pageY: 6, target: 23 }, + }); + + now = 1401; + + fireEvent(wrapper, 'touchEnd', { + nativeEvent: { pageX: 5, pageY: 6, target: 23 }, + }); + + expect(peekJourneyEvents()).toEqual([]); + }); + + it('rounds captured touch coordinates to integers', () => { + initJourneyTracking(); + enableJourneyConsumer(); + const component = render( + + child + + ); + + const wrapper = component.UNSAFE_getByType(View); + fireEvent(wrapper, 'touchStart', { + nativeEvent: { pageX: 5.4, pageY: 6.6, target: 22 }, + }); + fireEvent(wrapper, 'touchEnd', { + nativeEvent: { pageX: 5.4, pageY: 6.6, target: 22 }, + }); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + m: { id: '22', ac: 'tap', x: 5, y: 7 }, + }), + ]); + }); + + it('emits drag events from the automatic wrapper when movement exceeds threshold', () => { + initJourneyTracking(); + enableJourneyConsumer(); + const component = render( + + child + + ); + + const wrapper = component.UNSAFE_getByType(View); + fireEvent(wrapper, 'touchStart', { + nativeEvent: { pageX: 0, pageY: 0, target: 30 }, + }); + fireEvent(wrapper, 'touchEnd', { + nativeEvent: { pageX: 20, pageY: 20, target: 30 }, + }); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'drag', + v: 'View', + m: { id: '30', ac: 'drag_start', x: 20, y: 20 }, + }), + expect.objectContaining({ + k: 'drag', + v: 'View', + m: expect.objectContaining({ id: '30', ac: 'drag_end', x: 20, y: 20 }), + }), + ]); + }); + + it('emits scroll-shaped drag events when movement is axis-dominant', () => { + initJourneyTracking(); + enableJourneyConsumer(); + const component = render( + + child + + ); + + const wrapper = component.UNSAFE_getByType(View); + fireEvent(wrapper, 'touchStart', { + nativeEvent: { pageX: 0, pageY: 0, target: 40 }, + }); + fireEvent(wrapper, 'touchMove', { + nativeEvent: { pageX: 35, pageY: 3, target: 40 }, + }); + fireEvent(wrapper, 'touchEnd', { + nativeEvent: { pageX: 55, pageY: 4, target: 40 }, + }); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'drag', + v: 'ScrollView', + m: { id: '40', ac: 'scroll_start', x: 35, y: 3, val: 'horizontal:right' }, + }), + expect.objectContaining({ + k: 'drag', + v: 'ScrollView', + m: expect.objectContaining({ id: '40', ac: 'scroll_end', x: 55, y: 4 }), + }), + ]); + }); + + it('upgrades numeric targets to semantic identifiers when richer metadata becomes available', () => { + initJourneyTracking(); + enableJourneyConsumer(); + const component = render( + + child + + ); + + const wrapper = component.UNSAFE_getByType(View); + fireEvent(wrapper, 'touchStart', { + nativeEvent: { pageX: 0, pageY: 0, target: 41 }, + }); + fireEvent(wrapper, 'touchMove', { + nativeEvent: { + pageX: 25, + pageY: 2, + target: 41, + _dispatchInstances: { + pendingProps: { testID: 'checkout-cta' }, + return: null, + }, + }, + }); + fireEvent(wrapper, 'touchEnd', { + nativeEvent: { pageX: 30, pageY: 4, target: 41 }, + }); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + m: { id: 'checkout-cta', ac: 'scroll_start', x: 25, y: 2, val: 'horizontal:right' }, + }), + expect.objectContaining({ + m: expect.objectContaining({ id: 'checkout-cta', ac: 'scroll_end' }), + }), + ]); + }); + + it('reads semantic identifiers from the synthetic event when nativeEvent lacks fiber metadata', () => { + initJourneyTracking(); + enableJourneyConsumer(); + const component = render( + + child + + ); + + const wrapper = component.UNSAFE_getByType(View); + fireEvent(wrapper, 'touchStart', { + nativeEvent: { pageX: 0, pageY: 0, target: 42 }, + }); + fireEvent(wrapper, 'touchMove', { + _dispatchInstances: { + pendingProps: { nativeID: 'launch-captcha' }, + return: null, + }, + nativeEvent: { + pageX: 25.2, + pageY: 2.2, + target: 42, + }, + }); + fireEvent(wrapper, 'touchEnd', { + nativeEvent: { pageX: 30.1, pageY: 4.4, target: 42 }, + }); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + m: { id: 'launch-captcha', ac: 'scroll_start', x: 25, y: 2, val: 'horizontal:right' }, + }), + expect.objectContaining({ + m: expect.objectContaining({ id: 'launch-captcha', ac: 'scroll_end', x: 30, y: 4 }), + }), + ]); + }); + + it('prefers semantic identifiers over numeric fallbacks during resolution', () => { + expect(resolveJourneyIdentifier({ + target: 99, + _dispatchInstances: { + pendingProps: { nativeID: 'primary-action' }, + return: null, + }, + })).toBe('primary-action'); + }); + + it('resolves identifiers from the synthetic event before falling back to native numeric targets', () => { + expect(resolveJourneyIdentifier({ + _dispatchInstances: { + pendingProps: { testID: 'warmup-touch' }, + return: null, + }, + nativeEvent: { + target: 99, + }, + })).toBe('warmup-touch'); + }); + + it('retains the last 50 events in the ring buffer', () => { + initJourneyTracking(); + + for (let index = 0; index < 60; index += 1) { + emitJourneyEvent('click', 'View', { id: `event-${index}`, ac: 'tap' }); + } + + const events = peekJourneyEvents(); + expect(events).toHaveLength(50); + expect(events[0].m.id).toBe('event-10'); + expect(events[49].m.id).toBe('event-59'); + }); + + it('peek keeps buffered events while drain returns and clears them', () => { + initJourneyTracking(); + emitJourneyEvent('click', 'View', { id: 'peeked', ac: 'tap' }); + + expect(peekJourneyEvents()).toHaveLength(1); + expect(peekJourneyEvents()).toHaveLength(1); + expect(drainJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'click', + v: 'View', + m: { id: 'peeked', ac: 'tap' }, + }), + ]); + expect(peekJourneyEvents()).toEqual([]); + }); + + it('keeps buffering after the last consumer disables', () => { + initJourneyTracking(); + + enableJourneyConsumer(); + disableJourneyConsumer(); + emitJourneyEvent('click', 'View', { id: 'idle-event', ac: 'tap' }); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'click', + v: 'View', + m: { id: 'idle-event', ac: 'tap' }, + }), + ]); + }); + + it('publishes runtime stats and optional debug logs', () => { + const onStats = jest.fn(); + const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {}); + + initJourneyTracking({ debug: true, onStats }); + emitJourneyEvent('click', 'View', { id: 'screen', ac: 'tap' }); + enableJourneyConsumer(); + disableJourneyConsumer(); + + expect(debugSpy).toHaveBeenCalledWith('[hcaptcha] journey', expect.objectContaining({ + k: 'click', + v: 'View', + })); + expect(onStats).toHaveBeenLastCalledWith(expect.objectContaining({ + activeConsumers: 0, + bufferedEvents: 1, + capturing: true, + initialized: true, + touchCaptureEnabled: true, + wrapperInstalled: true, + })); + }); + + it('supports disabling touch capture while keeping the rest of journey tracking enabled', () => { + const wrapperSpy = jest.spyOn(AppRegistry, 'setWrapperComponentProvider'); + const navigation = { + addListener: jest.fn(() => () => {}), + getCurrentRoute: jest.fn(() => ({ key: 'home-key', name: 'Home' })), + }; + + initJourneyTracking({ navigationContainerRef: navigation, touchCapture: false }); + enableJourneyConsumer(); + + expect(wrapperSpy).not.toHaveBeenCalled(); + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'screen', + v: 'Screen', + m: { id: 'screen', sc: 'Home', ac: 'appear' }, + }), + ]); + + const component = render( + + child + + ); + + const wrapper = component.UNSAFE_getByType(View); + fireEvent(wrapper, 'touchStart', { + nativeEvent: { pageX: 9, pageY: 10, target: 90 }, + }); + fireEvent(wrapper, 'touchEnd', { + nativeEvent: { pageX: 9, pageY: 10, target: 90 }, + }); + + expect(peekJourneyEvents()).toEqual([ + expect.objectContaining({ + k: 'screen', + v: 'Screen', + m: { id: 'screen', sc: 'Home', ac: 'appear' }, + }), + ]); + }); + +}); diff --git a/index.d.ts b/index.d.ts index e247d0c..ceb7d09 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,5 +1,22 @@ import React from 'react'; -import { HcaptchaProps } from './Hcaptcha'; +import Hcaptcha, { HcaptchaProps } from './Hcaptcha'; + +export type JourneyRuntimeStats = { + activeConsumers: number; + bufferedEvents: number; + capturing: boolean; + currentRoute: { key?: string; name: string } | null; + initialized: boolean; + touchCaptureEnabled: boolean; + wrapperInstalled: boolean; +}; + +export type JourneyTrackingOptions = { + navigationContainerRef?: unknown; + touchCapture?: boolean; + debug?: boolean; + onStats?: (stats: JourneyRuntimeStats) => void; +}; type ConfirmHcaptchaProps = Omit & { /** @@ -31,4 +48,15 @@ export default class ConfirmHcaptcha extends React.Component void; + /** + * Clears the shared journey buffer and detaches this instance from journey attachment + * until it is explicitly reconfigured. + */ + stopEvents: () => void; } + +export function initJourneyTracking(options?: JourneyTrackingOptions): void; + +export function registerJourneyNavigationContainer(ref: unknown): void; + +export { Hcaptcha }; diff --git a/index.js b/index.js index 3443afb..f324eba 100644 --- a/index.js +++ b/index.js @@ -2,11 +2,54 @@ import React, { PureComponent } from 'react'; import { Modal, SafeAreaView, StyleSheet, TouchableWithoutFeedback, View } from 'react-native'; import Hcaptcha from './Hcaptcha'; import PropTypes from 'prop-types'; +import { clearJourneyEvents, disableJourneyConsumer, enableJourneyConsumer } from './journey'; +export { initJourneyTracking, registerJourneyNavigationContainer } from './journey'; +export { default as Hcaptcha } from './Hcaptcha'; class ConfirmHcaptcha extends PureComponent { state = { + journeyStopped: false, show: false, }; + hasJourneyConsumer = false; + getJourneyEnabled(props = this.props, state = this.state) { + return Boolean(props.userJourney && !state.journeyStopped); + } + componentDidMount() { + this.syncJourneyConsumer(false, this.getJourneyEnabled()); + } + componentDidUpdate(prevProps, prevState) { + if (prevProps.userJourney !== this.props.userJourney && prevState.journeyStopped) { + this.setState({ journeyStopped: false }); + return; + } + + this.syncJourneyConsumer( + this.getJourneyEnabled(prevProps, prevState), + this.getJourneyEnabled() + ); + } + componentWillUnmount() { + this.syncJourneyConsumer(this.getJourneyEnabled(), false); + } + syncJourneyConsumer(previousValue, nextValue) { + if (!previousValue && nextValue && !this.hasJourneyConsumer) { + enableJourneyConsumer(); + this.hasJourneyConsumer = true; + } else if (previousValue && !nextValue && this.hasJourneyConsumer) { + disableJourneyConsumer(); + this.hasJourneyConsumer = false; + } + } + stopEvents = () => { + if (this.hasJourneyConsumer) { + disableJourneyConsumer(); + this.hasJourneyConsumer = false; + } + + clearJourneyEvents(); + this.setState({ journeyStopped: true }); + }; show = () => { this.setState({ show: true }); }; @@ -42,9 +85,11 @@ class ConfirmHcaptcha extends PureComponent { useSafeAreaView, phonePrefix, phoneNumber, + verifyParams, } = this.props; const WrapperComponent = useSafeAreaView === false ? View : SafeAreaView; + const journeyEnabled = this.getJourneyEnabled(); return ( @@ -71,6 +116,9 @@ class ConfirmHcaptcha extends PureComponent { debug={debug} phonePrefix={phonePrefix} phoneNumber={phoneNumber} + userJourney={journeyEnabled} + verifyParams={verifyParams} + _journeyManagedExternally={true} /> ); @@ -158,7 +206,7 @@ ConfirmHcaptcha.propTypes = { siteKey: PropTypes.string.isRequired, passiveSiteKey: PropTypes.bool, baseUrl: PropTypes.string, - onMessage: PropTypes.func, + onMessage: PropTypes.func.isRequired, languageCode: PropTypes.string, orientation: PropTypes.string, backgroundColor: PropTypes.string, @@ -178,6 +226,12 @@ ConfirmHcaptcha.propTypes = { debug: PropTypes.object, phonePrefix: PropTypes.string, phoneNumber: PropTypes.string, + userJourney: PropTypes.bool, + verifyParams: PropTypes.shape({ + phoneNumber: PropTypes.string, + phonePrefix: PropTypes.string, + rqdata: PropTypes.string, + }), }; ConfirmHcaptcha.defaultProps = { @@ -201,6 +255,8 @@ ConfirmHcaptcha.defaultProps = { debug: {}, phonePrefix: null, phoneNumber: null, + userJourney: false, + verifyParams: undefined, }; export default ConfirmHcaptcha; diff --git a/journey/index.js b/journey/index.js new file mode 100644 index 0000000..fc07d85 --- /dev/null +++ b/journey/index.js @@ -0,0 +1,14 @@ +export { + __unsafeResetJourneyRuntime, + clearJourneyEvents, + disableJourneyConsumer, + drainJourneyEvents, + enableJourneyConsumer, + emitJourneyEvent, + initJourneyTracking, + isJourneyCapturing, + peekJourneyEvents, + registerJourneyNavigationContainer, + resolveJourneyIdentifier, +} from './runtime'; +export { JOURNEY_MAX_EVENTS, JourneyEventKind, JourneyField } from './schema'; diff --git a/journey/runtime.js b/journey/runtime.js new file mode 100644 index 0000000..994a44e --- /dev/null +++ b/journey/runtime.js @@ -0,0 +1,434 @@ +import { AppRegistry } from 'react-native'; +import { createJourneyEvent, cloneJourneyEvent, JourneyEventKind, JourneyField, JOURNEY_MAX_EVENTS } from './schema'; + +const state = { + activeConsumers: 0, + capturing: false, + currentRoute: null, + debug: false, + events: [], + initialized: false, + navigationCleanup: null, + navigationTarget: null, + statsListener: null, + touchCaptureEnabled: true, + wrapperInstalled: false, +}; + +const isFunction = (value) => typeof value === 'function'; +const numericIdentifierPattern = /^\d+$/; +const getEventPayload = (event) => event?.nativeEvent || event || null; +const baseSetWrapperComponentProvider = AppRegistry && isFunction(AppRegistry.setWrapperComponentProvider) + ? AppRegistry.setWrapperComponentProvider.bind(AppRegistry) + : null; +let appRegistrySetterPatched = false; +let externalWrapperProvider = null; +let installingJourneyWrapperProvider = false; +let originalSetWrapperComponentProvider = baseSetWrapperComponentProvider; + +const normalizeNavigationTarget = (target) => { + if (!target) { + return null; + } + + if (typeof target === 'object' && 'current' in target) { + return target.current || null; + } + + return target; +}; + +const setCapturing = (value) => { + state.capturing = Boolean(value); +}; + +const hasCurrentScreenEvent = () => + state.events.some((event) => + event[JourneyField.kind] === JourneyEventKind.screen + && event[JourneyField.metadata]?.[JourneyField.screen] === state.currentRoute?.name + && event[JourneyField.metadata]?.[JourneyField.action] === 'appear' + ); + +const getJourneyRuntimeStats = () => ({ + activeConsumers: state.activeConsumers, + bufferedEvents: state.events.length, + capturing: state.capturing, + currentRoute: state.currentRoute ? { ...state.currentRoute } : null, + initialized: state.initialized, + touchCaptureEnabled: state.touchCaptureEnabled, + wrapperInstalled: state.wrapperInstalled, +}); + +const publishStats = () => { + if (state.statsListener) { + state.statsListener(getJourneyRuntimeStats()); + } +}; + +const configureRuntime = (options = {}) => { + if (Object.prototype.hasOwnProperty.call(options, 'debug')) { + state.debug = Boolean(options.debug); + } + + if (Object.prototype.hasOwnProperty.call(options, 'onStats')) { + state.statsListener = isFunction(options.onStats) ? options.onStats : null; + } + + if (Object.prototype.hasOwnProperty.call(options, 'touchCapture')) { + state.touchCaptureEnabled = options.touchCapture !== false; + } + + publishStats(); +}; + +const clearNavigationCleanup = () => { + if (!state.navigationCleanup) { + return; + } + + if (isFunction(state.navigationCleanup)) { + state.navigationCleanup(); + } else if (isFunction(state.navigationCleanup.remove)) { + state.navigationCleanup.remove(); + } + + state.navigationCleanup = null; +}; + +const getRouteIdentity = (route) => { + if (!route || typeof route.name !== 'string' || route.name.length === 0) { + return null; + } + + return { + key: typeof route.key === 'string' ? route.key : undefined, + name: route.name, + }; +}; + +const sameRoute = (left, right) => { + if (!left || !right) { + return false; + } + + if (left.key && right.key) { + return left.key === right.key; + } + + return left.name === right.name; +}; + +const pushEvent = (event) => { + if (!state.capturing) { + return; + } + + state.events.push(event); + + if (state.events.length > JOURNEY_MAX_EVENTS) { + state.events.splice(0, state.events.length - JOURNEY_MAX_EVENTS); + } + + if (state.debug && typeof console !== 'undefined' && isFunction(console.debug)) { + console.debug('[hcaptcha] journey', event); + } + + publishStats(); +}; + +const createPassthroughWrapperProvider = () => () => { + const React = require('react'); + + return function JourneyPassthroughWrapper({ children }) { + return React.createElement(React.Fragment, null, children); + }; +}; + +const createComposedWrapperProvider = () => (appParameters) => { + const React = require('react'); + const JourneyWrapper = require('./wrapper').default; + const ExternalWrapper = externalWrapperProvider ? externalWrapperProvider(appParameters) : null; + + if (!ExternalWrapper) { + return JourneyWrapper; + } + + return function ComposedJourneyWrapper({ children }) { + return React.createElement( + ExternalWrapper, + null, + React.createElement(JourneyWrapper, null, children) + ); + }; +}; + +const patchWrapperProviderSetter = () => { + if (appRegistrySetterPatched) { + return true; + } + + if (!AppRegistry || !isFunction(AppRegistry.setWrapperComponentProvider)) { + return false; + } + + originalSetWrapperComponentProvider = AppRegistry.setWrapperComponentProvider.bind(AppRegistry); + AppRegistry.setWrapperComponentProvider = (provider) => { + if (!installingJourneyWrapperProvider) { + externalWrapperProvider = provider || null; + } + + if (installingJourneyWrapperProvider || !state.touchCaptureEnabled) { + return originalSetWrapperComponentProvider(provider); + } + + installingJourneyWrapperProvider = true; + try { + return originalSetWrapperComponentProvider(createComposedWrapperProvider()); + } finally { + installingJourneyWrapperProvider = false; + state.wrapperInstalled = true; + publishStats(); + } + }; + appRegistrySetterPatched = true; + return true; +}; + +const syncWrapperProvider = () => { + if (!state.touchCaptureEnabled || !patchWrapperProviderSetter()) { + if (!state.touchCaptureEnabled && originalSetWrapperComponentProvider) { + if (!state.wrapperInstalled && !externalWrapperProvider) { + publishStats(); + return; + } + + installingJourneyWrapperProvider = true; + try { + originalSetWrapperComponentProvider(externalWrapperProvider || createPassthroughWrapperProvider()); + } finally { + installingJourneyWrapperProvider = false; + state.wrapperInstalled = false; + publishStats(); + } + } + return; + } + + if (state.wrapperInstalled) { + publishStats(); + return; + } + + installingJourneyWrapperProvider = true; + try { + originalSetWrapperComponentProvider(createComposedWrapperProvider()); + } finally { + installingJourneyWrapperProvider = false; + } + state.wrapperInstalled = true; + publishStats(); +}; + +export const initJourneyTracking = (options = {}) => { + if (!state.initialized) { + state.initialized = true; + setCapturing(true); + } + + configureRuntime(options); + syncWrapperProvider(); + + if (options.navigationContainerRef) { + registerJourneyNavigationContainer(options.navigationContainerRef); + } +}; + +export const registerJourneyNavigationContainer = (target) => { + if (!state.initialized) { + initJourneyTracking(); + } + + const navigationTarget = normalizeNavigationTarget(target); + if (!navigationTarget || navigationTarget === state.navigationTarget) { + return; + } + + clearNavigationCleanup(); + state.navigationTarget = navigationTarget; + publishStats(); + + if (!isFunction(navigationTarget.addListener) || !isFunction(navigationTarget.getCurrentRoute)) { + return; + } + + const emitCurrentRoute = () => { + const currentRoute = getRouteIdentity(navigationTarget.getCurrentRoute()); + if (!currentRoute || sameRoute(state.currentRoute, currentRoute)) { + return; + } + + if (state.currentRoute) { + pushEvent(createJourneyEvent(JourneyEventKind.screen, 'Screen', { + [JourneyField.id]: 'screen', + [JourneyField.screen]: state.currentRoute.name, + [JourneyField.action]: 'disappear', + })); + } + + pushEvent(createJourneyEvent(JourneyEventKind.screen, 'Screen', { + [JourneyField.id]: 'screen', + [JourneyField.screen]: currentRoute.name, + [JourneyField.action]: 'appear', + })); + state.currentRoute = currentRoute; + publishStats(); + }; + + state.navigationCleanup = navigationTarget.addListener('state', emitCurrentRoute); + emitCurrentRoute(); +}; + +export const enableJourneyConsumer = () => { + if (!state.initialized) { + initJourneyTracking(); + } + + const wasInactive = state.activeConsumers === 0; + state.activeConsumers += 1; + setCapturing(true); + + if (wasInactive && state.currentRoute && !hasCurrentScreenEvent()) { + pushEvent(createJourneyEvent(JourneyEventKind.screen, 'Screen', { + [JourneyField.id]: 'screen', + [JourneyField.screen]: state.currentRoute.name, + [JourneyField.action]: 'appear', + })); + } + + publishStats(); +}; + +export const disableJourneyConsumer = () => { + if (state.activeConsumers > 0) { + state.activeConsumers -= 1; + } + + publishStats(); +}; + +export const emitJourneyEvent = (kind, view, metadata = {}) => { + pushEvent(createJourneyEvent(kind, view, metadata)); +}; + +export const peekJourneyEvents = () => state.events.map(cloneJourneyEvent); + +export const drainJourneyEvents = () => { + const snapshot = peekJourneyEvents(); + clearJourneyEvents(); + return snapshot; +}; + +export const clearJourneyEvents = () => { + state.events.length = 0; + publishStats(); +}; + +const readIdentifierFromProps = (props) => { + if (!props || typeof props !== 'object') { + return null; + } + + return props.nativeID || props.testID || props.accessibilityLabel || null; +}; + +const readIdentifierFromNode = (node) => { + let current = node; + + while (current) { + const identifier = readIdentifierFromProps(current.memoizedProps) + || readIdentifierFromProps(current.pendingProps) + || readIdentifierFromProps(current.stateNode?.props); + + if (identifier) { + return String(identifier); + } + + current = current.return; + } + + return null; +}; + +const readIdentifierFromNodeList = (value) => { + if (!value) { + return null; + } + + const nodes = Array.isArray(value) ? value : [value]; + for (const node of nodes) { + const identifier = readIdentifierFromNode(node); + if (identifier) { + return identifier; + } + } + + return null; +}; + +const getIdentifierRank = (value) => { + if (!value || value === 'unknown') { + return 0; + } + + return numericIdentifierPattern.test(String(value)) ? 1 : 2; +}; + +export const resolveJourneyIdentifier = (event, fallbackIdentifier) => { + const payload = getEventPayload(event); + const directIdentifier = readIdentifierFromProps(payload); + const fiberIdentifier = readIdentifierFromNodeList(event?._targetInst) + || readIdentifierFromNodeList(event?._dispatchInstances) + || readIdentifierFromNodeList(payload?._targetInst) + || readIdentifierFromNodeList(payload?._dispatchInstances); + const targetIdentifier = payload && payload.target != null ? String(payload.target) : null; + const candidates = [ + fallbackIdentifier, + directIdentifier ? String(directIdentifier) : null, + fiberIdentifier, + targetIdentifier, + 'unknown', + ]; + + let bestIdentifier = 'unknown'; + + for (const candidate of candidates) { + if (getIdentifierRank(candidate) > getIdentifierRank(bestIdentifier)) { + bestIdentifier = candidate; + } + } + + return bestIdentifier; +}; + +export const isJourneyCapturing = () => state.capturing && state.touchCaptureEnabled && state.activeConsumers > 0; + +export const __unsafeResetJourneyRuntime = () => { + clearNavigationCleanup(); + if (AppRegistry && baseSetWrapperComponentProvider) { + AppRegistry.setWrapperComponentProvider = baseSetWrapperComponentProvider; + } + appRegistrySetterPatched = false; + externalWrapperProvider = null; + installingJourneyWrapperProvider = false; + originalSetWrapperComponentProvider = baseSetWrapperComponentProvider; + state.activeConsumers = 0; + state.capturing = false; + state.currentRoute = null; + state.debug = false; + state.events = []; + state.initialized = false; + state.navigationTarget = null; + state.statsListener = null; + state.touchCaptureEnabled = true; + state.wrapperInstalled = false; +}; diff --git a/journey/schema.js b/journey/schema.js new file mode 100644 index 0000000..c4ee019 --- /dev/null +++ b/journey/schema.js @@ -0,0 +1,47 @@ +export const JOURNEY_MAX_EVENTS = 50; + +export const JourneyEventKind = Object.freeze({ + screen: 'screen', + click: 'click', + drag: 'drag', + gesture: 'gesture', + edit: 'edit', +}); + +export const JourneyField = Object.freeze({ + kind: 'k', + view: 'v', + timestamp: 'ts', + metadata: 'm', + id: 'id', + screen: 'sc', + action: 'ac', + value: 'val', + x: 'x', + y: 'y', + index: 'idx', + section: 'sct', + item: 'it', + target: 'tt', + control: 'ct', + gesture: 'gt', + state: 'gs', + taps: 'tap', + containerView: 'cv', + length: 'ln', + compose: 'comp', +}); + +export const getJourneyTimestamp = () => Math.floor(Date.now() / 1000); + +export const createJourneyEvent = (kind, view, metadata = {}) => ({ + [JourneyField.timestamp]: getJourneyTimestamp(), + [JourneyField.kind]: kind, + [JourneyField.view]: view, + [JourneyField.metadata]: metadata, +}); + +export const cloneJourneyEvent = (event) => ({ + ...event, + [JourneyField.metadata]: { ...event[JourneyField.metadata] }, +}); diff --git a/journey/wrapper.js b/journey/wrapper.js new file mode 100644 index 0000000..1367052 --- /dev/null +++ b/journey/wrapper.js @@ -0,0 +1,189 @@ +import React, { useRef } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { emitJourneyEvent, isJourneyCapturing, resolveJourneyIdentifier } from './runtime'; +import { JourneyEventKind, JourneyField } from './schema'; + +const TAP_DURATION_MS = 300; +const DRAG_THRESHOLD_PX = 10; +const SCROLL_DOMINANCE_RATIO = 2; +const SCROLL_THRESHOLD_PX = 24; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); + +const toInteger = (value) => ( + typeof value === 'number' && Number.isFinite(value) + ? Math.round(value) + : undefined +); + +const getPoint = (nativeEvent) => ({ + x: toInteger(typeof nativeEvent.pageX === 'number' ? nativeEvent.pageX : nativeEvent.locationX), + y: toInteger(typeof nativeEvent.pageY === 'number' ? nativeEvent.pageY : nativeEvent.locationY), +}); + +const getDirection = (dx, dy) => { + if (Math.abs(dx) >= Math.abs(dy)) { + return dx >= 0 ? 'horizontal:right' : 'horizontal:left'; + } + + return dy >= 0 ? 'vertical:down' : 'vertical:up'; +}; + +const getGestureSnapshot = (nativeEvent, gesture) => { + const point = getPoint(nativeEvent); + const dx = point.x - gesture.point.x; + const dy = point.y - gesture.point.y; + + return { + direction: getDirection(dx, dy), + distance: Math.sqrt((dx * dx) + (dy * dy)), + dx, + dy, + point, + }; +}; + +const getGestureKind = (distance, dx, dy) => { + if (distance < DRAG_THRESHOLD_PX) { + return null; + } + + const axisDominant = Math.max(Math.abs(dx), Math.abs(dy)) / Math.max(Math.min(Math.abs(dx), Math.abs(dy)), 1); + + return distance >= SCROLL_THRESHOLD_PX && axisDominant >= SCROLL_DOMINANCE_RATIO ? 'scroll' : 'drag'; +}; + +const createBaseMetadata = (identifier, point) => ({ + [JourneyField.id]: identifier, + [JourneyField.x]: point.x, + [JourneyField.y]: point.y, +}); + +const JourneyWrapper = ({ children }) => { + const gestureRef = useRef(null); + + const handleTouchStart = (event) => { + if (!isJourneyCapturing()) { + return; + } + + const nativeEvent = event.nativeEvent || {}; + gestureRef.current = { + identifier: resolveJourneyIdentifier(event), + kind: null, + point: getPoint(nativeEvent), + startedAt: Date.now(), + }; + }; + + const handleTouchMove = (event) => { + const gesture = gestureRef.current; + + if (!gesture || !isJourneyCapturing()) { + return; + } + + const nativeEvent = event.nativeEvent || {}; + const snapshot = getGestureSnapshot(nativeEvent, gesture); + const kind = getGestureKind(snapshot.distance, snapshot.dx, snapshot.dy); + + gesture.identifier = resolveJourneyIdentifier(event, gesture.identifier); + + if (!kind || gesture.kind) { + return; + } + + gesture.kind = kind; + const metadata = { + ...createBaseMetadata(gesture.identifier, snapshot.point), + [JourneyField.action]: kind === 'scroll' ? 'scroll_start' : 'drag_start', + }; + + if (kind === 'scroll') { + metadata[JourneyField.value] = snapshot.direction; + } + + emitJourneyEvent(JourneyEventKind.drag, kind === 'scroll' ? 'ScrollView' : 'View', metadata); + }; + + const handleTouchEnd = (event) => { + const gesture = gestureRef.current; + gestureRef.current = null; + + if (!gesture || !isJourneyCapturing()) { + return; + } + + const nativeEvent = event.nativeEvent || {}; + gesture.identifier = resolveJourneyIdentifier(event, gesture.identifier); + const snapshot = getGestureSnapshot(nativeEvent, gesture); + const duration = Date.now() - gesture.startedAt; + const baseMetadata = createBaseMetadata(gesture.identifier, snapshot.point); + + if (!gesture.kind && snapshot.distance < DRAG_THRESHOLD_PX && duration <= TAP_DURATION_MS) { + emitJourneyEvent(JourneyEventKind.click, 'View', { + ...baseMetadata, + [JourneyField.action]: 'tap', + }); + return; + } + + if (!gesture.kind && snapshot.distance < DRAG_THRESHOLD_PX) { + return; + } + + const finalKind = gesture.kind || getGestureKind(snapshot.distance, snapshot.dx, snapshot.dy) || 'drag'; + + if (finalKind === 'scroll') { + if (!gesture.kind) { + emitJourneyEvent(JourneyEventKind.drag, 'ScrollView', { + ...baseMetadata, + [JourneyField.action]: 'scroll_start', + [JourneyField.value]: snapshot.direction, + }); + } + + emitJourneyEvent(JourneyEventKind.drag, 'ScrollView', { + ...baseMetadata, + [JourneyField.action]: 'scroll_end', + [JourneyField.value]: Number(snapshot.distance.toFixed(2)), + }); + return; + } + + if (!gesture.kind) { + emitJourneyEvent(JourneyEventKind.drag, 'View', { + ...baseMetadata, + [JourneyField.action]: 'drag_start', + }); + } + + emitJourneyEvent(JourneyEventKind.drag, 'View', { + ...baseMetadata, + [JourneyField.action]: 'drag_end', + [JourneyField.value]: Number(snapshot.distance.toFixed(2)), + }); + }; + + const handleTouchCancel = () => { + gestureRef.current = null; + }; + + return ( + + {children} + + ); +}; + +export default JourneyWrapper;