diff --git a/MixpanelReactNative.podspec b/MixpanelReactNative.podspec index c47703af..e1943710 100644 --- a/MixpanelReactNative.podspec +++ b/MixpanelReactNative.podspec @@ -19,5 +19,6 @@ Pod::Spec.new do |s| s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } s.dependency "React-Core" - s.dependency "Mixpanel-swift", '6.5.0' + # Local mixpanel-swift with autocapture support + s.dependency "Mixpanel-swift", '~> 6.5' end diff --git a/Samples/MixpanelStarter/src/App.tsx b/Samples/MixpanelStarter/src/App.tsx index a7b294ec..e744eebc 100644 --- a/Samples/MixpanelStarter/src/App.tsx +++ b/Samples/MixpanelStarter/src/App.tsx @@ -11,6 +11,8 @@ import {OnboardingScreen} from './screens/OnboardingScreen'; import {HomeScreen} from './screens/HomeScreen'; import {FeatureFlagsScreen} from './screens/FeatureFlagsScreen'; import {SettingsScreen} from './screens/SettingsScreen'; +import {AutocaptureTestScreen} from './screens/AutocaptureTestScreen'; +import {WalkUpTestScreen} from './screens/WalkUpTestScreen'; import {MIXPANEL_TOKEN} from '@env'; const Tab = createBottomTabNavigator(); @@ -112,6 +114,26 @@ function AppNavigator(): React.JSX.Element { ), }} /> + ( + tap + ), + }} + /> + ( + 🔍 + ), + }} + /> - + diff --git a/Samples/MixpanelStarter/src/screens/WalkUpTestScreen.tsx b/Samples/MixpanelStarter/src/screens/WalkUpTestScreen.tsx new file mode 100644 index 00000000..77d86d1f --- /dev/null +++ b/Samples/MixpanelStarter/src/screens/WalkUpTestScreen.tsx @@ -0,0 +1,226 @@ +import React from 'react'; +import { + ScrollView, + View, + Text, + Pressable, + TouchableOpacity, + StyleSheet, +} from 'react-native'; + +/** + * Walk-Up-to-Clickable-Parent Test Screen + * + * Validates that when a non-interactive leaf view (Text, Image) is tapped, + * the native SDK walks up to the nearest clickable ancestor (Pressable, + * TouchableOpacity) for $el_id resolution. + * + * Run on both iOS and Android. Inspect Mixpanel event stream to verify. + */ +export function WalkUpTestScreen() { + return ( + + Walk-Up Test Screen + + Tests that tapping a leaf view (Text/Image) inside a clickable wrapper + reports the wrapper's identity as $el_id, not the leaf's hash. + + + {/* 1. Basic Walk-Up */} + + + Tap the text. $el_id should be "add_to_cart" from the Pressable. + + {}} + accessible={true} + accessibilityLabel="add_to_cart"> + Add to Cart + + + {/* 2. Nested Pressables */} + + + Tap "Delete". Walk-up stops at inner Pressable ("delete_item"), not + outer card ("product_card"). + + {}} + accessible={true} + accessibilityLabel="product_card"> + Product Name + {}} + accessible={true} + accessibilityLabel="delete_item"> + Delete + + + + {/* 3. Pressable with Image + Text */} + + + Tap the icon or text. $el_id should be "checkout_action". + + {}} + accessible={true} + accessibilityLabel="checkout_action"> + 🛒 + Proceed to Checkout + + + {/* 4. Non-interactive text */} + + + Tap below. No Pressable ancestor exists. $el_id = hash fallback. + + + Terms and Conditions apply. + + + {/* 5. Leaf with own identity */} + + + Tap the text. Even though it has its own accessibilityLabel + ("inner_label"), walk-up still activates and takes the clickable + parent's identity. $el_id = "outer_button". + + {}} + accessible={true} + accessibilityLabel="outer_button"> + + I have my own identity + + + + {/* 6. View flattening */} + + + Intermediate View has no visual props and will be flattened away. Tap the + text — walk-up should still find "flattened_pressable". + + {}} + accessible={true} + accessibilityLabel="flattened_pressable"> + + Text inside flattened View + + + + {/* 7. TouchableOpacity variant */} + + + Same as basic walk-up but with TouchableOpacity. $el_id should be + "touchable_btn". + + {}} + accessible={true} + accessibilityLabel="touchable_btn"> + TouchableOpacity Button + + + ); +} + +function SectionHeader({title}: {title: string}) { + return {title}; +} + +const styles = StyleSheet.create({ + container: { + padding: 16, + paddingBottom: 48, + }, + title: { + fontSize: 22, + fontWeight: 'bold', + marginBottom: 4, + }, + subtitle: { + fontSize: 13, + color: '#666', + marginBottom: 16, + }, + sectionHeader: { + fontSize: 17, + fontWeight: '700', + marginTop: 20, + marginBottom: 4, + color: '#333', + }, + description: { + fontSize: 12, + color: '#888', + marginBottom: 6, + }, + btn: { + backgroundColor: '#2196F3', + padding: 14, + borderRadius: 8, + alignItems: 'center', + marginBottom: 8, + }, + btnText: { + color: '#fff', + fontSize: 15, + fontWeight: '600', + }, + card: { + backgroundColor: '#f5f5f5', + borderRadius: 12, + padding: 16, + marginBottom: 8, + }, + cardTitle: { + fontSize: 16, + fontWeight: 'bold', + marginBottom: 8, + }, + deleteBtn: { + backgroundColor: '#f44336', + padding: 10, + borderRadius: 8, + alignItems: 'center', + }, + deleteBtnText: { + color: '#fff', + fontWeight: '600', + }, + row: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + backgroundColor: '#f5f5f5', + borderRadius: 8, + marginBottom: 8, + }, + icon: { + fontSize: 20, + marginRight: 12, + }, + rowText: { + fontSize: 16, + }, + plainTextContainer: { + padding: 8, + marginBottom: 8, + }, + plainText: { + fontSize: 14, + color: '#888', + }, +}); diff --git a/android/build.gradle b/android/build.gradle index 802f4dfe..ddac641d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -32,6 +32,7 @@ android { } repositories { + mavenLocal() // Local builds of mixpanel-android with autocapture support mavenCentral() maven { url 'https://maven.google.com/' @@ -41,5 +42,6 @@ repositories { dependencies { implementation 'com.facebook.react:react-native:+' - implementation 'com.mixpanel.android:mixpanel-android:8.9.0' + // Local mixpanel-android with autocapture support (publish locally with: ./gradlew :analytics:publishToMavenLocal -x signReleasePublication) + implementation 'com.mixpanel.android:mixpanel-android:9.0.0-beta' } diff --git a/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java b/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java index b6927834..3069af64 100644 --- a/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java +++ b/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java @@ -1,10 +1,14 @@ package com.mixpanel.reactnative; +import com.mixpanel.android.mpmetrics.AutocaptureOptions; +import com.mixpanel.android.mpmetrics.ClickOptions; +import com.mixpanel.android.mpmetrics.DeadClickOptions; import com.mixpanel.android.mpmetrics.FeatureFlagOptions; import com.mixpanel.android.mpmetrics.MixpanelAPI; import com.mixpanel.android.mpmetrics.MixpanelOptions; import com.mixpanel.android.mpmetrics.MixpanelFlagVariant; import com.mixpanel.android.mpmetrics.FlagCompletionCallback; +import com.mixpanel.android.mpmetrics.RageClickOptions; import com.mixpanel.android.mpmetrics.VariantLookupPolicy; import com.facebook.react.bridge.Promise; @@ -44,7 +48,7 @@ public String getName() { @ReactMethod - public void initialize(String token, boolean trackAutomaticEvents, boolean optOutTrackingDefault, ReadableMap metadata, String serverURL, boolean useGzipCompression, ReadableMap featureFlagsOptions, Promise promise) throws JSONException { + public void initialize(String token, boolean trackAutomaticEvents, boolean optOutTrackingDefault, ReadableMap metadata, String serverURL, boolean useGzipCompression, ReadableMap featureFlagsOptions, ReadableMap autocaptureConfig, Promise promise) throws JSONException { JSONObject mixpanelProperties = ReactNativeHelper.reactToJSON(metadata); AutomaticProperties.setAutomaticProperties(mixpanelProperties); @@ -81,6 +85,12 @@ public void initialize(String token, boolean trackAutomaticEvents, boolean optOu optionsBuilder.featureFlagOptions(ffBuilder.build()); } + // Configure autocapture if provided + if (autocaptureConfig != null) { + AutocaptureOptions autocaptureOptions = buildAutocaptureOptions(autocaptureConfig); + optionsBuilder.autocaptureOptions(autocaptureOptions); + } + MixpanelAPI instance = MixpanelAPI.getInstance(this.mReactContext, token, trackAutomaticEvents, optionsBuilder.build()); if (useGzipCompression) { instance.setShouldGzipRequestPayload(true); @@ -88,6 +98,56 @@ public void initialize(String token, boolean trackAutomaticEvents, boolean optOu promise.resolve(null); } + private AutocaptureOptions buildAutocaptureOptions(ReadableMap config) { + AutocaptureOptions.Builder builder = new AutocaptureOptions.Builder(); + if (config.hasKey("click")) { + ReadableMap clickConfig = config.getMap("click"); + if (clickConfig != null) { + ClickOptions.Builder clickBuilder = new ClickOptions.Builder(); + if (clickConfig.hasKey("enabled")) { + clickBuilder.enabled(clickConfig.getBoolean("enabled")); + } + builder.clickOptions(clickBuilder.build()); + } + } + + if (config.hasKey("rageClick")) { + ReadableMap rageConfig = config.getMap("rageClick"); + if (rageConfig != null) { + RageClickOptions.Builder rageBuilder = new RageClickOptions.Builder(); + if (rageConfig.hasKey("enabled")) { + rageBuilder.enabled(rageConfig.getBoolean("enabled")); + } + if (rageConfig.hasKey("clickThreshold")) { + rageBuilder.clickThreshold(rageConfig.getInt("clickThreshold")); + } + if (rageConfig.hasKey("timeWindowMs")) { + rageBuilder.timeWindowMs((long) rageConfig.getDouble("timeWindowMs")); + } + if (rageConfig.hasKey("radius")) { + rageBuilder.radius((float) rageConfig.getDouble("radius")); + } + builder.rageClickOptions(rageBuilder.build()); + } + } + + if (config.hasKey("deadClick")) { + ReadableMap deadConfig = config.getMap("deadClick"); + if (deadConfig != null) { + DeadClickOptions.Builder deadBuilder = new DeadClickOptions.Builder(); + if (deadConfig.hasKey("enabled")) { + deadBuilder.enabled(deadConfig.getBoolean("enabled")); + } + if (deadConfig.hasKey("timeWindowMs")) { + deadBuilder.timeWindowMs((long) deadConfig.getDouble("timeWindowMs")); + } + builder.deadClickOptions(deadBuilder.build()); + } + } + + return builder.build(); + } + private VariantLookupPolicy parseVariantLookupPolicy(ReadableMap policyMap) { if (policyMap == null || !policyMap.hasKey("variantLookupPolicy")) { return null; @@ -259,40 +319,6 @@ public void track(final String token, final String eventName, ReadableMap proper } } - @ReactMethod - public void trackScreenView(final String token, final String screenName, ReadableMap properties, Promise promise) throws JSONException { - MixpanelAPI instance = MixpanelAPI.getInstance(this.mReactContext, token, true); - if (instance == null) { - promise.reject("Instance Error", "Failed to get Mixpanel instance"); - return; - } - synchronized (instance) { - JSONObject eventProperties = ReactNativeHelper.reactToJSON(properties); - AutomaticProperties.appendLibraryProperties(eventProperties); - if (instance.getAutocapture() != null) { - instance.getAutocapture().trackScreenView(screenName, eventProperties); - } - promise.resolve(null); - } - } - - @ReactMethod - public void trackScreenLeave(final String token, final String screenName, ReadableMap properties, Promise promise) throws JSONException { - MixpanelAPI instance = MixpanelAPI.getInstance(this.mReactContext, token, true); - if (instance == null) { - promise.reject("Instance Error", "Failed to get Mixpanel instance"); - return; - } - synchronized (instance) { - JSONObject eventProperties = ReactNativeHelper.reactToJSON(properties); - AutomaticProperties.appendLibraryProperties(eventProperties); - if (instance.getAutocapture() != null) { - instance.getAutocapture().trackScreenLeave(screenName, eventProperties); - } - promise.resolve(null); - } - } - @ReactMethod public void registerSuperProperties(final String token, ReadableMap properties, Promise promise) throws JSONException { MixpanelAPI instance = MixpanelAPI.getInstance(this.mReactContext, token, true); diff --git a/context/AUTOCAPTURE.md b/context/AUTOCAPTURE.md new file mode 100644 index 00000000..de64c17c --- /dev/null +++ b/context/AUTOCAPTURE.md @@ -0,0 +1,69 @@ +# React Native Autocapture + +Autocapture automatically tracks user interactions in React Native apps by delegating to the native Android and iOS SDKs. + +## Overview + +Autocapture captures three types of events: + +| Event | Name | Description | +|-------|------|-------------| +| Click | `$mp_click` | Fired when a user taps any element | +| Rage Click | `$mp_rage_click` | Fired when a user taps rapidly (4+ times) in the same area | +| Dead Click | `$mp_dead_click` | Fired when a tap produces no visible UI response | + +## Quick Start + +```typescript +import {Mixpanel} from 'mixpanel-react-native'; + +const mixpanel = new Mixpanel('YOUR_TOKEN', true); +await mixpanel.init( + false, // optOutTrackingDefault + {}, // superProperties + '', // serverURL + true, // useGzipCompression + {}, // featureFlagsOptions + { // autocaptureOptions + click: true, + rageClick: true, + deadClick: true, + } +); +``` + +## Element Identification (`$el_id`) + +### Walk-Up to Clickable Parent + +When a non-interactive leaf view (e.g., `` inside a ``) is tapped, the native SDK walks up the view hierarchy to the nearest clickable ancestor and uses its `accessibilityLabel` for `$el_id`. This is always-on behavior — not configurable. + +- The walk-up always takes the clickable parent's identity, even if the leaf has its own `accessibilityLabel`. +- Stops at the first clickable ancestor (nested clickables: inner wins). +- Max ancestor search depth: **10 levels**. +- If no clickable ancestor is found within 10 levels, the leaf's own identity (or hash fallback) is used. + +React Native's view flattening compounds this — intermediate `` wrappers are removed from the native tree, so the platform's hit-test often returns a leaf `Text` node even when the developer intended the tap for the parent `Pressable`. + +### Best Practice + +Set `accessibilityLabel` on interactive wrappers (`Pressable`, `TouchableOpacity`): + +```tsx + + Add to Cart + +``` + +## Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `click` | `boolean \| AutocaptureClickOptions` | `true` | Track click events | +| `rageClick` | `boolean \| AutocaptureRageClickOptions` | `true` | Track rage click events | +| `deadClick` | `boolean \| AutocaptureDeadClickOptions` | `true` | Track dead click events | + +See `index.d.ts` for `AutocaptureClickOptions`, `AutocaptureRageClickOptions`, and `AutocaptureDeadClickOptions` interfaces. diff --git a/index.d.ts b/index.d.ts index 40d1cc14..4e29ef11 100644 --- a/index.d.ts +++ b/index.d.ts @@ -102,6 +102,46 @@ export interface Flags { check_first_time_events(eventName: string, properties?: MixpanelProperties): void; } +export interface AutocaptureClickOptions { + enabled?: boolean; +} + +export interface AutocaptureRageClickOptions { + enabled?: boolean; + clickThreshold?: number; + timeWindowMs?: number; + /** Spatial threshold. Unit: dp on Android, pt on iOS. */ + radius?: number; +} + +export interface AutocaptureDeadClickOptions { + enabled?: boolean; + timeWindowMs?: number; +} + +export interface AutocaptureOptions { + click?: boolean | AutocaptureClickOptions; + rageClick?: boolean | AutocaptureRageClickOptions; + deadClick?: boolean | AutocaptureDeadClickOptions; +} + +export interface ClickEventData { + /** Touch X coordinate. */ + x: number; + /** Touch Y coordinate. */ + y: number; + /** Stable identifier for the tapped element. */ + elementId: string; + /** Class name or component type of the tapped element. */ + tagName?: string; + /** Accessibility label of the element. */ + accessibleLabel?: string; + /** Semantic role (e.g., "button", "link", "switch"). */ + role?: string; + /** View hierarchy path, ">" separated. */ + elements?: string; +} + export class Autocapture { /** * Track a screen view event. @@ -119,6 +159,9 @@ export class Autocapture { * @param properties Optional additional properties to include with the event */ trackScreenLeave(screenName: string, properties?: MixpanelProperties): void; + trackClick(clickEvent: ClickEventData, properties?: MixpanelProperties): void; + trackRageClick(clickEvent: ClickEventData, properties?: MixpanelProperties): void; + trackDeadClick(clickEvent: ClickEventData, properties?: MixpanelProperties): void; } export class Mixpanel { @@ -143,7 +186,8 @@ export class Mixpanel { superProperties?: MixpanelProperties, serverURL?: string, useGzipCompression?: boolean, - featureFlagsOptions?: FeatureFlagsOptions + featureFlagsOptions?: FeatureFlagsOptions, + autocaptureOptions?: AutocaptureOptions | null ): Promise; setServerURL(serverURL: string): void; setLoggingEnabled(loggingEnabled: boolean): void; diff --git a/index.js b/index.js index f355ccde..526a8d63 100644 --- a/index.js +++ b/index.js @@ -141,6 +141,13 @@ export class Mixpanel { * Use the `custom_properties` key to nest targeting properties * (e.g., `context: { custom_properties: { user_tier: 'premium' } }`). * Note: In native mode, context must be set during initialization and cannot be updated later. + * @param {object} [autocaptureOptions=null] Autocapture configuration. Pass an object to enable autocapture. + * Requires native mode (useNative: true). Pass null or omit to disable autocapture. + * @param {boolean|object} [autocaptureOptions.click=true] Enable click tracking. Pass boolean or {enabled: boolean}. + * @param {boolean|object} [autocaptureOptions.rageClick=true] Enable rage click detection. + * Object form: {enabled, clickThreshold, timeWindowMs, radius} + * @param {boolean|object} [autocaptureOptions.deadClick=true] Enable dead click detection. + * Object form: {enabled, timeWindowMs} * @returns {Promise} A promise that resolves when initialization is complete * * @example @@ -169,17 +176,39 @@ export class Mixpanel { * 'https://api-eu.mixpanel.com', * true * ); + * + * @example + * // Initialize with autocapture enabled (native mode required) + * const mixpanel = new Mixpanel('YOUR_TOKEN', true, true); + * await mixpanel.init(false, {}, 'https://api.mixpanel.com', false, {}, { + * click: true, + * rageClick: { enabled: true, clickThreshold: 5, timeWindowMs: 2000 }, + * deadClick: { enabled: true, timeWindowMs: 1000 }, + * }); */ async init( optOutTrackingDefault = DEFAULT_OPT_OUT, superProperties = {}, serverURL = "https://api.mixpanel.com", useGzipCompression = false, - featureFlagsOptions = {} + featureFlagsOptions = {}, + autocaptureOptions = null ) { // Store feature flags options for later use this.featureFlagsOptions = featureFlagsOptions; + // Normalize autocapture options + let resolvedAutocaptureOptions = null; + if (autocaptureOptions != null) { + if (this.mixpanelImpl !== MixpanelReactNative) { + console.warn( + "Mixpanel autocapture requires native mode (useNative: true). Autocapture config will be ignored in JavaScript mode." + ); + } else { + resolvedAutocaptureOptions = AutocaptureHelper.normalizeOptions(autocaptureOptions); + } + } + await this.mixpanelImpl.initialize( this.token, this.trackAutomaticEvents, @@ -187,7 +216,8 @@ export class Mixpanel { {...Helper.getMetaData(), ...superProperties}, serverURL, useGzipCompression, - featureFlagsOptions + featureFlagsOptions, + resolvedAutocaptureOptions ); // If flags are enabled AND we're in native mode, initialize them @@ -224,7 +254,8 @@ export class Mixpanel { Helper.getMetaData(), "https://api.mixpanel.com", false, - {} + {}, + null ); return new Mixpanel(token, trackAutomaticEvents); } @@ -729,6 +760,10 @@ export class Autocapture { * @param {string} screenName The name of the screen being viewed. Must be non-empty; * if an empty or whitespace-only string is passed, the event is silently dropped. * @param {object} properties Optional additional properties to include with the event + * + * @example + * mixpanel.autocapture.trackScreenView('HomeScreen'); + * mixpanel.autocapture.trackScreenView('ProductDetail', { product_id: '123' }); */ trackScreenView(screenName, properties) { if (!StringHelper.isValid(screenName)) { @@ -741,11 +776,10 @@ export class Autocapture { if (!ObjectHelper.isValidOrUndefined(properties)) { ObjectHelper.raiseError(PARAMS.PROPERTIES); } - const mergedProperties = { - ...Helper.getMetaData(), + this._trackAutocaptureEvent("$mp_page_view", { + current_page_title: screenName, ...properties, - }; - this.mixpanelImpl.trackScreenView(this.token, screenName, mergedProperties); + }); } /** @@ -754,6 +788,10 @@ export class Autocapture { * @param {string} screenName The name of the screen being left. Must be non-empty; * if an empty or whitespace-only string is passed, the event is silently dropped. * @param {object} properties Optional additional properties to include with the event + * + * @example + * mixpanel.autocapture.trackScreenLeave('HomeScreen'); + * mixpanel.autocapture.trackScreenLeave('ProductDetail', { time_spent_ms: 5000 }); */ trackScreenLeave(screenName, properties) { if (!StringHelper.isValid(screenName)) { @@ -766,11 +804,149 @@ export class Autocapture { if (!ObjectHelper.isValidOrUndefined(properties)) { ObjectHelper.raiseError(PARAMS.PROPERTIES); } - const mergedProperties = { - ...Helper.getMetaData(), + this._trackAutocaptureEvent("$mp_page_leave", { + current_page_title: screenName, ...properties, + }); + } + + /** + * Track a click event with element metadata. + * + * Use this when your app implements its own click detection and you want to + * track click events with full element metadata in the Mixpanel autocapture format. + * + * @param {object} clickEvent The click event data. + * @param {number} clickEvent.x Touch X coordinate. + * @param {number} clickEvent.y Touch Y coordinate. + * @param {string} clickEvent.elementId Stable identifier for the tapped element. + * @param {string} [clickEvent.tagName] Class name or component type. + * @param {string} [clickEvent.accessibleLabel] Accessibility label. + * @param {string} [clickEvent.role] Semantic role (e.g., "button", "link"). + * @param {string} [clickEvent.elements] View hierarchy path, ">" separated. + * @param {object} [properties] Optional additional properties. + * + * @example + * mixpanel.autocapture.trackClick({ + * x: 150, + * y: 300, + * elementId: 'submit_button', + * tagName: 'Button', + * accessibleLabel: 'Submit Order', + * role: 'button', + * elements: 'Screen > Form > Button', + * }); + */ + trackClick(clickEvent, properties) { + if (!this._validateClickEvent(clickEvent, "trackClick")) return; + this._trackClickEvent("$mp_click", clickEvent, properties); + } + + /** + * Track a rage click event with element metadata. + * + * Use this when your app implements its own rage click detection. + * A rage click typically indicates a user rapidly tapping an unresponsive element. + * + * @param {object} clickEvent The click event data (same shape as trackClick). + * @param {object} [properties] Optional additional properties. + * + * @example + * mixpanel.autocapture.trackRageClick({ + * x: 150, + * y: 300, + * elementId: 'checkout_button', + * tagName: 'Button', + * role: 'button', + * }); + */ + trackRageClick(clickEvent, properties) { + if (!this._validateClickEvent(clickEvent, "trackRageClick")) return; + this._trackClickEvent("$mp_rage_click", clickEvent, properties); + } + + /** + * Track a dead click event with element metadata. + * + * Use this when your app implements its own dead click detection. + * A dead click indicates a user tapped an interactive element but no UI change occurred. + * + * @param {object} clickEvent The click event data (same shape as trackClick). + * @param {object} [properties] Optional additional properties. + * + * @example + * mixpanel.autocapture.trackDeadClick({ + * x: 200, + * y: 400, + * elementId: 'disabled_link', + * tagName: 'Text', + * accessibleLabel: 'Learn More', + * role: 'link', + * }); + */ + trackDeadClick(clickEvent, properties) { + if (!this._validateClickEvent(clickEvent, "trackDeadClick")) return; + this._trackClickEvent("$mp_dead_click", clickEvent, properties); + } + + _trackClickEvent(eventName, clickEvent, properties) { + if (!ObjectHelper.isValidOrUndefined(properties)) { + ObjectHelper.raiseError(PARAMS.PROPERTIES); + } + const clickProperties = { + $x: clickEvent.x, + $y: clickEvent.y, + $el_id: clickEvent.elementId, }; - this.mixpanelImpl.trackScreenLeave(this.token, screenName, mergedProperties); + if (clickEvent.tagName != null) { + clickProperties.$el_tag_name = clickEvent.tagName; + } + if (clickEvent.accessibleLabel != null) { + clickProperties["$attr-aria-label"] = clickEvent.accessibleLabel; + } + if (clickEvent.role != null) { + clickProperties["$attr-role"] = clickEvent.role; + } + if (clickEvent.elements != null) { + clickProperties.$elements = clickEvent.elements; + } + this._trackAutocaptureEvent(eventName, { + ...clickProperties, + ...properties, + }); + } + + _trackAutocaptureEvent(eventName, properties) { + this.mixpanelImpl.track(this.token, eventName, { + ...Helper.getMetaData(), + $mp_autocapture: true, + ...properties, + }); + } + + _validateClickEvent(clickEvent, methodName) { + if (clickEvent == null || typeof clickEvent !== "object") { + MixpanelLogger.warn( + this.token, + `${methodName} failed: clickEvent must be an object` + ); + return false; + } + if (typeof clickEvent.x !== "number" || typeof clickEvent.y !== "number") { + MixpanelLogger.warn( + this.token, + `${methodName} failed: clickEvent.x and clickEvent.y must be numbers` + ); + return false; + } + if (!StringHelper.isValid(clickEvent.elementId)) { + MixpanelLogger.warn( + this.token, + `${methodName} failed: clickEvent.elementId cannot be blank` + ); + return false; + } + return true; } } @@ -1162,6 +1338,47 @@ class StringHelper { } } +class AutocaptureHelper { + static normalizeOptions(options) { + const normalized = {}; + + // Click options + if (options.click !== undefined) { + if (typeof options.click === "boolean") { + normalized.click = { enabled: options.click }; + } else if (typeof options.click === "object") { + normalized.click = { enabled: true, ...options.click }; + } + } else { + normalized.click = { enabled: true }; + } + + // Rage click options + if (options.rageClick !== undefined) { + if (typeof options.rageClick === "boolean") { + normalized.rageClick = { enabled: options.rageClick }; + } else if (typeof options.rageClick === "object") { + normalized.rageClick = { enabled: true, ...options.rageClick }; + } + } else { + normalized.rageClick = { enabled: true }; + } + + // Dead click options + if (options.deadClick !== undefined) { + if (typeof options.deadClick === "boolean") { + normalized.deadClick = { enabled: options.deadClick }; + } else if (typeof options.deadClick === "object") { + normalized.deadClick = { enabled: true, ...options.deadClick }; + } + } else { + normalized.deadClick = { enabled: true }; + } + + return normalized; + } +} + class ObjectHelper { /** Check whether the parameter is an object. diff --git a/ios/MixpanelReactNative.m b/ios/MixpanelReactNative.m index fa9b6f5c..a0ea2953 100644 --- a/ios/MixpanelReactNative.m +++ b/ios/MixpanelReactNative.m @@ -5,7 +5,7 @@ @interface RCT_EXTERN_MODULE(MixpanelReactNative, NSObject) // MARK: - Mixpanel Instance -RCT_EXTERN_METHOD(initialize:(NSString *)token trackAutomaticEvents:(BOOL)trackAutomaticEvents optOutTrackingByDefault:(BOOL)optOutTrackingByDefault properties:(NSDictionary *)properties serverURL:(NSString *)serverURL useGzipCompression:(BOOL)useGzipCompression featureFlagsOptions:(NSDictionary *)featureFlagsOptions resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(initialize:(NSString *)token trackAutomaticEvents:(BOOL)trackAutomaticEvents optOutTrackingByDefault:(BOOL)optOutTrackingByDefault properties:(NSDictionary *)properties serverURL:(NSString *)serverURL useGzipCompression:(BOOL)useGzipCompression featureFlagsOptions:(NSDictionary *)featureFlagsOptions autocaptureConfig:(NSDictionary *)autocaptureConfig resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) // Mark: - Settings RCT_EXTERN_METHOD(setServerURL:(NSString *)token serverURL:(NSString *)serverURL resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) @@ -30,12 +30,6 @@ @interface RCT_EXTERN_MODULE(MixpanelReactNative, NSObject) RCT_EXTERN_METHOD(track:(NSString *)token event:(NSString *)event properties:(NSDictionary *)properties resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) -// MARK: - Autocapture - -RCT_EXTERN_METHOD(trackScreenView:(NSString *)token screenName:(NSString *)screenName properties:(NSDictionary *)properties resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) - -RCT_EXTERN_METHOD(trackScreenLeave:(NSString *)token screenName:(NSString *)screenName properties:(NSDictionary *)properties resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) - // MARK: - Timing Events RCT_EXTERN_METHOD(timeEvent:(NSString *)token event:(NSString *)event resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) diff --git a/ios/MixpanelReactNative.swift b/ios/MixpanelReactNative.swift index ae7a9481..eefeac73 100644 --- a/ios/MixpanelReactNative.swift +++ b/ios/MixpanelReactNative.swift @@ -19,6 +19,7 @@ open class MixpanelReactNative: NSObject { serverURL: String, useGzipCompression: Bool = false, featureFlagsOptions: [String: Any]?, + autocaptureConfig: [String: Any]?, resolver resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) -> Void { let autoProps = properties // copy @@ -37,6 +38,11 @@ open class MixpanelReactNative: NSObject { ) } + var resolvedAutocaptureOptions: AutocaptureOptions? = nil + if let config = autocaptureConfig { + resolvedAutocaptureOptions = buildAutocaptureOptions(from: config) + } + let options = MixpanelOptions( token: token, instanceName: token, @@ -45,13 +51,52 @@ open class MixpanelReactNative: NSObject { superProperties: propsProcessed, serverURL: serverURL, useGzipCompression: useGzipCompression, - featureFlagOptions: resolvedFeatureFlagOptions + featureFlagOptions: resolvedFeatureFlagOptions, + autocaptureOptions: resolvedAutocaptureOptions ) Mixpanel.initialize(options: options) resolve(true) } + private func buildAutocaptureOptions(from config: [String: Any]) -> AutocaptureOptions { + // Start with native defaults so we don't hardcode values that may change in the SDK + let defaults = RageClickOptions() + let deadDefaults = DeadClickOptions() + + var clickOpts = ClickOptions() + var rageClickOpts = RageClickOptions() + var deadClickOpts = DeadClickOptions() + + if let clickConfig = config["click"] as? [String: Any] { + clickOpts = ClickOptions( + enabled: clickConfig["enabled"] as? Bool ?? true + ) + } + + if let rageConfig = config["rageClick"] as? [String: Any] { + rageClickOpts = RageClickOptions( + enabled: rageConfig["enabled"] as? Bool ?? true, + clickThreshold: rageConfig["clickThreshold"] as? Int ?? defaults.clickThreshold, + timeWindowMs: rageConfig["timeWindowMs"] as? Int64 ?? defaults.timeWindowMs, + radius: rageConfig["radius"] as? CGFloat ?? defaults.radius + ) + } + + if let deadConfig = config["deadClick"] as? [String: Any] { + deadClickOpts = DeadClickOptions( + enabled: deadConfig["enabled"] as? Bool ?? true, + timeWindowMs: deadConfig["timeWindowMs"] as? Int ?? deadDefaults.timeWindowMs + ) + } + + return AutocaptureOptions( + clickOptions: clickOpts, + rageClickOptions: rageClickOpts, + deadClickOptions: deadClickOpts + ) + } + private func parseVariantLookupPolicy(_ policyMap: [String: Any]?) -> VariantLookupPolicy { guard let policyMap = policyMap, let kind = policyMap["variantLookupPolicy"] as? String else { return .networkOnly @@ -167,28 +212,6 @@ open class MixpanelReactNative: NSObject { // MARK: - Autocapture - @objc - func trackScreenView(_ token: String, screenName: String, - properties: [String: Any]? = nil, - resolver resolve: RCTPromiseResolveBlock, - rejecter reject: RCTPromiseRejectBlock) -> Void { - let instance = MixpanelReactNative.getMixpanelInstance(token) - let mpProperties = MixpanelTypeHandler.processProperties(properties: properties) - instance?.autocapture.trackScreenView(screenName: screenName, properties: mpProperties) - resolve(nil) - } - - @objc - func trackScreenLeave(_ token: String, screenName: String, - properties: [String: Any]? = nil, - resolver resolve: RCTPromiseResolveBlock, - rejecter reject: RCTPromiseRejectBlock) -> Void { - let instance = MixpanelReactNative.getMixpanelInstance(token) - let mpProperties = MixpanelTypeHandler.processProperties(properties: properties) - instance?.autocapture.trackScreenLeave(screenName: screenName, properties: mpProperties) - resolve(nil) - } - // MARK: - Timing Events @objc