@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.
- package/dist/components/RepliqoScrollView.d.ts +5 -3
- package/dist/components/RepliqoScrollView.js +113 -22
- package/dist/core/client.d.ts +36 -0
- package/dist/core/client.js +86 -15
- package/package.json +1 -1
- package/src/components/RepliqoScrollView.tsx +118 -22
- package/src/core/client.ts +102 -14
|
@@ -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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 =
|
|
43
|
+
const DEBOUNCE_MS = 250;
|
|
44
44
|
/** Throttle: minimum time between tile captures. */
|
|
45
|
-
const THROTTLE_MS =
|
|
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
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
156
|
+
// Update SDK with current scroll offset (both legacy + new registry)
|
|
92
157
|
try {
|
|
93
|
-
client_1.AppAnalytics.getInstance()
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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';
|
package/dist/core/client.d.ts
CHANGED
|
@@ -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.
|
package/dist/core/client.js
CHANGED
|
@@ -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
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
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: {
|
|
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
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
+
"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 =
|
|
14
|
+
const DEBOUNCE_MS = 250;
|
|
15
15
|
/** Throttle: minimum time between tile captures. */
|
|
16
|
-
const THROTTLE_MS =
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
140
|
+
// Update SDK with current scroll offset (both legacy + new registry)
|
|
77
141
|
try {
|
|
78
|
-
AppAnalytics.getInstance()
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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={
|
|
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
|
);
|
package/src/core/client.ts
CHANGED
|
@@ -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
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
|
|
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: {
|
|
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
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
}
|