@repliqo/sdk-react-native 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,8 +4,10 @@ import { ScrollView, type ScrollViewProps } from 'react-native';
4
4
  * Drop-in replacement for React Native's ScrollView that:
5
5
  *
6
6
  * 1. Reports scroll offset for accurate heatmap touch coords
7
- * 2. Captures viewport tiles on scroll stops for collaborative
8
- * screen building the backend composites these into a
9
- * full-content image over time from multiple user sessions
7
+ * 2. Captures viewport tiles on scroll stops AND during continuous
8
+ * scroll for collaborative screen building
9
+ * 3. Registers its window-relative bounds with the SDK so touches can
10
+ * be auto-classified as "content" (inside ScrollView) or "fixed"
11
+ * (outside, e.g. nav bars/headers)
10
12
  */
11
13
  export declare const RepliqoScrollView: React.ForwardRefExoticComponent<ScrollViewProps & React.RefAttributes<ScrollView>>;
@@ -40,41 +40,91 @@ const client_1 = require("../core/client");
40
40
  /** Minimum scroll delta (px) to trigger a new tile capture. */
41
41
  const MIN_DELTA_PX = 20;
42
42
  /** Debounce: wait this long after scroll stops before capturing. */
43
- const DEBOUNCE_MS = 300;
43
+ const DEBOUNCE_MS = 250;
44
44
  /** Throttle: minimum time between tile captures. */
45
- const THROTTLE_MS = 500;
45
+ const THROTTLE_MS = 400;
46
+ /**
47
+ * During continuous scroll, force a capture every this many ms.
48
+ * Ensures tiles are captured even during fast, uninterrupted scrolls
49
+ * where the user never pauses long enough for the debounce to fire.
50
+ */
51
+ const SCROLL_INTERVAL_MS = 1200;
52
+ let scrollViewIdCounter = 0;
53
+ const nextScrollViewId = () => `repliqo-scroll-${++scrollViewIdCounter}`;
46
54
  /**
47
55
  * Drop-in replacement for React Native's ScrollView that:
48
56
  *
49
57
  * 1. Reports scroll offset for accurate heatmap touch coords
50
- * 2. Captures viewport tiles on scroll stops for collaborative
51
- * screen building the backend composites these into a
52
- * full-content image over time from multiple user sessions
58
+ * 2. Captures viewport tiles on scroll stops AND during continuous
59
+ * scroll for collaborative screen building
60
+ * 3. Registers its window-relative bounds with the SDK so touches can
61
+ * be auto-classified as "content" (inside ScrollView) or "fixed"
62
+ * (outside, e.g. nav bars/headers)
53
63
  */
54
- exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, ...props }, ref) => {
64
+ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, onContentSizeChange, ...props }, ref) => {
55
65
  const debounceTimer = (0, react_1.useRef)(null);
66
+ const scrollIntervalTimer = (0, react_1.useRef)(null);
56
67
  const lastCaptureTime = (0, react_1.useRef)(0);
57
68
  const lastCaptureY = (0, react_1.useRef)(-999);
58
69
  const initialCaptured = (0, react_1.useRef)(false);
70
+ const realContentHeight = (0, react_1.useRef)(0);
71
+ const scrollViewId = (0, react_1.useRef)(nextScrollViewId());
72
+ const innerRef = (0, react_1.useRef)(null);
59
73
  const scrollState = (0, react_1.useRef)(null);
60
- // Cleanup debounce timer on unmount
74
+ // Forward the inner ref to the parent's ref (if provided)
75
+ const setRef = (0, react_1.useCallback)((node) => {
76
+ innerRef.current = node;
77
+ if (typeof ref === 'function') {
78
+ ref(node);
79
+ }
80
+ else if (ref) {
81
+ ref.current = node;
82
+ }
83
+ }, [ref]);
84
+ /** Measure window-relative bounds and register with SDK. */
85
+ const measureAndRegister = (0, react_1.useCallback)(() => {
86
+ const node = innerRef.current;
87
+ if (!node || typeof node.measureInWindow !== 'function')
88
+ return;
89
+ node.measureInWindow((x, y, width, height) => {
90
+ if (width <= 0 || height <= 0)
91
+ return;
92
+ try {
93
+ client_1.AppAnalytics.getInstance().registerScrollView(scrollViewId.current, {
94
+ x, y, width, height,
95
+ });
96
+ }
97
+ catch { }
98
+ });
99
+ }, []);
100
+ // Cleanup timers + unregister on unmount
61
101
  (0, react_1.useEffect)(() => {
102
+ const id = scrollViewId.current;
62
103
  return () => {
63
104
  if (debounceTimer.current)
64
105
  clearTimeout(debounceTimer.current);
106
+ if (scrollIntervalTimer.current)
107
+ clearInterval(scrollIntervalTimer.current);
108
+ try {
109
+ client_1.AppAnalytics.getInstance().unregisterScrollView(id);
110
+ }
111
+ catch { }
65
112
  };
66
113
  }, []);
67
114
  /**
68
115
  * Core tile capture logic. Reads from refs (stable across renders).
69
116
  * Checks throttle + delta before capturing.
117
+ *
118
+ * @param force Skip throttle check (used for interval captures)
70
119
  */
71
- const doCapture = () => {
120
+ const doCapture = (force = false) => {
72
121
  const state = scrollState.current;
73
122
  if (!state || state.viewportHeight <= 0)
74
123
  return;
75
124
  const now = Date.now();
76
- if (now - lastCaptureTime.current < THROTTLE_MS)
125
+ if (!force && now - lastCaptureTime.current < THROTTLE_MS)
77
126
  return;
127
+ // Always check delta — no point re-capturing the same position
78
128
  if (Math.abs(state.offsetY - lastCaptureY.current) < MIN_DELTA_PX &&
79
129
  initialCaptured.current)
80
130
  return;
@@ -86,32 +136,55 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
86
136
  }
87
137
  catch { }
88
138
  };
139
+ /** Start the interval timer for continuous scroll captures. */
140
+ const startScrollInterval = () => {
141
+ if (scrollIntervalTimer.current !== null)
142
+ return;
143
+ scrollIntervalTimer.current = setInterval(() => {
144
+ doCapture(true); // force=true skips throttle
145
+ }, SCROLL_INTERVAL_MS);
146
+ };
147
+ /** Stop the interval timer. */
148
+ const stopScrollInterval = () => {
149
+ if (scrollIntervalTimer.current !== null) {
150
+ clearInterval(scrollIntervalTimer.current);
151
+ scrollIntervalTimer.current = null;
152
+ }
153
+ };
89
154
  const handleScroll = (0, react_1.useCallback)((event) => {
90
155
  const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent;
91
- // Report scroll offset for touch coords
156
+ // Update SDK with current scroll offset (both legacy + new registry)
92
157
  try {
93
- client_1.AppAnalytics.getInstance().setScrollOffset(contentOffset.y);
158
+ const sdk = client_1.AppAnalytics.getInstance();
159
+ sdk.setScrollOffset(contentOffset.y);
160
+ sdk.updateScrollViewOffset(scrollViewId.current, contentOffset.y);
94
161
  }
95
162
  catch { }
96
- // Update scroll state
163
+ // Update scroll state — use realContentHeight if available
164
+ const bestContentHeight = realContentHeight.current > 0
165
+ ? realContentHeight.current
166
+ : contentSize.height;
97
167
  scrollState.current = {
98
168
  offsetY: contentOffset.y,
99
169
  viewportHeight: layoutMeasurement.height,
100
- contentHeight: contentSize.height,
170
+ contentHeight: bestContentHeight,
101
171
  };
102
172
  // Debounce: capture after scroll stops
103
173
  if (debounceTimer.current)
104
174
  clearTimeout(debounceTimer.current);
105
- debounceTimer.current = setTimeout(doCapture, DEBOUNCE_MS);
106
- // Capture initial tile (Y≈0) on first scroll event with real dimensions
175
+ debounceTimer.current = setTimeout(() => doCapture(), DEBOUNCE_MS);
176
+ // Start interval for continuous scroll captures
177
+ startScrollInterval();
178
+ // Capture initial tile (Y~0) on first scroll event with real dims
107
179
  if (!initialCaptured.current && contentOffset.y < 10) {
108
- setTimeout(doCapture, 200);
180
+ setTimeout(() => doCapture(), 200);
109
181
  }
110
182
  onScroll?.(event);
111
183
  }, [onScroll]);
112
184
  const handleScrollEndDrag = (0, react_1.useCallback)((event) => {
113
185
  // User lifted finger — capture immediately
114
- doCapture();
186
+ stopScrollInterval();
187
+ doCapture(true);
115
188
  onScrollEndDrag?.(event);
116
189
  }, [onScrollEndDrag]);
117
190
  const handleMomentumEnd = (0, react_1.useCallback)((event) => {
@@ -119,7 +192,8 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
119
192
  clearTimeout(debounceTimer.current);
120
193
  debounceTimer.current = null;
121
194
  }
122
- doCapture();
195
+ stopScrollInterval();
196
+ doCapture(true);
123
197
  onMomentumScrollEnd?.(event);
124
198
  }, [onMomentumScrollEnd]);
125
199
  const handleLayout = (0, react_1.useCallback)((event) => {
@@ -129,12 +203,29 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
129
203
  scrollState.current = {
130
204
  offsetY: 0,
131
205
  viewportHeight: height,
132
- contentHeight: height, // Will be updated on first scroll
206
+ contentHeight: realContentHeight.current || height,
133
207
  };
134
- setTimeout(doCapture, 1000);
208
+ setTimeout(() => doCapture(), 1000);
135
209
  }
210
+ // Register window-relative bounds with the SDK.
211
+ // Defer measurement to next tick — onLayout fires before the view
212
+ // is positioned in the window on some Android versions.
213
+ setTimeout(measureAndRegister, 50);
136
214
  onLayout?.(event);
137
- }, [onLayout]);
138
- return (<react_native_1.ScrollView ref={ref} {...props} onScroll={handleScroll} onScrollEndDrag={handleScrollEndDrag} onMomentumScrollEnd={handleMomentumEnd} onLayout={handleLayout} scrollEventThrottle={props.scrollEventThrottle ?? 16}/>);
215
+ }, [onLayout, measureAndRegister]);
216
+ /**
217
+ * Track the real content height from onContentSizeChange.
218
+ * This is more reliable than the first scroll event and ensures
219
+ * the initial tile at Y=0 has correct contentHeight metadata.
220
+ */
221
+ const handleContentSizeChange = (0, react_1.useCallback)((w, h) => {
222
+ realContentHeight.current = h;
223
+ // Update scroll state if it exists
224
+ if (scrollState.current) {
225
+ scrollState.current.contentHeight = h;
226
+ }
227
+ onContentSizeChange?.(w, h);
228
+ }, [onContentSizeChange]);
229
+ return (<react_native_1.ScrollView ref={setRef} {...props} onScroll={handleScroll} onScrollEndDrag={handleScrollEndDrag} onMomentumScrollEnd={handleMomentumEnd} onLayout={handleLayout} onContentSizeChange={handleContentSizeChange} scrollEventThrottle={props.scrollEventThrottle ?? 16}/>);
139
230
  });
140
231
  exports.RepliqoScrollView.displayName = 'RepliqoScrollView';
@@ -14,6 +14,18 @@ export declare class AppAnalytics {
14
14
  private scrollOffsetY;
15
15
  private logger;
16
16
  private appStateSubscription;
17
+ /**
18
+ * Registry of active ScrollViews on the current screen.
19
+ * Each RepliqoScrollView registers its window-relative bounds and current
20
+ * scroll state. Used by trackTouch to auto-detect:
21
+ * - If touch is INSIDE a ScrollView → adjust Y by that ScrollView's
22
+ * scrollOffset, mark as content touch
23
+ * - If touch is OUTSIDE all ScrollViews → mark as fixed UI (no adjust)
24
+ *
25
+ * This makes heatmaps render fixed elements (nav bars, headers) at their
26
+ * viewport position, while still mapping scrollable content correctly.
27
+ */
28
+ private scrollViewRegistry;
17
29
  private constructor();
18
30
  static init(config: SDKConfig): AppAnalytics;
19
31
  static getInstance(): AppAnalytics;
@@ -29,8 +41,32 @@ export declare class AppAnalytics {
29
41
  * or manually from the host app's ScrollView onScroll handler.
30
42
  * The offset is added to touch Y coordinates for accurate heatmaps
31
43
  * on scrollable screens.
44
+ *
45
+ * @deprecated Prefer registerScrollView + updateScrollViewOffset for
46
+ * auto-detection of fixed vs scrollable touches. Kept for backward compat.
32
47
  */
33
48
  setScrollOffset(y: number): void;
49
+ /**
50
+ * Register a ScrollView's window-relative bounds with the SDK.
51
+ * Called by RepliqoScrollView on layout. Used to auto-detect whether
52
+ * a touch is on scrollable content or on fixed UI (nav bars, headers).
53
+ */
54
+ registerScrollView(id: string, bounds: {
55
+ x: number;
56
+ y: number;
57
+ width: number;
58
+ height: number;
59
+ }): void;
60
+ /**
61
+ * Update the scroll offset for a previously-registered ScrollView.
62
+ * Called by RepliqoScrollView on every scroll event.
63
+ */
64
+ updateScrollViewOffset(id: string, scrollOffsetY: number): void;
65
+ /**
66
+ * Remove a ScrollView from the registry. Called by RepliqoScrollView
67
+ * on unmount.
68
+ */
69
+ unregisterScrollView(id: string): void;
34
70
  /**
35
71
  * Capture the current viewport as a tile for collaborative screen
36
72
  * building. Called by RepliqoScrollView on scroll stops.
@@ -18,6 +18,18 @@ class AppAnalytics {
18
18
  this.currentScreenEnteredAt = null;
19
19
  this.scrollOffsetY = 0;
20
20
  this.appStateSubscription = null;
21
+ /**
22
+ * Registry of active ScrollViews on the current screen.
23
+ * Each RepliqoScrollView registers its window-relative bounds and current
24
+ * scroll state. Used by trackTouch to auto-detect:
25
+ * - If touch is INSIDE a ScrollView → adjust Y by that ScrollView's
26
+ * scrollOffset, mark as content touch
27
+ * - If touch is OUTSIDE all ScrollViews → mark as fixed UI (no adjust)
28
+ *
29
+ * This makes heatmaps render fixed elements (nav bars, headers) at their
30
+ * viewport position, while still mapping scrollable content correctly.
31
+ */
32
+ this.scrollViewRegistry = new Map();
21
33
  this.config = (0, config_1.resolveConfig)(config);
22
34
  this.logger = new logger_1.Logger(this.config.debug);
23
35
  this.apiClient = new api_client_1.ApiClient(config_1.REPLIQO_API_URL, this.config.apiKey, this.logger);
@@ -163,14 +175,41 @@ class AppAnalytics {
163
175
  if (!this.config.enableTouchTracking) {
164
176
  return;
165
177
  }
166
- // Adjust Y by the current scroll offset so touches map to content
167
- // position, not just viewport position. This makes heatmaps accurate
168
- // for scrollable screens.
169
- const adjustedY = y + this.scrollOffsetY;
178
+ // Auto-detect: find which (if any) registered ScrollView contains
179
+ // this touch. If inside, adjust Y by that ScrollView's scroll offset.
180
+ // If outside all ScrollViews, treat as fixed UI (no adjustment).
181
+ let activeScrollOffset = 0;
182
+ let isFixed = true;
183
+ if (this.scrollViewRegistry.size > 0) {
184
+ for (const info of this.scrollViewRegistry.values()) {
185
+ const { bounds } = info;
186
+ if (x >= bounds.x &&
187
+ x <= bounds.x + bounds.width &&
188
+ y >= bounds.y &&
189
+ y <= bounds.y + bounds.height) {
190
+ activeScrollOffset = info.scrollOffsetY;
191
+ isFixed = false;
192
+ break;
193
+ }
194
+ }
195
+ }
196
+ else {
197
+ // No ScrollViews registered → fall back to legacy behavior
198
+ // (use the manually-set scrollOffsetY, treat as content touch).
199
+ activeScrollOffset = this.scrollOffsetY;
200
+ isFixed = false;
201
+ }
202
+ const adjustedY = y + activeScrollOffset;
170
203
  const event = {
171
204
  type: 'touch',
172
205
  screenName: screenName || this.currentScreen || undefined,
173
- data: { x, y: adjustedY, scrollOffsetY: this.scrollOffsetY, ...extra },
206
+ data: {
207
+ x,
208
+ y: adjustedY,
209
+ scrollOffsetY: activeScrollOffset,
210
+ isFixed,
211
+ ...extra,
212
+ },
174
213
  timestamp: new Date().toISOString(),
175
214
  };
176
215
  this.eventQueue.add(event);
@@ -219,10 +258,42 @@ class AppAnalytics {
219
258
  * or manually from the host app's ScrollView onScroll handler.
220
259
  * The offset is added to touch Y coordinates for accurate heatmaps
221
260
  * on scrollable screens.
261
+ *
262
+ * @deprecated Prefer registerScrollView + updateScrollViewOffset for
263
+ * auto-detection of fixed vs scrollable touches. Kept for backward compat.
222
264
  */
223
265
  setScrollOffset(y) {
224
266
  this.scrollOffsetY = y;
225
267
  }
268
+ /**
269
+ * Register a ScrollView's window-relative bounds with the SDK.
270
+ * Called by RepliqoScrollView on layout. Used to auto-detect whether
271
+ * a touch is on scrollable content or on fixed UI (nav bars, headers).
272
+ */
273
+ registerScrollView(id, bounds) {
274
+ const existing = this.scrollViewRegistry.get(id);
275
+ this.scrollViewRegistry.set(id, {
276
+ bounds,
277
+ scrollOffsetY: existing?.scrollOffsetY ?? 0,
278
+ });
279
+ }
280
+ /**
281
+ * Update the scroll offset for a previously-registered ScrollView.
282
+ * Called by RepliqoScrollView on every scroll event.
283
+ */
284
+ updateScrollViewOffset(id, scrollOffsetY) {
285
+ const existing = this.scrollViewRegistry.get(id);
286
+ if (existing) {
287
+ existing.scrollOffsetY = scrollOffsetY;
288
+ }
289
+ }
290
+ /**
291
+ * Remove a ScrollView from the registry. Called by RepliqoScrollView
292
+ * on unmount.
293
+ */
294
+ unregisterScrollView(id) {
295
+ this.scrollViewRegistry.delete(id);
296
+ }
226
297
  /**
227
298
  * Capture the current viewport as a tile for collaborative screen
228
299
  * building. Called by RepliqoScrollView on scroll stops.
@@ -282,18 +353,18 @@ class AppAnalytics {
282
353
  this.currentScreen = screenName;
283
354
  this.currentScreenEnteredAt = new Date().toISOString();
284
355
  this.scrollOffsetY = 0;
356
+ // Clear the ScrollView registry — old screen's scrollviews are gone
357
+ this.scrollViewRegistry.clear();
285
358
  this.errorTracker?.setCurrentScreen(screenName);
286
359
  this.snapshotCapture?.setCurrentScreen(screenName);
287
- // Auto-scan with PixelCopy: captures entire ScrollView content
288
- // by scrolling programmatically + PixelCopy from GPU framebuffer.
289
- // Fires 2s after screen enter to let content fully render.
290
- if (this.config.enableHeatmapCapture) {
291
- setTimeout(() => {
292
- if (this.currentScreen !== screenName || !this.sessionId)
293
- return;
294
- this.autoScanScreen(screenName);
295
- }, 2000);
296
- }
360
+ // NOTE: autoScanScreen (PixelCopy / programmatic scroll) is DISABLED.
361
+ // React Native does NOT render off-screen content to the GPU or view
362
+ // tree, so programmatic scroll + View.draw()/PixelCopy produces blank
363
+ // tiles. Worse, those blank tiles poison the dedup cache and block
364
+ // real tiles captured during natural user scroll.
365
+ //
366
+ // Full-content capture relies entirely on RepliqoScrollView capturing
367
+ // viewport tiles as the user naturally scrolls through the content.
297
368
  this.logger.log('Screen entered:', screenName);
298
369
  }
299
370
  onScreenExit(screenName) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@repliqo/sdk-react-native",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Session replay & analytics SDK for React Native. Captures screenshots (including modals/alerts), navigation, screen visits, and custom events.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -11,47 +11,95 @@ import { AppAnalytics } from '../core/client';
11
11
  /** Minimum scroll delta (px) to trigger a new tile capture. */
12
12
  const MIN_DELTA_PX = 20;
13
13
  /** Debounce: wait this long after scroll stops before capturing. */
14
- const DEBOUNCE_MS = 300;
14
+ const DEBOUNCE_MS = 250;
15
15
  /** Throttle: minimum time between tile captures. */
16
- const THROTTLE_MS = 500;
16
+ const THROTTLE_MS = 400;
17
+ /**
18
+ * During continuous scroll, force a capture every this many ms.
19
+ * Ensures tiles are captured even during fast, uninterrupted scrolls
20
+ * where the user never pauses long enough for the debounce to fire.
21
+ */
22
+ const SCROLL_INTERVAL_MS = 1200;
23
+
24
+ let scrollViewIdCounter = 0;
25
+ const nextScrollViewId = () => `repliqo-scroll-${++scrollViewIdCounter}`;
17
26
 
18
27
  /**
19
28
  * Drop-in replacement for React Native's ScrollView that:
20
29
  *
21
30
  * 1. Reports scroll offset for accurate heatmap touch coords
22
- * 2. Captures viewport tiles on scroll stops for collaborative
23
- * screen building the backend composites these into a
24
- * full-content image over time from multiple user sessions
31
+ * 2. Captures viewport tiles on scroll stops AND during continuous
32
+ * scroll for collaborative screen building
33
+ * 3. Registers its window-relative bounds with the SDK so touches can
34
+ * be auto-classified as "content" (inside ScrollView) or "fixed"
35
+ * (outside, e.g. nav bars/headers)
25
36
  */
26
37
  export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
27
- ({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, ...props }, ref) => {
38
+ ({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, onContentSizeChange, ...props }, ref) => {
28
39
  const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
40
+ const scrollIntervalTimer = useRef<ReturnType<typeof setInterval> | null>(null);
29
41
  const lastCaptureTime = useRef(0);
30
42
  const lastCaptureY = useRef(-999);
31
43
  const initialCaptured = useRef(false);
44
+ const realContentHeight = useRef(0);
45
+ const scrollViewId = useRef<string>(nextScrollViewId());
46
+ const innerRef = useRef<ScrollView | null>(null);
32
47
  const scrollState = useRef<{
33
48
  offsetY: number;
34
49
  viewportHeight: number;
35
50
  contentHeight: number;
36
51
  } | null>(null);
37
52
 
38
- // Cleanup debounce timer on unmount
53
+ // Forward the inner ref to the parent's ref (if provided)
54
+ const setRef = useCallback((node: ScrollView | null) => {
55
+ innerRef.current = node;
56
+ if (typeof ref === 'function') {
57
+ ref(node);
58
+ } else if (ref) {
59
+ (ref as React.MutableRefObject<ScrollView | null>).current = node;
60
+ }
61
+ }, [ref]);
62
+
63
+ /** Measure window-relative bounds and register with SDK. */
64
+ const measureAndRegister = useCallback(() => {
65
+ const node = innerRef.current as any;
66
+ if (!node || typeof node.measureInWindow !== 'function') return;
67
+ node.measureInWindow((x: number, y: number, width: number, height: number) => {
68
+ if (width <= 0 || height <= 0) return;
69
+ try {
70
+ AppAnalytics.getInstance().registerScrollView(scrollViewId.current, {
71
+ x, y, width, height,
72
+ });
73
+ } catch {}
74
+ });
75
+ }, []);
76
+
77
+ // Cleanup timers + unregister on unmount
39
78
  useEffect(() => {
79
+ const id = scrollViewId.current;
40
80
  return () => {
41
81
  if (debounceTimer.current) clearTimeout(debounceTimer.current);
82
+ if (scrollIntervalTimer.current) clearInterval(scrollIntervalTimer.current);
83
+ try {
84
+ AppAnalytics.getInstance().unregisterScrollView(id);
85
+ } catch {}
42
86
  };
43
87
  }, []);
44
88
 
45
89
  /**
46
90
  * Core tile capture logic. Reads from refs (stable across renders).
47
91
  * Checks throttle + delta before capturing.
92
+ *
93
+ * @param force Skip throttle check (used for interval captures)
48
94
  */
49
- const doCapture = () => {
95
+ const doCapture = (force = false) => {
50
96
  const state = scrollState.current;
51
97
  if (!state || state.viewportHeight <= 0) return;
52
98
 
53
99
  const now = Date.now();
54
- if (now - lastCaptureTime.current < THROTTLE_MS) return;
100
+ if (!force && now - lastCaptureTime.current < THROTTLE_MS) return;
101
+
102
+ // Always check delta — no point re-capturing the same position
55
103
  if (Math.abs(state.offsetY - lastCaptureY.current) < MIN_DELTA_PX &&
56
104
  initialCaptured.current) return;
57
105
 
@@ -68,30 +116,54 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
68
116
  } catch {}
69
117
  };
70
118
 
119
+ /** Start the interval timer for continuous scroll captures. */
120
+ const startScrollInterval = () => {
121
+ if (scrollIntervalTimer.current !== null) return;
122
+ scrollIntervalTimer.current = setInterval(() => {
123
+ doCapture(true); // force=true skips throttle
124
+ }, SCROLL_INTERVAL_MS);
125
+ };
126
+
127
+ /** Stop the interval timer. */
128
+ const stopScrollInterval = () => {
129
+ if (scrollIntervalTimer.current !== null) {
130
+ clearInterval(scrollIntervalTimer.current);
131
+ scrollIntervalTimer.current = null;
132
+ }
133
+ };
134
+
71
135
  const handleScroll = useCallback(
72
136
  (event: NativeSyntheticEvent<NativeScrollEvent>) => {
73
137
  const { contentOffset, layoutMeasurement, contentSize } =
74
138
  event.nativeEvent;
75
139
 
76
- // Report scroll offset for touch coords
140
+ // Update SDK with current scroll offset (both legacy + new registry)
77
141
  try {
78
- AppAnalytics.getInstance().setScrollOffset(contentOffset.y);
142
+ const sdk = AppAnalytics.getInstance();
143
+ sdk.setScrollOffset(contentOffset.y);
144
+ sdk.updateScrollViewOffset(scrollViewId.current, contentOffset.y);
79
145
  } catch {}
80
146
 
81
- // Update scroll state
147
+ // Update scroll state — use realContentHeight if available
148
+ const bestContentHeight = realContentHeight.current > 0
149
+ ? realContentHeight.current
150
+ : contentSize.height;
82
151
  scrollState.current = {
83
152
  offsetY: contentOffset.y,
84
153
  viewportHeight: layoutMeasurement.height,
85
- contentHeight: contentSize.height,
154
+ contentHeight: bestContentHeight,
86
155
  };
87
156
 
88
157
  // Debounce: capture after scroll stops
89
158
  if (debounceTimer.current) clearTimeout(debounceTimer.current);
90
- debounceTimer.current = setTimeout(doCapture, DEBOUNCE_MS);
159
+ debounceTimer.current = setTimeout(() => doCapture(), DEBOUNCE_MS);
91
160
 
92
- // Capture initial tile (Y≈0) on first scroll event with real dimensions
161
+ // Start interval for continuous scroll captures
162
+ startScrollInterval();
163
+
164
+ // Capture initial tile (Y~0) on first scroll event with real dims
93
165
  if (!initialCaptured.current && contentOffset.y < 10) {
94
- setTimeout(doCapture, 200);
166
+ setTimeout(() => doCapture(), 200);
95
167
  }
96
168
 
97
169
  onScroll?.(event);
@@ -102,7 +174,8 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
102
174
  const handleScrollEndDrag = useCallback(
103
175
  (event: NativeSyntheticEvent<NativeScrollEvent>) => {
104
176
  // User lifted finger — capture immediately
105
- doCapture();
177
+ stopScrollInterval();
178
+ doCapture(true);
106
179
  onScrollEndDrag?.(event);
107
180
  },
108
181
  [onScrollEndDrag],
@@ -114,7 +187,8 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
114
187
  clearTimeout(debounceTimer.current);
115
188
  debounceTimer.current = null;
116
189
  }
117
- doCapture();
190
+ stopScrollInterval();
191
+ doCapture(true);
118
192
  onMomentumScrollEnd?.(event);
119
193
  },
120
194
  [onMomentumScrollEnd],
@@ -128,23 +202,45 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
128
202
  scrollState.current = {
129
203
  offsetY: 0,
130
204
  viewportHeight: height,
131
- contentHeight: height, // Will be updated on first scroll
205
+ contentHeight: realContentHeight.current || height,
132
206
  };
133
- setTimeout(doCapture, 1000);
207
+ setTimeout(() => doCapture(), 1000);
134
208
  }
209
+ // Register window-relative bounds with the SDK.
210
+ // Defer measurement to next tick — onLayout fires before the view
211
+ // is positioned in the window on some Android versions.
212
+ setTimeout(measureAndRegister, 50);
135
213
  onLayout?.(event);
136
214
  },
137
- [onLayout],
215
+ [onLayout, measureAndRegister],
216
+ );
217
+
218
+ /**
219
+ * Track the real content height from onContentSizeChange.
220
+ * This is more reliable than the first scroll event and ensures
221
+ * the initial tile at Y=0 has correct contentHeight metadata.
222
+ */
223
+ const handleContentSizeChange = useCallback(
224
+ (w: number, h: number) => {
225
+ realContentHeight.current = h;
226
+ // Update scroll state if it exists
227
+ if (scrollState.current) {
228
+ scrollState.current.contentHeight = h;
229
+ }
230
+ onContentSizeChange?.(w, h);
231
+ },
232
+ [onContentSizeChange],
138
233
  );
139
234
 
140
235
  return (
141
236
  <ScrollView
142
- ref={ref}
237
+ ref={setRef}
143
238
  {...props}
144
239
  onScroll={handleScroll}
145
240
  onScrollEndDrag={handleScrollEndDrag}
146
241
  onMomentumScrollEnd={handleMomentumEnd}
147
242
  onLayout={handleLayout}
243
+ onContentSizeChange={handleContentSizeChange}
148
244
  scrollEventThrottle={props.scrollEventThrottle ?? 16}
149
245
  />
150
246
  );
@@ -31,6 +31,25 @@ export class AppAnalytics {
31
31
  private logger: Logger;
32
32
  private appStateSubscription: { remove: () => void } | null = null;
33
33
 
34
+ /**
35
+ * Registry of active ScrollViews on the current screen.
36
+ * Each RepliqoScrollView registers its window-relative bounds and current
37
+ * scroll state. Used by trackTouch to auto-detect:
38
+ * - If touch is INSIDE a ScrollView → adjust Y by that ScrollView's
39
+ * scrollOffset, mark as content touch
40
+ * - If touch is OUTSIDE all ScrollViews → mark as fixed UI (no adjust)
41
+ *
42
+ * This makes heatmaps render fixed elements (nav bars, headers) at their
43
+ * viewport position, while still mapping scrollable content correctly.
44
+ */
45
+ private scrollViewRegistry = new Map<
46
+ string,
47
+ {
48
+ bounds: { x: number; y: number; width: number; height: number };
49
+ scrollOffsetY: number;
50
+ }
51
+ >();
52
+
34
53
  private constructor(config: SDKConfig) {
35
54
  this.config = resolveConfig(config);
36
55
  this.logger = new Logger(this.config.debug);
@@ -246,15 +265,45 @@ export class AppAnalytics {
246
265
  return;
247
266
  }
248
267
 
249
- // Adjust Y by the current scroll offset so touches map to content
250
- // position, not just viewport position. This makes heatmaps accurate
251
- // for scrollable screens.
252
- const adjustedY = y + this.scrollOffsetY;
268
+ // Auto-detect: find which (if any) registered ScrollView contains
269
+ // this touch. If inside, adjust Y by that ScrollView's scroll offset.
270
+ // If outside all ScrollViews, treat as fixed UI (no adjustment).
271
+ let activeScrollOffset = 0;
272
+ let isFixed = true;
273
+
274
+ if (this.scrollViewRegistry.size > 0) {
275
+ for (const info of this.scrollViewRegistry.values()) {
276
+ const { bounds } = info;
277
+ if (
278
+ x >= bounds.x &&
279
+ x <= bounds.x + bounds.width &&
280
+ y >= bounds.y &&
281
+ y <= bounds.y + bounds.height
282
+ ) {
283
+ activeScrollOffset = info.scrollOffsetY;
284
+ isFixed = false;
285
+ break;
286
+ }
287
+ }
288
+ } else {
289
+ // No ScrollViews registered → fall back to legacy behavior
290
+ // (use the manually-set scrollOffsetY, treat as content touch).
291
+ activeScrollOffset = this.scrollOffsetY;
292
+ isFixed = false;
293
+ }
294
+
295
+ const adjustedY = y + activeScrollOffset;
253
296
 
254
297
  const event: AnalyticsEvent = {
255
298
  type: 'touch',
256
299
  screenName: screenName || this.currentScreen || undefined,
257
- data: { x, y: adjustedY, scrollOffsetY: this.scrollOffsetY, ...extra },
300
+ data: {
301
+ x,
302
+ y: adjustedY,
303
+ scrollOffsetY: activeScrollOffset,
304
+ isFixed,
305
+ ...extra,
306
+ },
258
307
  timestamp: new Date().toISOString(),
259
308
  };
260
309
 
@@ -316,11 +365,49 @@ export class AppAnalytics {
316
365
  * or manually from the host app's ScrollView onScroll handler.
317
366
  * The offset is added to touch Y coordinates for accurate heatmaps
318
367
  * on scrollable screens.
368
+ *
369
+ * @deprecated Prefer registerScrollView + updateScrollViewOffset for
370
+ * auto-detection of fixed vs scrollable touches. Kept for backward compat.
319
371
  */
320
372
  setScrollOffset(y: number): void {
321
373
  this.scrollOffsetY = y;
322
374
  }
323
375
 
376
+ /**
377
+ * Register a ScrollView's window-relative bounds with the SDK.
378
+ * Called by RepliqoScrollView on layout. Used to auto-detect whether
379
+ * a touch is on scrollable content or on fixed UI (nav bars, headers).
380
+ */
381
+ registerScrollView(
382
+ id: string,
383
+ bounds: { x: number; y: number; width: number; height: number },
384
+ ): void {
385
+ const existing = this.scrollViewRegistry.get(id);
386
+ this.scrollViewRegistry.set(id, {
387
+ bounds,
388
+ scrollOffsetY: existing?.scrollOffsetY ?? 0,
389
+ });
390
+ }
391
+
392
+ /**
393
+ * Update the scroll offset for a previously-registered ScrollView.
394
+ * Called by RepliqoScrollView on every scroll event.
395
+ */
396
+ updateScrollViewOffset(id: string, scrollOffsetY: number): void {
397
+ const existing = this.scrollViewRegistry.get(id);
398
+ if (existing) {
399
+ existing.scrollOffsetY = scrollOffsetY;
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Remove a ScrollView from the registry. Called by RepliqoScrollView
405
+ * on unmount.
406
+ */
407
+ unregisterScrollView(id: string): void {
408
+ this.scrollViewRegistry.delete(id);
409
+ }
410
+
324
411
  /**
325
412
  * Capture the current viewport as a tile for collaborative screen
326
413
  * building. Called by RepliqoScrollView on scroll stops.
@@ -414,17 +501,18 @@ export class AppAnalytics {
414
501
  this.currentScreen = screenName;
415
502
  this.currentScreenEnteredAt = new Date().toISOString();
416
503
  this.scrollOffsetY = 0;
504
+ // Clear the ScrollView registry — old screen's scrollviews are gone
505
+ this.scrollViewRegistry.clear();
417
506
  this.errorTracker?.setCurrentScreen(screenName);
418
507
  this.snapshotCapture?.setCurrentScreen(screenName);
419
- // Auto-scan with PixelCopy: captures entire ScrollView content
420
- // by scrolling programmatically + PixelCopy from GPU framebuffer.
421
- // Fires 2s after screen enter to let content fully render.
422
- if (this.config.enableHeatmapCapture) {
423
- setTimeout(() => {
424
- if (this.currentScreen !== screenName || !this.sessionId) return;
425
- this.autoScanScreen(screenName);
426
- }, 2000);
427
- }
508
+ // NOTE: autoScanScreen (PixelCopy / programmatic scroll) is DISABLED.
509
+ // React Native does NOT render off-screen content to the GPU or view
510
+ // tree, so programmatic scroll + View.draw()/PixelCopy produces blank
511
+ // tiles. Worse, those blank tiles poison the dedup cache and block
512
+ // real tiles captured during natural user scroll.
513
+ //
514
+ // Full-content capture relies entirely on RepliqoScrollView capturing
515
+ // viewport tiles as the user naturally scrolls through the content.
428
516
 
429
517
  this.logger.log('Screen entered:', screenName);
430
518
  }