@repliqo/sdk-react-native 0.3.5 → 0.3.7
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 +65 -14
- package/dist/core/client.d.ts +23 -4
- package/dist/core/client.js +62 -23
- package/dist/transport/api.client.d.ts +8 -1
- package/dist/transport/api.client.js +25 -11
- package/package.json +1 -1
- package/src/components/RepliqoScrollView.tsx +65 -13
- package/src/core/client.ts +68 -24
- package/src/transport/api.client.ts +27 -10
|
@@ -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,28 @@ 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);
|
|
82
|
+
// Last measured window-relative top Y of the ScrollView — passed to
|
|
83
|
+
// captureScrollTile so the backend can crop each tile to just the
|
|
84
|
+
// scroll viewport area (no fixed header/footer duplication).
|
|
85
|
+
const lastViewportTop = (0, react_1.useRef)(null);
|
|
73
86
|
const scrollState = (0, react_1.useRef)(null);
|
|
74
|
-
// Forward the inner ref to the parent's ref (if provided)
|
|
87
|
+
// Forward the inner ref to the parent's ref (if provided).
|
|
88
|
+
// IMPORTANT: do NOT clear innerRef on cleanup — RN sometimes calls the
|
|
89
|
+
// ref callback with null mid-update, and clearing would break async
|
|
90
|
+
// measurement callbacks that fire after the ref reattaches.
|
|
75
91
|
const setRef = (0, react_1.useCallback)((node) => {
|
|
76
|
-
|
|
92
|
+
if (node !== null) {
|
|
93
|
+
innerRef.current = node;
|
|
94
|
+
}
|
|
77
95
|
if (typeof ref === 'function') {
|
|
78
96
|
ref(node);
|
|
79
97
|
}
|
|
@@ -81,14 +99,24 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
81
99
|
ref.current = node;
|
|
82
100
|
}
|
|
83
101
|
}, [ref]);
|
|
84
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* Measure window-relative bounds and register with SDK.
|
|
104
|
+
* Idempotent + safe to call after unmount (guards via isMounted).
|
|
105
|
+
*/
|
|
85
106
|
const measureAndRegister = (0, react_1.useCallback)(() => {
|
|
107
|
+
if (!isMounted.current)
|
|
108
|
+
return;
|
|
86
109
|
const node = innerRef.current;
|
|
87
110
|
if (!node || typeof node.measureInWindow !== 'function')
|
|
88
111
|
return;
|
|
89
112
|
node.measureInWindow((x, y, width, height) => {
|
|
113
|
+
if (!isMounted.current)
|
|
114
|
+
return;
|
|
115
|
+
// RN sometimes returns 0/0 dims before the view is positioned —
|
|
116
|
+
// ignore those, the next onLayout/onScroll will re-measure.
|
|
90
117
|
if (width <= 0 || height <= 0)
|
|
91
118
|
return;
|
|
119
|
+
lastViewportTop.current = y;
|
|
92
120
|
try {
|
|
93
121
|
client_1.AppAnalytics.getInstance().registerScrollView(scrollViewId.current, {
|
|
94
122
|
x, y, width, height,
|
|
@@ -97,20 +125,29 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
97
125
|
catch { }
|
|
98
126
|
});
|
|
99
127
|
}, []);
|
|
100
|
-
// Cleanup timers + unregister on unmount
|
|
128
|
+
// Cleanup all timers + unregister + listeners on unmount
|
|
101
129
|
(0, react_1.useEffect)(() => {
|
|
102
130
|
const id = scrollViewId.current;
|
|
131
|
+
isMounted.current = true;
|
|
132
|
+
// Re-measure when keyboard appears/disappears (causes layout shift)
|
|
133
|
+
const showSub = react_native_1.Keyboard.addListener('keyboardDidShow', measureAndRegister);
|
|
134
|
+
const hideSub = react_native_1.Keyboard.addListener('keyboardDidHide', measureAndRegister);
|
|
103
135
|
return () => {
|
|
136
|
+
isMounted.current = false;
|
|
104
137
|
if (debounceTimer.current)
|
|
105
138
|
clearTimeout(debounceTimer.current);
|
|
106
139
|
if (scrollIntervalTimer.current)
|
|
107
140
|
clearInterval(scrollIntervalTimer.current);
|
|
141
|
+
if (initialLayoutTimer.current)
|
|
142
|
+
clearTimeout(initialLayoutTimer.current);
|
|
143
|
+
showSub.remove();
|
|
144
|
+
hideSub.remove();
|
|
108
145
|
try {
|
|
109
146
|
client_1.AppAnalytics.getInstance().unregisterScrollView(id);
|
|
110
147
|
}
|
|
111
148
|
catch { }
|
|
112
149
|
};
|
|
113
|
-
}, []);
|
|
150
|
+
}, [measureAndRegister]);
|
|
114
151
|
/**
|
|
115
152
|
* Core tile capture logic. Reads from refs (stable across renders).
|
|
116
153
|
* Checks throttle + delta before capturing.
|
|
@@ -118,6 +155,8 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
118
155
|
* @param force Skip throttle check (used for interval captures)
|
|
119
156
|
*/
|
|
120
157
|
const doCapture = (force = false) => {
|
|
158
|
+
if (!isMounted.current)
|
|
159
|
+
return;
|
|
121
160
|
const state = scrollState.current;
|
|
122
161
|
if (!state || state.viewportHeight <= 0)
|
|
123
162
|
return;
|
|
@@ -132,7 +171,8 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
132
171
|
lastCaptureY.current = state.offsetY;
|
|
133
172
|
initialCaptured.current = true;
|
|
134
173
|
try {
|
|
135
|
-
|
|
174
|
+
const windowHeight = react_native_1.Dimensions.get('window').height;
|
|
175
|
+
client_1.AppAnalytics.getInstance().captureScrollTile(state.offsetY, state.viewportHeight, state.contentHeight, lastViewportTop.current ?? undefined, windowHeight);
|
|
136
176
|
}
|
|
137
177
|
catch { }
|
|
138
178
|
};
|
|
@@ -160,6 +200,13 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
160
200
|
sdk.updateScrollViewOffset(scrollViewId.current, contentOffset.y);
|
|
161
201
|
}
|
|
162
202
|
catch { }
|
|
203
|
+
// Re-measure bounds occasionally during scroll — catches layout
|
|
204
|
+
// shifts from animations, keyboard, orientation changes, etc.
|
|
205
|
+
const now = Date.now();
|
|
206
|
+
if (now - lastMeasureTime.current > REMEASURE_THROTTLE_MS) {
|
|
207
|
+
lastMeasureTime.current = now;
|
|
208
|
+
measureAndRegister();
|
|
209
|
+
}
|
|
163
210
|
// Update scroll state — use realContentHeight if available
|
|
164
211
|
const bestContentHeight = realContentHeight.current > 0
|
|
165
212
|
? realContentHeight.current
|
|
@@ -177,10 +224,12 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
177
224
|
startScrollInterval();
|
|
178
225
|
// Capture initial tile (Y~0) on first scroll event with real dims
|
|
179
226
|
if (!initialCaptured.current && contentOffset.y < 10) {
|
|
180
|
-
|
|
227
|
+
if (initialLayoutTimer.current)
|
|
228
|
+
clearTimeout(initialLayoutTimer.current);
|
|
229
|
+
initialLayoutTimer.current = setTimeout(() => doCapture(), 200);
|
|
181
230
|
}
|
|
182
231
|
onScroll?.(event);
|
|
183
|
-
}, [onScroll]);
|
|
232
|
+
}, [onScroll, measureAndRegister]);
|
|
184
233
|
const handleScrollEndDrag = (0, react_1.useCallback)((event) => {
|
|
185
234
|
// User lifted finger — capture immediately
|
|
186
235
|
stopScrollInterval();
|
|
@@ -197,7 +246,6 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
197
246
|
onMomentumScrollEnd?.(event);
|
|
198
247
|
}, [onMomentumScrollEnd]);
|
|
199
248
|
const handleLayout = (0, react_1.useCallback)((event) => {
|
|
200
|
-
// Capture initial tile after layout (gets real viewportHeight)
|
|
201
249
|
const { height } = event.nativeEvent.layout;
|
|
202
250
|
if (!initialCaptured.current && height > 0) {
|
|
203
251
|
scrollState.current = {
|
|
@@ -205,12 +253,15 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
205
253
|
viewportHeight: height,
|
|
206
254
|
contentHeight: realContentHeight.current || height,
|
|
207
255
|
};
|
|
208
|
-
|
|
256
|
+
// Clear any prior pending initial-capture timer before scheduling
|
|
257
|
+
if (initialLayoutTimer.current)
|
|
258
|
+
clearTimeout(initialLayoutTimer.current);
|
|
259
|
+
initialLayoutTimer.current = setTimeout(() => doCapture(), 1000);
|
|
209
260
|
}
|
|
210
|
-
// Register window-relative bounds
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
261
|
+
// Register window-relative bounds immediately (no setTimeout).
|
|
262
|
+
// measureInWindow itself is async; if it returns 0/0 dims, the
|
|
263
|
+
// next onLayout / onScroll will retry — no race window.
|
|
264
|
+
measureAndRegister();
|
|
214
265
|
onLayout?.(event);
|
|
215
266
|
}, [onLayout, measureAndRegister]);
|
|
216
267
|
/**
|
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;
|
|
@@ -71,11 +85,16 @@ export declare class AppAnalytics {
|
|
|
71
85
|
* Capture the current viewport as a tile for collaborative screen
|
|
72
86
|
* building. Called by RepliqoScrollView on scroll stops.
|
|
73
87
|
*
|
|
74
|
-
* The tile includes
|
|
75
|
-
* in the full content this viewport belongs
|
|
76
|
-
*
|
|
88
|
+
* The tile includes:
|
|
89
|
+
* - `scrollOffsetY`: where in the full content this viewport belongs
|
|
90
|
+
* - `viewportTop`: Y position (window logical px) of the ScrollView's
|
|
91
|
+
* top edge. Used by the backend to crop each tile to just the
|
|
92
|
+
* scrollable area, removing fixed headers/footers that would
|
|
93
|
+
* otherwise duplicate in the composite.
|
|
94
|
+
* - `windowHeight`: window height in logical px, for image-to-logical
|
|
95
|
+
* scale computation during backend cropping.
|
|
77
96
|
*/
|
|
78
|
-
captureScrollTile(scrollOffsetY: number, viewportHeight: number, contentHeight: number): void;
|
|
97
|
+
captureScrollTile(scrollOffsetY: number, viewportHeight: number, contentHeight: number, viewportTop?: number, windowHeight?: number): void;
|
|
79
98
|
/**
|
|
80
99
|
* Programmatically scroll-scan the current ScrollView and upload all
|
|
81
100
|
* tiles to the backend. Invisible to the user (~300-500ms).
|
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',
|
|
@@ -298,11 +331,16 @@ class AppAnalytics {
|
|
|
298
331
|
* Capture the current viewport as a tile for collaborative screen
|
|
299
332
|
* building. Called by RepliqoScrollView on scroll stops.
|
|
300
333
|
*
|
|
301
|
-
* The tile includes
|
|
302
|
-
* in the full content this viewport belongs
|
|
303
|
-
*
|
|
334
|
+
* The tile includes:
|
|
335
|
+
* - `scrollOffsetY`: where in the full content this viewport belongs
|
|
336
|
+
* - `viewportTop`: Y position (window logical px) of the ScrollView's
|
|
337
|
+
* top edge. Used by the backend to crop each tile to just the
|
|
338
|
+
* scrollable area, removing fixed headers/footers that would
|
|
339
|
+
* otherwise duplicate in the composite.
|
|
340
|
+
* - `windowHeight`: window height in logical px, for image-to-logical
|
|
341
|
+
* scale computation during backend cropping.
|
|
304
342
|
*/
|
|
305
|
-
captureScrollTile(scrollOffsetY, viewportHeight, contentHeight) {
|
|
343
|
+
captureScrollTile(scrollOffsetY, viewportHeight, contentHeight, viewportTop, windowHeight) {
|
|
306
344
|
if (!this.sessionId || !this.currentScreen)
|
|
307
345
|
return;
|
|
308
346
|
if (viewportHeight <= 0)
|
|
@@ -317,8 +355,9 @@ class AppAnalytics {
|
|
|
317
355
|
return;
|
|
318
356
|
this.logger.log(`Scroll tile: "${screenName}" Y=${Math.round(scrollOffsetY)} ` +
|
|
319
357
|
`vp=${Math.round(viewportHeight)} content=${Math.round(contentHeight)} ` +
|
|
358
|
+
`top=${viewportTop !== undefined ? Math.round(viewportTop) : '?'} ` +
|
|
320
359
|
`${Math.round(frame.image.length / 1024)}KB`);
|
|
321
|
-
await this.apiClient.uploadScreenTile(sessionId, screenName, frame.image, frame.width, frame.height, scrollOffsetY, viewportHeight, contentHeight);
|
|
360
|
+
await this.apiClient.uploadScreenTile(sessionId, screenName, frame.image, frame.width, frame.height, scrollOffsetY, viewportHeight, contentHeight, viewportTop, windowHeight);
|
|
322
361
|
}
|
|
323
362
|
catch (err) {
|
|
324
363
|
this.logger.warn('Scroll tile failed:', err);
|
|
@@ -32,7 +32,14 @@ export declare class ApiClient {
|
|
|
32
32
|
*/
|
|
33
33
|
/**
|
|
34
34
|
* Upload a scroll tile for collaborative screen building.
|
|
35
|
+
*
|
|
36
|
+
* @param viewportTop Y position (window logical px) of the ScrollView's
|
|
37
|
+
* top edge. Used by the backend to crop each tile to just the
|
|
38
|
+
* scrollable area, removing fixed headers/footers that would
|
|
39
|
+
* otherwise duplicate in the composite.
|
|
40
|
+
* @param windowHeight Window height (logical px) at capture time.
|
|
41
|
+
* Used by the backend to compute image-to-logical scale for cropping.
|
|
35
42
|
*/
|
|
36
|
-
uploadScreenTile(sessionId: string, screenName: string, image: string, width: number, height: number, scrollOffsetY: number, viewportHeight: number, contentHeight: number): Promise<void>;
|
|
43
|
+
uploadScreenTile(sessionId: string, screenName: string, image: string, width: number, height: number, scrollOffsetY: number, viewportHeight: number, contentHeight: number, viewportTop?: number, windowHeight?: number): Promise<void>;
|
|
37
44
|
uploadScreenCapture(screenName: string, image: string, width: number, height: number, isFullContent: boolean): Promise<void>;
|
|
38
45
|
}
|
|
@@ -158,22 +158,36 @@ class ApiClient {
|
|
|
158
158
|
*/
|
|
159
159
|
/**
|
|
160
160
|
* Upload a scroll tile for collaborative screen building.
|
|
161
|
+
*
|
|
162
|
+
* @param viewportTop Y position (window logical px) of the ScrollView's
|
|
163
|
+
* top edge. Used by the backend to crop each tile to just the
|
|
164
|
+
* scrollable area, removing fixed headers/footers that would
|
|
165
|
+
* otherwise duplicate in the composite.
|
|
166
|
+
* @param windowHeight Window height (logical px) at capture time.
|
|
167
|
+
* Used by the backend to compute image-to-logical scale for cropping.
|
|
161
168
|
*/
|
|
162
|
-
async uploadScreenTile(sessionId, screenName, image, width, height, scrollOffsetY, viewportHeight, contentHeight) {
|
|
169
|
+
async uploadScreenTile(sessionId, screenName, image, width, height, scrollOffsetY, viewportHeight, contentHeight, viewportTop, windowHeight) {
|
|
163
170
|
try {
|
|
171
|
+
const body = {
|
|
172
|
+
sessionId,
|
|
173
|
+
screenName,
|
|
174
|
+
image,
|
|
175
|
+
width,
|
|
176
|
+
height,
|
|
177
|
+
scrollOffsetY: Math.round(scrollOffsetY),
|
|
178
|
+
viewportHeight: Math.round(viewportHeight),
|
|
179
|
+
contentHeight: Math.round(contentHeight),
|
|
180
|
+
};
|
|
181
|
+
if (typeof viewportTop === 'number' && Number.isFinite(viewportTop)) {
|
|
182
|
+
body.viewportTop = Math.round(viewportTop);
|
|
183
|
+
}
|
|
184
|
+
if (typeof windowHeight === 'number' && Number.isFinite(windowHeight)) {
|
|
185
|
+
body.windowHeight = Math.round(windowHeight);
|
|
186
|
+
}
|
|
164
187
|
const response = await fetch(`${this.baseUrl}/api/screen-tiles`, {
|
|
165
188
|
method: 'POST',
|
|
166
189
|
headers: this.getHeaders(),
|
|
167
|
-
body: JSON.stringify(
|
|
168
|
-
sessionId,
|
|
169
|
-
screenName,
|
|
170
|
-
image,
|
|
171
|
-
width,
|
|
172
|
-
height,
|
|
173
|
-
scrollOffsetY: Math.round(scrollOffsetY),
|
|
174
|
-
viewportHeight: Math.round(viewportHeight),
|
|
175
|
-
contentHeight: Math.round(contentHeight),
|
|
176
|
-
}),
|
|
190
|
+
body: JSON.stringify(body),
|
|
177
191
|
});
|
|
178
192
|
if (!response.ok) {
|
|
179
193
|
const text = await response.text();
|
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.7",
|
|
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,8 @@
|
|
|
1
1
|
import React, { useCallback, useRef, useEffect } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
ScrollView,
|
|
4
|
+
Keyboard,
|
|
5
|
+
Dimensions,
|
|
4
6
|
type ScrollViewProps,
|
|
5
7
|
type NativeSyntheticEvent,
|
|
6
8
|
type NativeScrollEvent,
|
|
@@ -20,6 +22,12 @@ const THROTTLE_MS = 400;
|
|
|
20
22
|
* where the user never pauses long enough for the debounce to fire.
|
|
21
23
|
*/
|
|
22
24
|
const SCROLL_INTERVAL_MS = 1200;
|
|
25
|
+
/**
|
|
26
|
+
* During scroll, throttle window-bounds re-measurement to at most once per
|
|
27
|
+
* this many ms. Bounds rarely change while scrolling, but we still want to
|
|
28
|
+
* catch orientation changes / keyboard / overlay animations.
|
|
29
|
+
*/
|
|
30
|
+
const REMEASURE_THROTTLE_MS = 500;
|
|
23
31
|
|
|
24
32
|
let scrollViewIdCounter = 0;
|
|
25
33
|
const nextScrollViewId = () => `repliqo-scroll-${++scrollViewIdCounter}`;
|
|
@@ -38,21 +46,33 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
38
46
|
({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, onContentSizeChange, ...props }, ref) => {
|
|
39
47
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
40
48
|
const scrollIntervalTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
49
|
+
const initialLayoutTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
41
50
|
const lastCaptureTime = useRef(0);
|
|
42
51
|
const lastCaptureY = useRef(-999);
|
|
52
|
+
const lastMeasureTime = useRef(0);
|
|
43
53
|
const initialCaptured = useRef(false);
|
|
44
54
|
const realContentHeight = useRef(0);
|
|
45
55
|
const scrollViewId = useRef<string>(nextScrollViewId());
|
|
46
56
|
const innerRef = useRef<ScrollView | null>(null);
|
|
57
|
+
const isMounted = useRef(true);
|
|
58
|
+
// Last measured window-relative top Y of the ScrollView — passed to
|
|
59
|
+
// captureScrollTile so the backend can crop each tile to just the
|
|
60
|
+
// scroll viewport area (no fixed header/footer duplication).
|
|
61
|
+
const lastViewportTop = useRef<number | null>(null);
|
|
47
62
|
const scrollState = useRef<{
|
|
48
63
|
offsetY: number;
|
|
49
64
|
viewportHeight: number;
|
|
50
65
|
contentHeight: number;
|
|
51
66
|
} | null>(null);
|
|
52
67
|
|
|
53
|
-
// Forward the inner ref to the parent's ref (if provided)
|
|
68
|
+
// Forward the inner ref to the parent's ref (if provided).
|
|
69
|
+
// IMPORTANT: do NOT clear innerRef on cleanup — RN sometimes calls the
|
|
70
|
+
// ref callback with null mid-update, and clearing would break async
|
|
71
|
+
// measurement callbacks that fire after the ref reattaches.
|
|
54
72
|
const setRef = useCallback((node: ScrollView | null) => {
|
|
55
|
-
|
|
73
|
+
if (node !== null) {
|
|
74
|
+
innerRef.current = node;
|
|
75
|
+
}
|
|
56
76
|
if (typeof ref === 'function') {
|
|
57
77
|
ref(node);
|
|
58
78
|
} else if (ref) {
|
|
@@ -60,12 +80,20 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
60
80
|
}
|
|
61
81
|
}, [ref]);
|
|
62
82
|
|
|
63
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Measure window-relative bounds and register with SDK.
|
|
85
|
+
* Idempotent + safe to call after unmount (guards via isMounted).
|
|
86
|
+
*/
|
|
64
87
|
const measureAndRegister = useCallback(() => {
|
|
88
|
+
if (!isMounted.current) return;
|
|
65
89
|
const node = innerRef.current as any;
|
|
66
90
|
if (!node || typeof node.measureInWindow !== 'function') return;
|
|
67
91
|
node.measureInWindow((x: number, y: number, width: number, height: number) => {
|
|
92
|
+
if (!isMounted.current) return;
|
|
93
|
+
// RN sometimes returns 0/0 dims before the view is positioned —
|
|
94
|
+
// ignore those, the next onLayout/onScroll will re-measure.
|
|
68
95
|
if (width <= 0 || height <= 0) return;
|
|
96
|
+
lastViewportTop.current = y;
|
|
69
97
|
try {
|
|
70
98
|
AppAnalytics.getInstance().registerScrollView(scrollViewId.current, {
|
|
71
99
|
x, y, width, height,
|
|
@@ -74,17 +102,27 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
74
102
|
});
|
|
75
103
|
}, []);
|
|
76
104
|
|
|
77
|
-
// Cleanup timers + unregister on unmount
|
|
105
|
+
// Cleanup all timers + unregister + listeners on unmount
|
|
78
106
|
useEffect(() => {
|
|
79
107
|
const id = scrollViewId.current;
|
|
108
|
+
isMounted.current = true;
|
|
109
|
+
|
|
110
|
+
// Re-measure when keyboard appears/disappears (causes layout shift)
|
|
111
|
+
const showSub = Keyboard.addListener('keyboardDidShow', measureAndRegister);
|
|
112
|
+
const hideSub = Keyboard.addListener('keyboardDidHide', measureAndRegister);
|
|
113
|
+
|
|
80
114
|
return () => {
|
|
115
|
+
isMounted.current = false;
|
|
81
116
|
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
|
82
117
|
if (scrollIntervalTimer.current) clearInterval(scrollIntervalTimer.current);
|
|
118
|
+
if (initialLayoutTimer.current) clearTimeout(initialLayoutTimer.current);
|
|
119
|
+
showSub.remove();
|
|
120
|
+
hideSub.remove();
|
|
83
121
|
try {
|
|
84
122
|
AppAnalytics.getInstance().unregisterScrollView(id);
|
|
85
123
|
} catch {}
|
|
86
124
|
};
|
|
87
|
-
}, []);
|
|
125
|
+
}, [measureAndRegister]);
|
|
88
126
|
|
|
89
127
|
/**
|
|
90
128
|
* Core tile capture logic. Reads from refs (stable across renders).
|
|
@@ -93,6 +131,7 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
93
131
|
* @param force Skip throttle check (used for interval captures)
|
|
94
132
|
*/
|
|
95
133
|
const doCapture = (force = false) => {
|
|
134
|
+
if (!isMounted.current) return;
|
|
96
135
|
const state = scrollState.current;
|
|
97
136
|
if (!state || state.viewportHeight <= 0) return;
|
|
98
137
|
|
|
@@ -108,10 +147,13 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
108
147
|
initialCaptured.current = true;
|
|
109
148
|
|
|
110
149
|
try {
|
|
150
|
+
const windowHeight = Dimensions.get('window').height;
|
|
111
151
|
AppAnalytics.getInstance().captureScrollTile(
|
|
112
152
|
state.offsetY,
|
|
113
153
|
state.viewportHeight,
|
|
114
154
|
state.contentHeight,
|
|
155
|
+
lastViewportTop.current ?? undefined,
|
|
156
|
+
windowHeight,
|
|
115
157
|
);
|
|
116
158
|
} catch {}
|
|
117
159
|
};
|
|
@@ -144,6 +186,14 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
144
186
|
sdk.updateScrollViewOffset(scrollViewId.current, contentOffset.y);
|
|
145
187
|
} catch {}
|
|
146
188
|
|
|
189
|
+
// Re-measure bounds occasionally during scroll — catches layout
|
|
190
|
+
// shifts from animations, keyboard, orientation changes, etc.
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
if (now - lastMeasureTime.current > REMEASURE_THROTTLE_MS) {
|
|
193
|
+
lastMeasureTime.current = now;
|
|
194
|
+
measureAndRegister();
|
|
195
|
+
}
|
|
196
|
+
|
|
147
197
|
// Update scroll state — use realContentHeight if available
|
|
148
198
|
const bestContentHeight = realContentHeight.current > 0
|
|
149
199
|
? realContentHeight.current
|
|
@@ -163,12 +213,13 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
163
213
|
|
|
164
214
|
// Capture initial tile (Y~0) on first scroll event with real dims
|
|
165
215
|
if (!initialCaptured.current && contentOffset.y < 10) {
|
|
166
|
-
|
|
216
|
+
if (initialLayoutTimer.current) clearTimeout(initialLayoutTimer.current);
|
|
217
|
+
initialLayoutTimer.current = setTimeout(() => doCapture(), 200);
|
|
167
218
|
}
|
|
168
219
|
|
|
169
220
|
onScroll?.(event);
|
|
170
221
|
},
|
|
171
|
-
[onScroll],
|
|
222
|
+
[onScroll, measureAndRegister],
|
|
172
223
|
);
|
|
173
224
|
|
|
174
225
|
const handleScrollEndDrag = useCallback(
|
|
@@ -196,7 +247,6 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
196
247
|
|
|
197
248
|
const handleLayout = useCallback(
|
|
198
249
|
(event: LayoutChangeEvent) => {
|
|
199
|
-
// Capture initial tile after layout (gets real viewportHeight)
|
|
200
250
|
const { height } = event.nativeEvent.layout;
|
|
201
251
|
if (!initialCaptured.current && height > 0) {
|
|
202
252
|
scrollState.current = {
|
|
@@ -204,12 +254,14 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
204
254
|
viewportHeight: height,
|
|
205
255
|
contentHeight: realContentHeight.current || height,
|
|
206
256
|
};
|
|
207
|
-
|
|
257
|
+
// Clear any prior pending initial-capture timer before scheduling
|
|
258
|
+
if (initialLayoutTimer.current) clearTimeout(initialLayoutTimer.current);
|
|
259
|
+
initialLayoutTimer.current = setTimeout(() => doCapture(), 1000);
|
|
208
260
|
}
|
|
209
|
-
// Register window-relative bounds
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
|
|
261
|
+
// Register window-relative bounds immediately (no setTimeout).
|
|
262
|
+
// measureInWindow itself is async; if it returns 0/0 dims, the
|
|
263
|
+
// next onLayout / onScroll will retry — no race window.
|
|
264
|
+
measureAndRegister();
|
|
213
265
|
onLayout?.(event);
|
|
214
266
|
},
|
|
215
267
|
[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;
|
|
@@ -412,14 +446,21 @@ export class AppAnalytics {
|
|
|
412
446
|
* Capture the current viewport as a tile for collaborative screen
|
|
413
447
|
* building. Called by RepliqoScrollView on scroll stops.
|
|
414
448
|
*
|
|
415
|
-
* The tile includes
|
|
416
|
-
* in the full content this viewport belongs
|
|
417
|
-
*
|
|
449
|
+
* The tile includes:
|
|
450
|
+
* - `scrollOffsetY`: where in the full content this viewport belongs
|
|
451
|
+
* - `viewportTop`: Y position (window logical px) of the ScrollView's
|
|
452
|
+
* top edge. Used by the backend to crop each tile to just the
|
|
453
|
+
* scrollable area, removing fixed headers/footers that would
|
|
454
|
+
* otherwise duplicate in the composite.
|
|
455
|
+
* - `windowHeight`: window height in logical px, for image-to-logical
|
|
456
|
+
* scale computation during backend cropping.
|
|
418
457
|
*/
|
|
419
458
|
captureScrollTile(
|
|
420
459
|
scrollOffsetY: number,
|
|
421
460
|
viewportHeight: number,
|
|
422
461
|
contentHeight: number,
|
|
462
|
+
viewportTop?: number,
|
|
463
|
+
windowHeight?: number,
|
|
423
464
|
): void {
|
|
424
465
|
if (!this.sessionId || !this.currentScreen) return;
|
|
425
466
|
if (viewportHeight <= 0) return; // No real dimensions yet
|
|
@@ -436,6 +477,7 @@ export class AppAnalytics {
|
|
|
436
477
|
this.logger.log(
|
|
437
478
|
`Scroll tile: "${screenName}" Y=${Math.round(scrollOffsetY)} ` +
|
|
438
479
|
`vp=${Math.round(viewportHeight)} content=${Math.round(contentHeight)} ` +
|
|
480
|
+
`top=${viewportTop !== undefined ? Math.round(viewportTop) : '?'} ` +
|
|
439
481
|
`${Math.round(frame.image.length / 1024)}KB`,
|
|
440
482
|
);
|
|
441
483
|
|
|
@@ -448,6 +490,8 @@ export class AppAnalytics {
|
|
|
448
490
|
scrollOffsetY,
|
|
449
491
|
viewportHeight,
|
|
450
492
|
contentHeight,
|
|
493
|
+
viewportTop,
|
|
494
|
+
windowHeight,
|
|
451
495
|
);
|
|
452
496
|
} catch (err) {
|
|
453
497
|
this.logger.warn('Scroll tile failed:', err);
|
|
@@ -229,6 +229,13 @@ export class ApiClient {
|
|
|
229
229
|
*/
|
|
230
230
|
/**
|
|
231
231
|
* Upload a scroll tile for collaborative screen building.
|
|
232
|
+
*
|
|
233
|
+
* @param viewportTop Y position (window logical px) of the ScrollView's
|
|
234
|
+
* top edge. Used by the backend to crop each tile to just the
|
|
235
|
+
* scrollable area, removing fixed headers/footers that would
|
|
236
|
+
* otherwise duplicate in the composite.
|
|
237
|
+
* @param windowHeight Window height (logical px) at capture time.
|
|
238
|
+
* Used by the backend to compute image-to-logical scale for cropping.
|
|
232
239
|
*/
|
|
233
240
|
async uploadScreenTile(
|
|
234
241
|
sessionId: string,
|
|
@@ -239,23 +246,33 @@ export class ApiClient {
|
|
|
239
246
|
scrollOffsetY: number,
|
|
240
247
|
viewportHeight: number,
|
|
241
248
|
contentHeight: number,
|
|
249
|
+
viewportTop?: number,
|
|
250
|
+
windowHeight?: number,
|
|
242
251
|
): Promise<void> {
|
|
243
252
|
try {
|
|
253
|
+
const body: Record<string, unknown> = {
|
|
254
|
+
sessionId,
|
|
255
|
+
screenName,
|
|
256
|
+
image,
|
|
257
|
+
width,
|
|
258
|
+
height,
|
|
259
|
+
scrollOffsetY: Math.round(scrollOffsetY),
|
|
260
|
+
viewportHeight: Math.round(viewportHeight),
|
|
261
|
+
contentHeight: Math.round(contentHeight),
|
|
262
|
+
};
|
|
263
|
+
if (typeof viewportTop === 'number' && Number.isFinite(viewportTop)) {
|
|
264
|
+
body.viewportTop = Math.round(viewportTop);
|
|
265
|
+
}
|
|
266
|
+
if (typeof windowHeight === 'number' && Number.isFinite(windowHeight)) {
|
|
267
|
+
body.windowHeight = Math.round(windowHeight);
|
|
268
|
+
}
|
|
269
|
+
|
|
244
270
|
const response = await fetch(
|
|
245
271
|
`${this.baseUrl}/api/screen-tiles`,
|
|
246
272
|
{
|
|
247
273
|
method: 'POST',
|
|
248
274
|
headers: this.getHeaders(),
|
|
249
|
-
body: JSON.stringify(
|
|
250
|
-
sessionId,
|
|
251
|
-
screenName,
|
|
252
|
-
image,
|
|
253
|
-
width,
|
|
254
|
-
height,
|
|
255
|
-
scrollOffsetY: Math.round(scrollOffsetY),
|
|
256
|
-
viewportHeight: Math.round(viewportHeight),
|
|
257
|
-
contentHeight: Math.round(contentHeight),
|
|
258
|
-
}),
|
|
275
|
+
body: JSON.stringify(body),
|
|
259
276
|
},
|
|
260
277
|
);
|
|
261
278
|
|