@repliqo/sdk-react-native 0.3.5 → 0.3.6
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.js +58 -13
- package/dist/core/client.d.ts +14 -0
- package/dist/core/client.js +51 -18
- package/package.json +1 -1
- package/src/components/RepliqoScrollView.tsx +56 -13
- package/src/core/client.ts +55 -21
|
@@ -49,6 +49,12 @@ const THROTTLE_MS = 400;
|
|
|
49
49
|
* where the user never pauses long enough for the debounce to fire.
|
|
50
50
|
*/
|
|
51
51
|
const SCROLL_INTERVAL_MS = 1200;
|
|
52
|
+
/**
|
|
53
|
+
* During scroll, throttle window-bounds re-measurement to at most once per
|
|
54
|
+
* this many ms. Bounds rarely change while scrolling, but we still want to
|
|
55
|
+
* catch orientation changes / keyboard / overlay animations.
|
|
56
|
+
*/
|
|
57
|
+
const REMEASURE_THROTTLE_MS = 500;
|
|
52
58
|
let scrollViewIdCounter = 0;
|
|
53
59
|
const nextScrollViewId = () => `repliqo-scroll-${++scrollViewIdCounter}`;
|
|
54
60
|
/**
|
|
@@ -64,16 +70,24 @@ const nextScrollViewId = () => `repliqo-scroll-${++scrollViewIdCounter}`;
|
|
|
64
70
|
exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, onContentSizeChange, ...props }, ref) => {
|
|
65
71
|
const debounceTimer = (0, react_1.useRef)(null);
|
|
66
72
|
const scrollIntervalTimer = (0, react_1.useRef)(null);
|
|
73
|
+
const initialLayoutTimer = (0, react_1.useRef)(null);
|
|
67
74
|
const lastCaptureTime = (0, react_1.useRef)(0);
|
|
68
75
|
const lastCaptureY = (0, react_1.useRef)(-999);
|
|
76
|
+
const lastMeasureTime = (0, react_1.useRef)(0);
|
|
69
77
|
const initialCaptured = (0, react_1.useRef)(false);
|
|
70
78
|
const realContentHeight = (0, react_1.useRef)(0);
|
|
71
79
|
const scrollViewId = (0, react_1.useRef)(nextScrollViewId());
|
|
72
80
|
const innerRef = (0, react_1.useRef)(null);
|
|
81
|
+
const isMounted = (0, react_1.useRef)(true);
|
|
73
82
|
const scrollState = (0, react_1.useRef)(null);
|
|
74
|
-
// Forward the inner ref to the parent's ref (if provided)
|
|
83
|
+
// Forward the inner ref to the parent's ref (if provided).
|
|
84
|
+
// IMPORTANT: do NOT clear innerRef on cleanup — RN sometimes calls the
|
|
85
|
+
// ref callback with null mid-update, and clearing would break async
|
|
86
|
+
// measurement callbacks that fire after the ref reattaches.
|
|
75
87
|
const setRef = (0, react_1.useCallback)((node) => {
|
|
76
|
-
|
|
88
|
+
if (node !== null) {
|
|
89
|
+
innerRef.current = node;
|
|
90
|
+
}
|
|
77
91
|
if (typeof ref === 'function') {
|
|
78
92
|
ref(node);
|
|
79
93
|
}
|
|
@@ -81,12 +95,21 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
81
95
|
ref.current = node;
|
|
82
96
|
}
|
|
83
97
|
}, [ref]);
|
|
84
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Measure window-relative bounds and register with SDK.
|
|
100
|
+
* Idempotent + safe to call after unmount (guards via isMounted).
|
|
101
|
+
*/
|
|
85
102
|
const measureAndRegister = (0, react_1.useCallback)(() => {
|
|
103
|
+
if (!isMounted.current)
|
|
104
|
+
return;
|
|
86
105
|
const node = innerRef.current;
|
|
87
106
|
if (!node || typeof node.measureInWindow !== 'function')
|
|
88
107
|
return;
|
|
89
108
|
node.measureInWindow((x, y, width, height) => {
|
|
109
|
+
if (!isMounted.current)
|
|
110
|
+
return;
|
|
111
|
+
// RN sometimes returns 0/0 dims before the view is positioned —
|
|
112
|
+
// ignore those, the next onLayout/onScroll will re-measure.
|
|
90
113
|
if (width <= 0 || height <= 0)
|
|
91
114
|
return;
|
|
92
115
|
try {
|
|
@@ -97,20 +120,29 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
97
120
|
catch { }
|
|
98
121
|
});
|
|
99
122
|
}, []);
|
|
100
|
-
// Cleanup timers + unregister on unmount
|
|
123
|
+
// Cleanup all timers + unregister + listeners on unmount
|
|
101
124
|
(0, react_1.useEffect)(() => {
|
|
102
125
|
const id = scrollViewId.current;
|
|
126
|
+
isMounted.current = true;
|
|
127
|
+
// Re-measure when keyboard appears/disappears (causes layout shift)
|
|
128
|
+
const showSub = react_native_1.Keyboard.addListener('keyboardDidShow', measureAndRegister);
|
|
129
|
+
const hideSub = react_native_1.Keyboard.addListener('keyboardDidHide', measureAndRegister);
|
|
103
130
|
return () => {
|
|
131
|
+
isMounted.current = false;
|
|
104
132
|
if (debounceTimer.current)
|
|
105
133
|
clearTimeout(debounceTimer.current);
|
|
106
134
|
if (scrollIntervalTimer.current)
|
|
107
135
|
clearInterval(scrollIntervalTimer.current);
|
|
136
|
+
if (initialLayoutTimer.current)
|
|
137
|
+
clearTimeout(initialLayoutTimer.current);
|
|
138
|
+
showSub.remove();
|
|
139
|
+
hideSub.remove();
|
|
108
140
|
try {
|
|
109
141
|
client_1.AppAnalytics.getInstance().unregisterScrollView(id);
|
|
110
142
|
}
|
|
111
143
|
catch { }
|
|
112
144
|
};
|
|
113
|
-
}, []);
|
|
145
|
+
}, [measureAndRegister]);
|
|
114
146
|
/**
|
|
115
147
|
* Core tile capture logic. Reads from refs (stable across renders).
|
|
116
148
|
* Checks throttle + delta before capturing.
|
|
@@ -118,6 +150,8 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
118
150
|
* @param force Skip throttle check (used for interval captures)
|
|
119
151
|
*/
|
|
120
152
|
const doCapture = (force = false) => {
|
|
153
|
+
if (!isMounted.current)
|
|
154
|
+
return;
|
|
121
155
|
const state = scrollState.current;
|
|
122
156
|
if (!state || state.viewportHeight <= 0)
|
|
123
157
|
return;
|
|
@@ -160,6 +194,13 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
160
194
|
sdk.updateScrollViewOffset(scrollViewId.current, contentOffset.y);
|
|
161
195
|
}
|
|
162
196
|
catch { }
|
|
197
|
+
// Re-measure bounds occasionally during scroll — catches layout
|
|
198
|
+
// shifts from animations, keyboard, orientation changes, etc.
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
if (now - lastMeasureTime.current > REMEASURE_THROTTLE_MS) {
|
|
201
|
+
lastMeasureTime.current = now;
|
|
202
|
+
measureAndRegister();
|
|
203
|
+
}
|
|
163
204
|
// Update scroll state — use realContentHeight if available
|
|
164
205
|
const bestContentHeight = realContentHeight.current > 0
|
|
165
206
|
? realContentHeight.current
|
|
@@ -177,10 +218,12 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
177
218
|
startScrollInterval();
|
|
178
219
|
// Capture initial tile (Y~0) on first scroll event with real dims
|
|
179
220
|
if (!initialCaptured.current && contentOffset.y < 10) {
|
|
180
|
-
|
|
221
|
+
if (initialLayoutTimer.current)
|
|
222
|
+
clearTimeout(initialLayoutTimer.current);
|
|
223
|
+
initialLayoutTimer.current = setTimeout(() => doCapture(), 200);
|
|
181
224
|
}
|
|
182
225
|
onScroll?.(event);
|
|
183
|
-
}, [onScroll]);
|
|
226
|
+
}, [onScroll, measureAndRegister]);
|
|
184
227
|
const handleScrollEndDrag = (0, react_1.useCallback)((event) => {
|
|
185
228
|
// User lifted finger — capture immediately
|
|
186
229
|
stopScrollInterval();
|
|
@@ -197,7 +240,6 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
197
240
|
onMomentumScrollEnd?.(event);
|
|
198
241
|
}, [onMomentumScrollEnd]);
|
|
199
242
|
const handleLayout = (0, react_1.useCallback)((event) => {
|
|
200
|
-
// Capture initial tile after layout (gets real viewportHeight)
|
|
201
243
|
const { height } = event.nativeEvent.layout;
|
|
202
244
|
if (!initialCaptured.current && height > 0) {
|
|
203
245
|
scrollState.current = {
|
|
@@ -205,12 +247,15 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
205
247
|
viewportHeight: height,
|
|
206
248
|
contentHeight: realContentHeight.current || height,
|
|
207
249
|
};
|
|
208
|
-
|
|
250
|
+
// Clear any prior pending initial-capture timer before scheduling
|
|
251
|
+
if (initialLayoutTimer.current)
|
|
252
|
+
clearTimeout(initialLayoutTimer.current);
|
|
253
|
+
initialLayoutTimer.current = setTimeout(() => doCapture(), 1000);
|
|
209
254
|
}
|
|
210
|
-
// Register window-relative bounds
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
255
|
+
// Register window-relative bounds immediately (no setTimeout).
|
|
256
|
+
// measureInWindow itself is async; if it returns 0/0 dims, the
|
|
257
|
+
// next onLayout / onScroll will retry — no race window.
|
|
258
|
+
measureAndRegister();
|
|
214
259
|
onLayout?.(event);
|
|
215
260
|
}, [onLayout, measureAndRegister]);
|
|
216
261
|
/**
|
package/dist/core/client.d.ts
CHANGED
|
@@ -32,6 +32,20 @@ export declare class AppAnalytics {
|
|
|
32
32
|
static destroy(): void;
|
|
33
33
|
startSession(deviceId: string, deviceInfo?: DeviceInfo): Promise<string>;
|
|
34
34
|
endSession(): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Track a touch event for heatmap analysis.
|
|
37
|
+
*
|
|
38
|
+
* @param x Touch X coordinate, in WINDOW coordinates (must match the
|
|
39
|
+
* coordinate system of `measureInWindow` — typically `pageX`
|
|
40
|
+
* from a React Native gesture event).
|
|
41
|
+
* @param y Touch Y coordinate, in WINDOW coordinates (must match the
|
|
42
|
+
* coordinate system of `measureInWindow` — typically `pageY`
|
|
43
|
+
* from a React Native gesture event). On Android with edge-to-edge
|
|
44
|
+
* enabled, ensure `pageY` excludes the status bar to stay
|
|
45
|
+
* consistent with `measureInWindow`.
|
|
46
|
+
* @param screenName Optional screen name (defaults to current screen).
|
|
47
|
+
* @param extra Optional extra data to attach to the event.
|
|
48
|
+
*/
|
|
35
49
|
trackTouch(x: number, y: number, screenName?: string, extra?: Record<string, any>): void;
|
|
36
50
|
trackNavigation(fromScreen: string, toScreen: string): void;
|
|
37
51
|
trackCustomEvent(eventName: string, data?: Record<string, any>): void;
|
package/dist/core/client.js
CHANGED
|
@@ -167,6 +167,20 @@ class AppAnalytics {
|
|
|
167
167
|
this.snapshotCapture.setSessionId(null);
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Track a touch event for heatmap analysis.
|
|
172
|
+
*
|
|
173
|
+
* @param x Touch X coordinate, in WINDOW coordinates (must match the
|
|
174
|
+
* coordinate system of `measureInWindow` — typically `pageX`
|
|
175
|
+
* from a React Native gesture event).
|
|
176
|
+
* @param y Touch Y coordinate, in WINDOW coordinates (must match the
|
|
177
|
+
* coordinate system of `measureInWindow` — typically `pageY`
|
|
178
|
+
* from a React Native gesture event). On Android with edge-to-edge
|
|
179
|
+
* enabled, ensure `pageY` excludes the status bar to stay
|
|
180
|
+
* consistent with `measureInWindow`.
|
|
181
|
+
* @param screenName Optional screen name (defaults to current screen).
|
|
182
|
+
* @param extra Optional extra data to attach to the event.
|
|
183
|
+
*/
|
|
170
184
|
trackTouch(x, y, screenName, extra) {
|
|
171
185
|
if (!this.sessionId) {
|
|
172
186
|
this.logger.warn('Cannot track touch: no active session');
|
|
@@ -176,29 +190,48 @@ class AppAnalytics {
|
|
|
176
190
|
return;
|
|
177
191
|
}
|
|
178
192
|
// Auto-detect: find which (if any) registered ScrollView contains
|
|
179
|
-
// this touch. If
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
let
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
+
// this touch. If multiple match (nested ScrollViews, e.g. FlatList
|
|
194
|
+
// inside ScrollView), pick the SMALLEST by area — the most-nested
|
|
195
|
+
// one is the actual scroll context for that touch.
|
|
196
|
+
let bestMatchOffset = 0;
|
|
197
|
+
let bestMatchArea = Infinity;
|
|
198
|
+
let foundMatch = false;
|
|
199
|
+
for (const info of this.scrollViewRegistry.values()) {
|
|
200
|
+
const { bounds } = info;
|
|
201
|
+
if (x >= bounds.x &&
|
|
202
|
+
x <= bounds.x + bounds.width &&
|
|
203
|
+
y >= bounds.y &&
|
|
204
|
+
y <= bounds.y + bounds.height) {
|
|
205
|
+
const area = bounds.width * bounds.height;
|
|
206
|
+
if (area < bestMatchArea) {
|
|
207
|
+
bestMatchArea = area;
|
|
208
|
+
bestMatchOffset = info.scrollOffsetY;
|
|
209
|
+
foundMatch = true;
|
|
193
210
|
}
|
|
194
211
|
}
|
|
195
212
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
213
|
+
let activeScrollOffset;
|
|
214
|
+
let isFixed;
|
|
215
|
+
if (foundMatch) {
|
|
216
|
+
// Touch is inside a registered ScrollView → content touch
|
|
217
|
+
activeScrollOffset = bestMatchOffset;
|
|
200
218
|
isFixed = false;
|
|
201
219
|
}
|
|
220
|
+
else if (this.scrollViewRegistry.size > 0) {
|
|
221
|
+
// ScrollViews ARE registered, but this touch is outside all of them
|
|
222
|
+
// → it's on fixed UI (nav bar, header, etc.)
|
|
223
|
+
activeScrollOffset = 0;
|
|
224
|
+
isFixed = true;
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
// No ScrollViews registered at all. Two possible cases:
|
|
228
|
+
// (a) The screen has no scrollable content → touch is fixed
|
|
229
|
+
// (b) RepliqoScrollView hasn't measured yet (first ~1 frame)
|
|
230
|
+
// Either way, marking as fixed is safer than guessing content
|
|
231
|
+
// position with stale scrollOffsetY from a previous screen.
|
|
232
|
+
activeScrollOffset = 0;
|
|
233
|
+
isFixed = true;
|
|
234
|
+
}
|
|
202
235
|
const adjustedY = y + activeScrollOffset;
|
|
203
236
|
const event = {
|
|
204
237
|
type: 'touch',
|
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.6",
|
|
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",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useCallback, useRef, useEffect } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
ScrollView,
|
|
4
|
+
Keyboard,
|
|
4
5
|
type ScrollViewProps,
|
|
5
6
|
type NativeSyntheticEvent,
|
|
6
7
|
type NativeScrollEvent,
|
|
@@ -20,6 +21,12 @@ const THROTTLE_MS = 400;
|
|
|
20
21
|
* where the user never pauses long enough for the debounce to fire.
|
|
21
22
|
*/
|
|
22
23
|
const SCROLL_INTERVAL_MS = 1200;
|
|
24
|
+
/**
|
|
25
|
+
* During scroll, throttle window-bounds re-measurement to at most once per
|
|
26
|
+
* this many ms. Bounds rarely change while scrolling, but we still want to
|
|
27
|
+
* catch orientation changes / keyboard / overlay animations.
|
|
28
|
+
*/
|
|
29
|
+
const REMEASURE_THROTTLE_MS = 500;
|
|
23
30
|
|
|
24
31
|
let scrollViewIdCounter = 0;
|
|
25
32
|
const nextScrollViewId = () => `repliqo-scroll-${++scrollViewIdCounter}`;
|
|
@@ -38,21 +45,29 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
38
45
|
({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, onContentSizeChange, ...props }, ref) => {
|
|
39
46
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
40
47
|
const scrollIntervalTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
48
|
+
const initialLayoutTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
41
49
|
const lastCaptureTime = useRef(0);
|
|
42
50
|
const lastCaptureY = useRef(-999);
|
|
51
|
+
const lastMeasureTime = useRef(0);
|
|
43
52
|
const initialCaptured = useRef(false);
|
|
44
53
|
const realContentHeight = useRef(0);
|
|
45
54
|
const scrollViewId = useRef<string>(nextScrollViewId());
|
|
46
55
|
const innerRef = useRef<ScrollView | null>(null);
|
|
56
|
+
const isMounted = useRef(true);
|
|
47
57
|
const scrollState = useRef<{
|
|
48
58
|
offsetY: number;
|
|
49
59
|
viewportHeight: number;
|
|
50
60
|
contentHeight: number;
|
|
51
61
|
} | null>(null);
|
|
52
62
|
|
|
53
|
-
// Forward the inner ref to the parent's ref (if provided)
|
|
63
|
+
// Forward the inner ref to the parent's ref (if provided).
|
|
64
|
+
// IMPORTANT: do NOT clear innerRef on cleanup — RN sometimes calls the
|
|
65
|
+
// ref callback with null mid-update, and clearing would break async
|
|
66
|
+
// measurement callbacks that fire after the ref reattaches.
|
|
54
67
|
const setRef = useCallback((node: ScrollView | null) => {
|
|
55
|
-
|
|
68
|
+
if (node !== null) {
|
|
69
|
+
innerRef.current = node;
|
|
70
|
+
}
|
|
56
71
|
if (typeof ref === 'function') {
|
|
57
72
|
ref(node);
|
|
58
73
|
} else if (ref) {
|
|
@@ -60,11 +75,18 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
60
75
|
}
|
|
61
76
|
}, [ref]);
|
|
62
77
|
|
|
63
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Measure window-relative bounds and register with SDK.
|
|
80
|
+
* Idempotent + safe to call after unmount (guards via isMounted).
|
|
81
|
+
*/
|
|
64
82
|
const measureAndRegister = useCallback(() => {
|
|
83
|
+
if (!isMounted.current) return;
|
|
65
84
|
const node = innerRef.current as any;
|
|
66
85
|
if (!node || typeof node.measureInWindow !== 'function') return;
|
|
67
86
|
node.measureInWindow((x: number, y: number, width: number, height: number) => {
|
|
87
|
+
if (!isMounted.current) return;
|
|
88
|
+
// RN sometimes returns 0/0 dims before the view is positioned —
|
|
89
|
+
// ignore those, the next onLayout/onScroll will re-measure.
|
|
68
90
|
if (width <= 0 || height <= 0) return;
|
|
69
91
|
try {
|
|
70
92
|
AppAnalytics.getInstance().registerScrollView(scrollViewId.current, {
|
|
@@ -74,17 +96,27 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
74
96
|
});
|
|
75
97
|
}, []);
|
|
76
98
|
|
|
77
|
-
// Cleanup timers + unregister on unmount
|
|
99
|
+
// Cleanup all timers + unregister + listeners on unmount
|
|
78
100
|
useEffect(() => {
|
|
79
101
|
const id = scrollViewId.current;
|
|
102
|
+
isMounted.current = true;
|
|
103
|
+
|
|
104
|
+
// Re-measure when keyboard appears/disappears (causes layout shift)
|
|
105
|
+
const showSub = Keyboard.addListener('keyboardDidShow', measureAndRegister);
|
|
106
|
+
const hideSub = Keyboard.addListener('keyboardDidHide', measureAndRegister);
|
|
107
|
+
|
|
80
108
|
return () => {
|
|
109
|
+
isMounted.current = false;
|
|
81
110
|
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
|
82
111
|
if (scrollIntervalTimer.current) clearInterval(scrollIntervalTimer.current);
|
|
112
|
+
if (initialLayoutTimer.current) clearTimeout(initialLayoutTimer.current);
|
|
113
|
+
showSub.remove();
|
|
114
|
+
hideSub.remove();
|
|
83
115
|
try {
|
|
84
116
|
AppAnalytics.getInstance().unregisterScrollView(id);
|
|
85
117
|
} catch {}
|
|
86
118
|
};
|
|
87
|
-
}, []);
|
|
119
|
+
}, [measureAndRegister]);
|
|
88
120
|
|
|
89
121
|
/**
|
|
90
122
|
* Core tile capture logic. Reads from refs (stable across renders).
|
|
@@ -93,6 +125,7 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
93
125
|
* @param force Skip throttle check (used for interval captures)
|
|
94
126
|
*/
|
|
95
127
|
const doCapture = (force = false) => {
|
|
128
|
+
if (!isMounted.current) return;
|
|
96
129
|
const state = scrollState.current;
|
|
97
130
|
if (!state || state.viewportHeight <= 0) return;
|
|
98
131
|
|
|
@@ -144,6 +177,14 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
144
177
|
sdk.updateScrollViewOffset(scrollViewId.current, contentOffset.y);
|
|
145
178
|
} catch {}
|
|
146
179
|
|
|
180
|
+
// Re-measure bounds occasionally during scroll — catches layout
|
|
181
|
+
// shifts from animations, keyboard, orientation changes, etc.
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
if (now - lastMeasureTime.current > REMEASURE_THROTTLE_MS) {
|
|
184
|
+
lastMeasureTime.current = now;
|
|
185
|
+
measureAndRegister();
|
|
186
|
+
}
|
|
187
|
+
|
|
147
188
|
// Update scroll state — use realContentHeight if available
|
|
148
189
|
const bestContentHeight = realContentHeight.current > 0
|
|
149
190
|
? realContentHeight.current
|
|
@@ -163,12 +204,13 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
163
204
|
|
|
164
205
|
// Capture initial tile (Y~0) on first scroll event with real dims
|
|
165
206
|
if (!initialCaptured.current && contentOffset.y < 10) {
|
|
166
|
-
|
|
207
|
+
if (initialLayoutTimer.current) clearTimeout(initialLayoutTimer.current);
|
|
208
|
+
initialLayoutTimer.current = setTimeout(() => doCapture(), 200);
|
|
167
209
|
}
|
|
168
210
|
|
|
169
211
|
onScroll?.(event);
|
|
170
212
|
},
|
|
171
|
-
[onScroll],
|
|
213
|
+
[onScroll, measureAndRegister],
|
|
172
214
|
);
|
|
173
215
|
|
|
174
216
|
const handleScrollEndDrag = useCallback(
|
|
@@ -196,7 +238,6 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
196
238
|
|
|
197
239
|
const handleLayout = useCallback(
|
|
198
240
|
(event: LayoutChangeEvent) => {
|
|
199
|
-
// Capture initial tile after layout (gets real viewportHeight)
|
|
200
241
|
const { height } = event.nativeEvent.layout;
|
|
201
242
|
if (!initialCaptured.current && height > 0) {
|
|
202
243
|
scrollState.current = {
|
|
@@ -204,12 +245,14 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
204
245
|
viewportHeight: height,
|
|
205
246
|
contentHeight: realContentHeight.current || height,
|
|
206
247
|
};
|
|
207
|
-
|
|
248
|
+
// Clear any prior pending initial-capture timer before scheduling
|
|
249
|
+
if (initialLayoutTimer.current) clearTimeout(initialLayoutTimer.current);
|
|
250
|
+
initialLayoutTimer.current = setTimeout(() => doCapture(), 1000);
|
|
208
251
|
}
|
|
209
|
-
// Register window-relative bounds
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
|
|
252
|
+
// Register window-relative bounds immediately (no setTimeout).
|
|
253
|
+
// measureInWindow itself is async; if it returns 0/0 dims, the
|
|
254
|
+
// next onLayout / onScroll will retry — no race window.
|
|
255
|
+
measureAndRegister();
|
|
213
256
|
onLayout?.(event);
|
|
214
257
|
},
|
|
215
258
|
[onLayout, measureAndRegister],
|
package/src/core/client.ts
CHANGED
|
@@ -250,6 +250,20 @@ export class AppAnalytics {
|
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Track a touch event for heatmap analysis.
|
|
255
|
+
*
|
|
256
|
+
* @param x Touch X coordinate, in WINDOW coordinates (must match the
|
|
257
|
+
* coordinate system of `measureInWindow` — typically `pageX`
|
|
258
|
+
* from a React Native gesture event).
|
|
259
|
+
* @param y Touch Y coordinate, in WINDOW coordinates (must match the
|
|
260
|
+
* coordinate system of `measureInWindow` — typically `pageY`
|
|
261
|
+
* from a React Native gesture event). On Android with edge-to-edge
|
|
262
|
+
* enabled, ensure `pageY` excludes the status bar to stay
|
|
263
|
+
* consistent with `measureInWindow`.
|
|
264
|
+
* @param screenName Optional screen name (defaults to current screen).
|
|
265
|
+
* @param extra Optional extra data to attach to the event.
|
|
266
|
+
*/
|
|
253
267
|
trackTouch(
|
|
254
268
|
x: number,
|
|
255
269
|
y: number,
|
|
@@ -266,30 +280,50 @@ export class AppAnalytics {
|
|
|
266
280
|
}
|
|
267
281
|
|
|
268
282
|
// Auto-detect: find which (if any) registered ScrollView contains
|
|
269
|
-
// this touch. If
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
let
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
283
|
+
// this touch. If multiple match (nested ScrollViews, e.g. FlatList
|
|
284
|
+
// inside ScrollView), pick the SMALLEST by area — the most-nested
|
|
285
|
+
// one is the actual scroll context for that touch.
|
|
286
|
+
let bestMatchOffset = 0;
|
|
287
|
+
let bestMatchArea = Infinity;
|
|
288
|
+
let foundMatch = false;
|
|
289
|
+
|
|
290
|
+
for (const info of this.scrollViewRegistry.values()) {
|
|
291
|
+
const { bounds } = info;
|
|
292
|
+
if (
|
|
293
|
+
x >= bounds.x &&
|
|
294
|
+
x <= bounds.x + bounds.width &&
|
|
295
|
+
y >= bounds.y &&
|
|
296
|
+
y <= bounds.y + bounds.height
|
|
297
|
+
) {
|
|
298
|
+
const area = bounds.width * bounds.height;
|
|
299
|
+
if (area < bestMatchArea) {
|
|
300
|
+
bestMatchArea = area;
|
|
301
|
+
bestMatchOffset = info.scrollOffsetY;
|
|
302
|
+
foundMatch = true;
|
|
286
303
|
}
|
|
287
304
|
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let activeScrollOffset: number;
|
|
308
|
+
let isFixed: boolean;
|
|
309
|
+
|
|
310
|
+
if (foundMatch) {
|
|
311
|
+
// Touch is inside a registered ScrollView → content touch
|
|
312
|
+
activeScrollOffset = bestMatchOffset;
|
|
292
313
|
isFixed = false;
|
|
314
|
+
} else if (this.scrollViewRegistry.size > 0) {
|
|
315
|
+
// ScrollViews ARE registered, but this touch is outside all of them
|
|
316
|
+
// → it's on fixed UI (nav bar, header, etc.)
|
|
317
|
+
activeScrollOffset = 0;
|
|
318
|
+
isFixed = true;
|
|
319
|
+
} else {
|
|
320
|
+
// No ScrollViews registered at all. Two possible cases:
|
|
321
|
+
// (a) The screen has no scrollable content → touch is fixed
|
|
322
|
+
// (b) RepliqoScrollView hasn't measured yet (first ~1 frame)
|
|
323
|
+
// Either way, marking as fixed is safer than guessing content
|
|
324
|
+
// position with stale scrollOffsetY from a previous screen.
|
|
325
|
+
activeScrollOffset = 0;
|
|
326
|
+
isFixed = true;
|
|
293
327
|
}
|
|
294
328
|
|
|
295
329
|
const adjustedY = y + activeScrollOffset;
|