@repliqo/sdk-react-native 0.2.2 → 0.2.3
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 -20
- package/dist/components/RepliqoScrollView.js +85 -28
- package/dist/core/client.d.ts +9 -1
- package/dist/core/client.js +34 -14
- package/dist/transport/api.client.d.ts +4 -0
- package/dist/transport/api.client.js +28 -0
- package/package.json +1 -1
- package/src/components/RepliqoScrollView.tsx +110 -28
- package/src/core/client.ts +50 -21
- package/src/transport/api.client.ts +45 -0
|
@@ -1,26 +1,11 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { ScrollView, type ScrollViewProps } from 'react-native';
|
|
3
3
|
/**
|
|
4
|
-
* Drop-in replacement for React Native's ScrollView that
|
|
5
|
-
* reports the scroll offset to Repliqo for accurate heatmaps.
|
|
4
|
+
* Drop-in replacement for React Native's ScrollView that:
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Usage — just replace `<ScrollView>` with `<RepliqoScrollView>`:
|
|
12
|
-
*
|
|
13
|
-
* ```tsx
|
|
14
|
-
* import { RepliqoScrollView } from '@repliqo/sdk-react-native';
|
|
15
|
-
*
|
|
16
|
-
* // Before:
|
|
17
|
-
* <ScrollView>...</ScrollView>
|
|
18
|
-
*
|
|
19
|
-
* // After:
|
|
20
|
-
* <RepliqoScrollView>...</RepliqoScrollView>
|
|
21
|
-
* ```
|
|
22
|
-
*
|
|
23
|
-
* All ScrollView props are forwarded. Your existing `onScroll` handler
|
|
24
|
-
* still fires normally.
|
|
6
|
+
* 1. Reports scroll offset for accurate heatmap touch coords
|
|
7
|
+
* 2. Captures viewport tiles on scroll stops for collaborative
|
|
8
|
+
* screen building — the backend composites these into a
|
|
9
|
+
* full-content image over time from multiple user sessions
|
|
25
10
|
*/
|
|
26
11
|
export declare const RepliqoScrollView: React.ForwardRefExoticComponent<ScrollViewProps & React.RefAttributes<ScrollView>>;
|
|
@@ -37,42 +37,99 @@ exports.RepliqoScrollView = void 0;
|
|
|
37
37
|
const react_1 = __importStar(require("react"));
|
|
38
38
|
const react_native_1 = require("react-native");
|
|
39
39
|
const client_1 = require("../core/client");
|
|
40
|
+
/** Minimum scroll delta (px) to trigger a new tile capture. */
|
|
41
|
+
const MIN_DELTA_PX = 50;
|
|
42
|
+
/** Debounce: wait this long after scroll stops before capturing. */
|
|
43
|
+
const DEBOUNCE_MS = 500;
|
|
44
|
+
/** Throttle: minimum time between tile captures. */
|
|
45
|
+
const THROTTLE_MS = 3000;
|
|
40
46
|
/**
|
|
41
|
-
* Drop-in replacement for React Native's ScrollView that
|
|
42
|
-
* reports the scroll offset to Repliqo for accurate heatmaps.
|
|
47
|
+
* Drop-in replacement for React Native's ScrollView that:
|
|
43
48
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* Usage — just replace `<ScrollView>` with `<RepliqoScrollView>`:
|
|
49
|
-
*
|
|
50
|
-
* ```tsx
|
|
51
|
-
* import { RepliqoScrollView } from '@repliqo/sdk-react-native';
|
|
52
|
-
*
|
|
53
|
-
* // Before:
|
|
54
|
-
* <ScrollView>...</ScrollView>
|
|
55
|
-
*
|
|
56
|
-
* // After:
|
|
57
|
-
* <RepliqoScrollView>...</RepliqoScrollView>
|
|
58
|
-
* ```
|
|
59
|
-
*
|
|
60
|
-
* All ScrollView props are forwarded. Your existing `onScroll` handler
|
|
61
|
-
* still fires normally.
|
|
49
|
+
* 1. Reports scroll offset for accurate heatmap touch coords
|
|
50
|
+
* 2. Captures viewport tiles on scroll stops for collaborative
|
|
51
|
+
* screen building — the backend composites these into a
|
|
52
|
+
* full-content image over time from multiple user sessions
|
|
62
53
|
*/
|
|
63
|
-
exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, ...props }, ref) => {
|
|
54
|
+
exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumScrollEnd, onLayout, ...props }, ref) => {
|
|
55
|
+
const debounceTimer = (0, react_1.useRef)(null);
|
|
56
|
+
const lastCaptureTime = (0, react_1.useRef)(0);
|
|
57
|
+
const lastCaptureY = (0, react_1.useRef)(-999);
|
|
58
|
+
const initialCaptured = (0, react_1.useRef)(false);
|
|
59
|
+
const scrollState = (0, react_1.useRef)(null);
|
|
60
|
+
// Cleanup debounce timer on unmount
|
|
61
|
+
(0, react_1.useEffect)(() => {
|
|
62
|
+
return () => {
|
|
63
|
+
if (debounceTimer.current)
|
|
64
|
+
clearTimeout(debounceTimer.current);
|
|
65
|
+
};
|
|
66
|
+
}, []);
|
|
67
|
+
/**
|
|
68
|
+
* Core tile capture logic. Reads from refs (stable across renders).
|
|
69
|
+
* Checks throttle + delta before capturing.
|
|
70
|
+
*/
|
|
71
|
+
const doCapture = () => {
|
|
72
|
+
const state = scrollState.current;
|
|
73
|
+
if (!state || state.viewportHeight <= 0)
|
|
74
|
+
return;
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
if (now - lastCaptureTime.current < THROTTLE_MS)
|
|
77
|
+
return;
|
|
78
|
+
if (Math.abs(state.offsetY - lastCaptureY.current) < MIN_DELTA_PX &&
|
|
79
|
+
initialCaptured.current)
|
|
80
|
+
return;
|
|
81
|
+
lastCaptureTime.current = now;
|
|
82
|
+
lastCaptureY.current = state.offsetY;
|
|
83
|
+
initialCaptured.current = true;
|
|
84
|
+
try {
|
|
85
|
+
client_1.AppAnalytics.getInstance().captureScrollTile(state.offsetY, state.viewportHeight, state.contentHeight);
|
|
86
|
+
}
|
|
87
|
+
catch { }
|
|
88
|
+
};
|
|
64
89
|
const handleScroll = (0, react_1.useCallback)((event) => {
|
|
65
|
-
|
|
90
|
+
const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent;
|
|
91
|
+
// Report scroll offset for touch coords
|
|
66
92
|
try {
|
|
67
|
-
|
|
68
|
-
client_1.AppAnalytics.getInstance().setScrollOffset(offsetY);
|
|
93
|
+
client_1.AppAnalytics.getInstance().setScrollOffset(contentOffset.y);
|
|
69
94
|
}
|
|
70
|
-
catch {
|
|
71
|
-
|
|
95
|
+
catch { }
|
|
96
|
+
// Update scroll state
|
|
97
|
+
scrollState.current = {
|
|
98
|
+
offsetY: contentOffset.y,
|
|
99
|
+
viewportHeight: layoutMeasurement.height,
|
|
100
|
+
contentHeight: contentSize.height,
|
|
101
|
+
};
|
|
102
|
+
// Debounce: capture after scroll stops
|
|
103
|
+
if (debounceTimer.current)
|
|
104
|
+
clearTimeout(debounceTimer.current);
|
|
105
|
+
debounceTimer.current = setTimeout(doCapture, DEBOUNCE_MS);
|
|
106
|
+
// Capture initial tile (Y≈0) on first scroll event with real dimensions
|
|
107
|
+
if (!initialCaptured.current && contentOffset.y < 10) {
|
|
108
|
+
setTimeout(doCapture, 200);
|
|
72
109
|
}
|
|
73
|
-
// Forward to the original onScroll handler
|
|
74
110
|
onScroll?.(event);
|
|
75
111
|
}, [onScroll]);
|
|
76
|
-
|
|
112
|
+
const handleMomentumEnd = (0, react_1.useCallback)((event) => {
|
|
113
|
+
if (debounceTimer.current) {
|
|
114
|
+
clearTimeout(debounceTimer.current);
|
|
115
|
+
debounceTimer.current = null;
|
|
116
|
+
}
|
|
117
|
+
setTimeout(doCapture, 200);
|
|
118
|
+
onMomentumScrollEnd?.(event);
|
|
119
|
+
}, [onMomentumScrollEnd]);
|
|
120
|
+
const handleLayout = (0, react_1.useCallback)((event) => {
|
|
121
|
+
// Capture initial tile after layout (gets real viewportHeight)
|
|
122
|
+
const { height } = event.nativeEvent.layout;
|
|
123
|
+
if (!initialCaptured.current && height > 0) {
|
|
124
|
+
scrollState.current = {
|
|
125
|
+
offsetY: 0,
|
|
126
|
+
viewportHeight: height,
|
|
127
|
+
contentHeight: height, // Will be updated on first scroll
|
|
128
|
+
};
|
|
129
|
+
setTimeout(doCapture, 1000);
|
|
130
|
+
}
|
|
131
|
+
onLayout?.(event);
|
|
132
|
+
}, [onLayout]);
|
|
133
|
+
return (<react_native_1.ScrollView ref={ref} {...props} onScroll={handleScroll} onMomentumScrollEnd={handleMomentumEnd} onLayout={handleLayout} scrollEventThrottle={props.scrollEventThrottle ?? 16}/>);
|
|
77
134
|
});
|
|
78
135
|
exports.RepliqoScrollView.displayName = 'RepliqoScrollView';
|
package/dist/core/client.d.ts
CHANGED
|
@@ -7,7 +7,6 @@ export declare class AppAnalytics {
|
|
|
7
7
|
private screenVisitQueue;
|
|
8
8
|
private snapshotQueue;
|
|
9
9
|
private snapshotCapture;
|
|
10
|
-
private screenCaptureManager;
|
|
11
10
|
private errorTracker;
|
|
12
11
|
private sessionId;
|
|
13
12
|
private currentScreen;
|
|
@@ -32,6 +31,15 @@ export declare class AppAnalytics {
|
|
|
32
31
|
* on scrollable screens.
|
|
33
32
|
*/
|
|
34
33
|
setScrollOffset(y: number): void;
|
|
34
|
+
/**
|
|
35
|
+
* Capture the current viewport as a tile for collaborative screen
|
|
36
|
+
* building. Called by RepliqoScrollView on scroll stops.
|
|
37
|
+
*
|
|
38
|
+
* The tile includes the scrollOffsetY so the backend knows where
|
|
39
|
+
* in the full content this viewport belongs. Multiple users/sessions
|
|
40
|
+
* contribute tiles until the full content is covered.
|
|
41
|
+
*/
|
|
42
|
+
captureScrollTile(scrollOffsetY: number, viewportHeight: number, contentHeight: number): void;
|
|
35
43
|
onScreenEnter(screenName: string): void;
|
|
36
44
|
onScreenExit(screenName: string): void;
|
|
37
45
|
flush(): Promise<void>;
|
package/dist/core/client.js
CHANGED
|
@@ -3,18 +3,15 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.AppAnalytics = void 0;
|
|
4
4
|
const react_native_1 = require("react-native");
|
|
5
5
|
const capture_1 = require("../snapshot/capture");
|
|
6
|
-
const ScreenCaptureManager_1 = require("../snapshot/ScreenCaptureManager");
|
|
7
6
|
const config_1 = require("./config");
|
|
8
7
|
const logger_1 = require("./logger");
|
|
9
8
|
const api_client_1 = require("../transport/api.client");
|
|
10
9
|
const batch_queue_1 = require("../transport/batch-queue");
|
|
11
10
|
const error_tracker_1 = require("../trackers/error.tracker");
|
|
12
|
-
|
|
13
|
-
// when the ScreenCaptureModule is linked. No permission or service needed.
|
|
11
|
+
const NativeScreenCapture_1 = require("../snapshot/NativeScreenCapture");
|
|
14
12
|
class AppAnalytics {
|
|
15
13
|
constructor(config) {
|
|
16
14
|
this.snapshotCapture = null;
|
|
17
|
-
this.screenCaptureManager = null;
|
|
18
15
|
this.errorTracker = null;
|
|
19
16
|
this.sessionId = null;
|
|
20
17
|
this.currentScreen = null;
|
|
@@ -52,12 +49,6 @@ class AppAnalytics {
|
|
|
52
49
|
}, undefined, // default captureScreenshot provider
|
|
53
50
|
(...args) => this.logger.log(...args), (...args) => this.logger.warn(...args));
|
|
54
51
|
}
|
|
55
|
-
// Screen capture manager for heatmap backgrounds.
|
|
56
|
-
// Captures full ScrollView content on screen enter (24h cache).
|
|
57
|
-
// Independent of session replay snapshots.
|
|
58
|
-
if (this.config.enableHeatmapCapture) {
|
|
59
|
-
this.screenCaptureManager = new ScreenCaptureManager_1.ScreenCaptureManager((screenName, image, width, height, isFullContent) => this.apiClient.uploadScreenCapture(screenName, image, width, height, isFullContent), this.config.debug);
|
|
60
|
-
}
|
|
61
52
|
if (this.config.enableCrashTracking) {
|
|
62
53
|
this.errorTracker = new error_tracker_1.ErrorTracker((crash) => {
|
|
63
54
|
// Crash reports are sent immediately (fire-and-forget), not batched
|
|
@@ -102,8 +93,6 @@ class AppAnalytics {
|
|
|
102
93
|
instance.snapshotCapture.stop();
|
|
103
94
|
instance.snapshotCapture = null;
|
|
104
95
|
}
|
|
105
|
-
instance.screenCaptureManager?.reset();
|
|
106
|
-
instance.screenCaptureManager = null;
|
|
107
96
|
instance.eventQueue.stopAutoFlush();
|
|
108
97
|
instance.screenVisitQueue.stopAutoFlush();
|
|
109
98
|
instance.snapshotQueue.stopAutoFlush();
|
|
@@ -234,14 +223,45 @@ class AppAnalytics {
|
|
|
234
223
|
setScrollOffset(y) {
|
|
235
224
|
this.scrollOffsetY = y;
|
|
236
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Capture the current viewport as a tile for collaborative screen
|
|
228
|
+
* building. Called by RepliqoScrollView on scroll stops.
|
|
229
|
+
*
|
|
230
|
+
* The tile includes the scrollOffsetY so the backend knows where
|
|
231
|
+
* in the full content this viewport belongs. Multiple users/sessions
|
|
232
|
+
* contribute tiles until the full content is covered.
|
|
233
|
+
*/
|
|
234
|
+
captureScrollTile(scrollOffsetY, viewportHeight, contentHeight) {
|
|
235
|
+
if (!this.sessionId || !this.currentScreen)
|
|
236
|
+
return;
|
|
237
|
+
if (viewportHeight <= 0)
|
|
238
|
+
return; // No real dimensions yet
|
|
239
|
+
const screenName = this.currentScreen;
|
|
240
|
+
const sessionId = this.sessionId;
|
|
241
|
+
// Fire-and-forget: capture + upload in background
|
|
242
|
+
(async () => {
|
|
243
|
+
try {
|
|
244
|
+
const frame = await (0, NativeScreenCapture_1.captureNativeFrame)();
|
|
245
|
+
if (!frame)
|
|
246
|
+
return;
|
|
247
|
+
this.logger.log(`Scroll tile: "${screenName}" Y=${Math.round(scrollOffsetY)} ` +
|
|
248
|
+
`vp=${Math.round(viewportHeight)} content=${Math.round(contentHeight)} ` +
|
|
249
|
+
`${Math.round(frame.image.length / 1024)}KB`);
|
|
250
|
+
await this.apiClient.uploadScreenTile(sessionId, screenName, frame.image, frame.width, frame.height, scrollOffsetY, viewportHeight, contentHeight);
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
this.logger.warn('Scroll tile failed:', err);
|
|
254
|
+
}
|
|
255
|
+
})();
|
|
256
|
+
}
|
|
237
257
|
onScreenEnter(screenName) {
|
|
238
258
|
this.currentScreen = screenName;
|
|
239
259
|
this.currentScreenEnteredAt = new Date().toISOString();
|
|
240
260
|
this.scrollOffsetY = 0;
|
|
241
261
|
this.errorTracker?.setCurrentScreen(screenName);
|
|
242
262
|
this.snapshotCapture?.setCurrentScreen(screenName);
|
|
243
|
-
//
|
|
244
|
-
|
|
263
|
+
// Initial tile capture (Y=0) is handled by RepliqoScrollView's
|
|
264
|
+
// onLayout event, which provides real viewport dimensions.
|
|
245
265
|
this.logger.log('Screen entered:', screenName);
|
|
246
266
|
}
|
|
247
267
|
onScreenExit(screenName) {
|
|
@@ -30,5 +30,9 @@ export declare class ApiClient {
|
|
|
30
30
|
* Upload a full-content screen capture for heatmap backgrounds.
|
|
31
31
|
* Fire-and-forget: errors are logged but never thrown.
|
|
32
32
|
*/
|
|
33
|
+
/**
|
|
34
|
+
* Upload a scroll tile for collaborative screen building.
|
|
35
|
+
*/
|
|
36
|
+
uploadScreenTile(sessionId: string, screenName: string, image: string, width: number, height: number, scrollOffsetY: number, viewportHeight: number, contentHeight: number): Promise<void>;
|
|
33
37
|
uploadScreenCapture(screenName: string, image: string, width: number, height: number, isFullContent: boolean): Promise<void>;
|
|
34
38
|
}
|
|
@@ -156,6 +156,34 @@ class ApiClient {
|
|
|
156
156
|
* Upload a full-content screen capture for heatmap backgrounds.
|
|
157
157
|
* Fire-and-forget: errors are logged but never thrown.
|
|
158
158
|
*/
|
|
159
|
+
/**
|
|
160
|
+
* Upload a scroll tile for collaborative screen building.
|
|
161
|
+
*/
|
|
162
|
+
async uploadScreenTile(sessionId, screenName, image, width, height, scrollOffsetY, viewportHeight, contentHeight) {
|
|
163
|
+
try {
|
|
164
|
+
const response = await fetch(`${this.baseUrl}/api/screen-tiles`, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
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
|
+
}),
|
|
177
|
+
});
|
|
178
|
+
if (!response.ok) {
|
|
179
|
+
const text = await response.text();
|
|
180
|
+
this.logger.warn('Screen tile upload failed:', response.status, text?.substring(0, 200));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
this.logger.warn('Error uploading screen tile:', error);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
159
187
|
async uploadScreenCapture(screenName, image, width, height, isFullContent) {
|
|
160
188
|
try {
|
|
161
189
|
const response = await fetch(`${this.baseUrl}/api/screen-captures`, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@repliqo/sdk-react-native",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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,58 +1,140 @@
|
|
|
1
|
-
import React, { useCallback } from 'react';
|
|
1
|
+
import React, { useCallback, useRef, useEffect } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
ScrollView,
|
|
4
4
|
type ScrollViewProps,
|
|
5
5
|
type NativeSyntheticEvent,
|
|
6
6
|
type NativeScrollEvent,
|
|
7
|
+
type LayoutChangeEvent,
|
|
7
8
|
} from 'react-native';
|
|
8
9
|
import { AppAnalytics } from '../core/client';
|
|
9
10
|
|
|
11
|
+
/** Minimum scroll delta (px) to trigger a new tile capture. */
|
|
12
|
+
const MIN_DELTA_PX = 50;
|
|
13
|
+
/** Debounce: wait this long after scroll stops before capturing. */
|
|
14
|
+
const DEBOUNCE_MS = 500;
|
|
15
|
+
/** Throttle: minimum time between tile captures. */
|
|
16
|
+
const THROTTLE_MS = 3000;
|
|
17
|
+
|
|
10
18
|
/**
|
|
11
|
-
* Drop-in replacement for React Native's ScrollView that
|
|
12
|
-
* reports the scroll offset to Repliqo for accurate heatmaps.
|
|
13
|
-
*
|
|
14
|
-
* Without this, touch coordinates on scrollable screens are relative to
|
|
15
|
-
* the visible viewport. With this, they're relative to the full content,
|
|
16
|
-
* so heatmaps accurately show which content elements users touch.
|
|
17
|
-
*
|
|
18
|
-
* Usage — just replace `<ScrollView>` with `<RepliqoScrollView>`:
|
|
19
|
-
*
|
|
20
|
-
* ```tsx
|
|
21
|
-
* import { RepliqoScrollView } from '@repliqo/sdk-react-native';
|
|
19
|
+
* Drop-in replacement for React Native's ScrollView that:
|
|
22
20
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* <RepliqoScrollView>...</RepliqoScrollView>
|
|
28
|
-
* ```
|
|
29
|
-
*
|
|
30
|
-
* All ScrollView props are forwarded. Your existing `onScroll` handler
|
|
31
|
-
* still fires normally.
|
|
21
|
+
* 1. Reports scroll offset for accurate heatmap touch coords
|
|
22
|
+
* 2. Captures viewport tiles on scroll stops for collaborative
|
|
23
|
+
* screen building — the backend composites these into a
|
|
24
|
+
* full-content image over time from multiple user sessions
|
|
32
25
|
*/
|
|
33
26
|
export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
34
|
-
({ onScroll, ...props }, ref) => {
|
|
27
|
+
({ onScroll, onMomentumScrollEnd, onLayout, ...props }, ref) => {
|
|
28
|
+
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
29
|
+
const lastCaptureTime = useRef(0);
|
|
30
|
+
const lastCaptureY = useRef(-999);
|
|
31
|
+
const initialCaptured = useRef(false);
|
|
32
|
+
const scrollState = useRef<{
|
|
33
|
+
offsetY: number;
|
|
34
|
+
viewportHeight: number;
|
|
35
|
+
contentHeight: number;
|
|
36
|
+
} | null>(null);
|
|
37
|
+
|
|
38
|
+
// Cleanup debounce timer on unmount
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
return () => {
|
|
41
|
+
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
|
42
|
+
};
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Core tile capture logic. Reads from refs (stable across renders).
|
|
47
|
+
* Checks throttle + delta before capturing.
|
|
48
|
+
*/
|
|
49
|
+
const doCapture = () => {
|
|
50
|
+
const state = scrollState.current;
|
|
51
|
+
if (!state || state.viewportHeight <= 0) return;
|
|
52
|
+
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
if (now - lastCaptureTime.current < THROTTLE_MS) return;
|
|
55
|
+
if (Math.abs(state.offsetY - lastCaptureY.current) < MIN_DELTA_PX &&
|
|
56
|
+
initialCaptured.current) return;
|
|
57
|
+
|
|
58
|
+
lastCaptureTime.current = now;
|
|
59
|
+
lastCaptureY.current = state.offsetY;
|
|
60
|
+
initialCaptured.current = true;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
AppAnalytics.getInstance().captureScrollTile(
|
|
64
|
+
state.offsetY,
|
|
65
|
+
state.viewportHeight,
|
|
66
|
+
state.contentHeight,
|
|
67
|
+
);
|
|
68
|
+
} catch {}
|
|
69
|
+
};
|
|
70
|
+
|
|
35
71
|
const handleScroll = useCallback(
|
|
36
72
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
37
|
-
|
|
73
|
+
const { contentOffset, layoutMeasurement, contentSize } =
|
|
74
|
+
event.nativeEvent;
|
|
75
|
+
|
|
76
|
+
// Report scroll offset for touch coords
|
|
38
77
|
try {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
78
|
+
AppAnalytics.getInstance().setScrollOffset(contentOffset.y);
|
|
79
|
+
} catch {}
|
|
80
|
+
|
|
81
|
+
// Update scroll state
|
|
82
|
+
scrollState.current = {
|
|
83
|
+
offsetY: contentOffset.y,
|
|
84
|
+
viewportHeight: layoutMeasurement.height,
|
|
85
|
+
contentHeight: contentSize.height,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Debounce: capture after scroll stops
|
|
89
|
+
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
|
90
|
+
debounceTimer.current = setTimeout(doCapture, DEBOUNCE_MS);
|
|
91
|
+
|
|
92
|
+
// Capture initial tile (Y≈0) on first scroll event with real dimensions
|
|
93
|
+
if (!initialCaptured.current && contentOffset.y < 10) {
|
|
94
|
+
setTimeout(doCapture, 200);
|
|
43
95
|
}
|
|
44
96
|
|
|
45
|
-
// Forward to the original onScroll handler
|
|
46
97
|
onScroll?.(event);
|
|
47
98
|
},
|
|
48
99
|
[onScroll],
|
|
49
100
|
);
|
|
50
101
|
|
|
102
|
+
const handleMomentumEnd = useCallback(
|
|
103
|
+
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
104
|
+
if (debounceTimer.current) {
|
|
105
|
+
clearTimeout(debounceTimer.current);
|
|
106
|
+
debounceTimer.current = null;
|
|
107
|
+
}
|
|
108
|
+
setTimeout(doCapture, 200);
|
|
109
|
+
onMomentumScrollEnd?.(event);
|
|
110
|
+
},
|
|
111
|
+
[onMomentumScrollEnd],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const handleLayout = useCallback(
|
|
115
|
+
(event: LayoutChangeEvent) => {
|
|
116
|
+
// Capture initial tile after layout (gets real viewportHeight)
|
|
117
|
+
const { height } = event.nativeEvent.layout;
|
|
118
|
+
if (!initialCaptured.current && height > 0) {
|
|
119
|
+
scrollState.current = {
|
|
120
|
+
offsetY: 0,
|
|
121
|
+
viewportHeight: height,
|
|
122
|
+
contentHeight: height, // Will be updated on first scroll
|
|
123
|
+
};
|
|
124
|
+
setTimeout(doCapture, 1000);
|
|
125
|
+
}
|
|
126
|
+
onLayout?.(event);
|
|
127
|
+
},
|
|
128
|
+
[onLayout],
|
|
129
|
+
);
|
|
130
|
+
|
|
51
131
|
return (
|
|
52
132
|
<ScrollView
|
|
53
133
|
ref={ref}
|
|
54
134
|
{...props}
|
|
55
135
|
onScroll={handleScroll}
|
|
136
|
+
onMomentumScrollEnd={handleMomentumEnd}
|
|
137
|
+
onLayout={handleLayout}
|
|
56
138
|
scrollEventThrottle={props.scrollEventThrottle ?? 16}
|
|
57
139
|
/>
|
|
58
140
|
);
|
package/src/core/client.ts
CHANGED
|
@@ -7,14 +7,12 @@ import {
|
|
|
7
7
|
} from '../types/events';
|
|
8
8
|
import { SnapshotPayload } from '../types/snapshot';
|
|
9
9
|
import { SnapshotCapture } from '../snapshot/capture';
|
|
10
|
-
import { ScreenCaptureManager } from '../snapshot/ScreenCaptureManager';
|
|
11
10
|
import { ResolvedConfig, resolveConfig, REPLIQO_API_URL } from './config';
|
|
12
11
|
import { Logger } from './logger';
|
|
13
12
|
import { ApiClient } from '../transport/api.client';
|
|
14
13
|
import { BatchQueue } from '../transport/batch-queue';
|
|
15
14
|
import { ErrorTracker } from '../trackers/error.tracker';
|
|
16
|
-
|
|
17
|
-
// when the ScreenCaptureModule is linked. No permission or service needed.
|
|
15
|
+
import { captureNativeFrame } from '../snapshot/NativeScreenCapture';
|
|
18
16
|
|
|
19
17
|
export class AppAnalytics {
|
|
20
18
|
private static instance: AppAnalytics | null = null;
|
|
@@ -25,7 +23,6 @@ export class AppAnalytics {
|
|
|
25
23
|
private screenVisitQueue: BatchQueue<ScreenVisit>;
|
|
26
24
|
private snapshotQueue: BatchQueue<SnapshotPayload>;
|
|
27
25
|
private snapshotCapture: SnapshotCapture | null = null;
|
|
28
|
-
private screenCaptureManager: ScreenCaptureManager | null = null;
|
|
29
26
|
private errorTracker: ErrorTracker | null = null;
|
|
30
27
|
private sessionId: string | null = null;
|
|
31
28
|
private currentScreen: string | null = null;
|
|
@@ -96,19 +93,6 @@ export class AppAnalytics {
|
|
|
96
93
|
);
|
|
97
94
|
}
|
|
98
95
|
|
|
99
|
-
// Screen capture manager for heatmap backgrounds.
|
|
100
|
-
// Captures full ScrollView content on screen enter (24h cache).
|
|
101
|
-
// Independent of session replay snapshots.
|
|
102
|
-
if (this.config.enableHeatmapCapture) {
|
|
103
|
-
this.screenCaptureManager = new ScreenCaptureManager(
|
|
104
|
-
(screenName, image, width, height, isFullContent) =>
|
|
105
|
-
this.apiClient.uploadScreenCapture(
|
|
106
|
-
screenName, image, width, height, isFullContent,
|
|
107
|
-
),
|
|
108
|
-
this.config.debug,
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
96
|
if (this.config.enableCrashTracking) {
|
|
113
97
|
this.errorTracker = new ErrorTracker((crash) => {
|
|
114
98
|
// Crash reports are sent immediately (fire-and-forget), not batched
|
|
@@ -166,8 +150,6 @@ export class AppAnalytics {
|
|
|
166
150
|
instance.snapshotCapture = null;
|
|
167
151
|
}
|
|
168
152
|
|
|
169
|
-
instance.screenCaptureManager?.reset();
|
|
170
|
-
instance.screenCaptureManager = null;
|
|
171
153
|
|
|
172
154
|
instance.eventQueue.stopAutoFlush();
|
|
173
155
|
instance.screenVisitQueue.stopAutoFlush();
|
|
@@ -339,14 +321,61 @@ export class AppAnalytics {
|
|
|
339
321
|
this.scrollOffsetY = y;
|
|
340
322
|
}
|
|
341
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Capture the current viewport as a tile for collaborative screen
|
|
326
|
+
* building. Called by RepliqoScrollView on scroll stops.
|
|
327
|
+
*
|
|
328
|
+
* The tile includes the scrollOffsetY so the backend knows where
|
|
329
|
+
* in the full content this viewport belongs. Multiple users/sessions
|
|
330
|
+
* contribute tiles until the full content is covered.
|
|
331
|
+
*/
|
|
332
|
+
captureScrollTile(
|
|
333
|
+
scrollOffsetY: number,
|
|
334
|
+
viewportHeight: number,
|
|
335
|
+
contentHeight: number,
|
|
336
|
+
): void {
|
|
337
|
+
if (!this.sessionId || !this.currentScreen) return;
|
|
338
|
+
if (viewportHeight <= 0) return; // No real dimensions yet
|
|
339
|
+
|
|
340
|
+
const screenName = this.currentScreen;
|
|
341
|
+
const sessionId = this.sessionId;
|
|
342
|
+
|
|
343
|
+
// Fire-and-forget: capture + upload in background
|
|
344
|
+
(async () => {
|
|
345
|
+
try {
|
|
346
|
+
const frame = await captureNativeFrame();
|
|
347
|
+
if (!frame) return;
|
|
348
|
+
|
|
349
|
+
this.logger.log(
|
|
350
|
+
`Scroll tile: "${screenName}" Y=${Math.round(scrollOffsetY)} ` +
|
|
351
|
+
`vp=${Math.round(viewportHeight)} content=${Math.round(contentHeight)} ` +
|
|
352
|
+
`${Math.round(frame.image.length / 1024)}KB`,
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
await this.apiClient.uploadScreenTile(
|
|
356
|
+
sessionId,
|
|
357
|
+
screenName,
|
|
358
|
+
frame.image,
|
|
359
|
+
frame.width,
|
|
360
|
+
frame.height,
|
|
361
|
+
scrollOffsetY,
|
|
362
|
+
viewportHeight,
|
|
363
|
+
contentHeight,
|
|
364
|
+
);
|
|
365
|
+
} catch (err) {
|
|
366
|
+
this.logger.warn('Scroll tile failed:', err);
|
|
367
|
+
}
|
|
368
|
+
})();
|
|
369
|
+
}
|
|
370
|
+
|
|
342
371
|
onScreenEnter(screenName: string): void {
|
|
343
372
|
this.currentScreen = screenName;
|
|
344
373
|
this.currentScreenEnteredAt = new Date().toISOString();
|
|
345
374
|
this.scrollOffsetY = 0;
|
|
346
375
|
this.errorTracker?.setCurrentScreen(screenName);
|
|
347
376
|
this.snapshotCapture?.setCurrentScreen(screenName);
|
|
348
|
-
//
|
|
349
|
-
|
|
377
|
+
// Initial tile capture (Y=0) is handled by RepliqoScrollView's
|
|
378
|
+
// onLayout event, which provides real viewport dimensions.
|
|
350
379
|
this.logger.log('Screen entered:', screenName);
|
|
351
380
|
}
|
|
352
381
|
|
|
@@ -227,6 +227,51 @@ export class ApiClient {
|
|
|
227
227
|
* Upload a full-content screen capture for heatmap backgrounds.
|
|
228
228
|
* Fire-and-forget: errors are logged but never thrown.
|
|
229
229
|
*/
|
|
230
|
+
/**
|
|
231
|
+
* Upload a scroll tile for collaborative screen building.
|
|
232
|
+
*/
|
|
233
|
+
async uploadScreenTile(
|
|
234
|
+
sessionId: string,
|
|
235
|
+
screenName: string,
|
|
236
|
+
image: string,
|
|
237
|
+
width: number,
|
|
238
|
+
height: number,
|
|
239
|
+
scrollOffsetY: number,
|
|
240
|
+
viewportHeight: number,
|
|
241
|
+
contentHeight: number,
|
|
242
|
+
): Promise<void> {
|
|
243
|
+
try {
|
|
244
|
+
const response = await fetch(
|
|
245
|
+
`${this.baseUrl}/api/screen-tiles`,
|
|
246
|
+
{
|
|
247
|
+
method: 'POST',
|
|
248
|
+
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
|
+
}),
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
if (!response.ok) {
|
|
263
|
+
const text = await response.text();
|
|
264
|
+
this.logger.warn(
|
|
265
|
+
'Screen tile upload failed:',
|
|
266
|
+
response.status,
|
|
267
|
+
text?.substring(0, 200),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
} catch (error) {
|
|
271
|
+
this.logger.warn('Error uploading screen tile:', error);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
230
275
|
async uploadScreenCapture(
|
|
231
276
|
screenName: string,
|
|
232
277
|
image: string,
|