@repliqo/sdk-react-native 0.3.7 → 0.4.0

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.
Files changed (48) hide show
  1. package/INTEGRATION_GUIDE.md +1384 -1312
  2. package/android/src/main/java/com/repliqo/screencapture/MultiWindowCapture.java +242 -230
  3. package/android/src/main/java/com/repliqo/screencapture/ScreenCaptureModule.java +94 -170
  4. package/dist/components/RepliqoFlatList.d.ts +5 -0
  5. package/dist/components/RepliqoFlatList.js +39 -0
  6. package/dist/components/RepliqoScrollView.d.ts +3 -0
  7. package/dist/components/RepliqoScrollView.js +16 -265
  8. package/dist/components/useRepliqoScrollTracking.d.ts +33 -0
  9. package/dist/components/useRepliqoScrollTracking.js +304 -0
  10. package/dist/core/client.d.ts +52 -5
  11. package/dist/core/client.js +310 -48
  12. package/dist/core/config.d.ts +1 -1
  13. package/dist/core/config.js +5 -1
  14. package/dist/core/storage.d.ts +29 -0
  15. package/dist/core/storage.js +33 -0
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +3 -2
  18. package/dist/snapshot/NativeScreenCapture.d.ts +6 -44
  19. package/dist/snapshot/NativeScreenCapture.js +6 -64
  20. package/dist/snapshot/capture.d.ts +7 -0
  21. package/dist/snapshot/capture.js +17 -5
  22. package/dist/trackers/error.tracker.d.ts +25 -0
  23. package/dist/trackers/error.tracker.js +114 -37
  24. package/dist/trackers/navigation.tracker.js +11 -0
  25. package/dist/transport/api.client.d.ts +34 -1
  26. package/dist/transport/api.client.js +102 -19
  27. package/dist/transport/batch-queue.d.ts +53 -1
  28. package/dist/transport/batch-queue.js +126 -19
  29. package/dist/types/events.d.ts +12 -0
  30. package/package.json +68 -64
  31. package/src/components/RepliqoFlatList.tsx +64 -0
  32. package/src/components/RepliqoScrollView.tsx +53 -302
  33. package/src/components/useRepliqoScrollTracking.ts +366 -0
  34. package/src/core/client.ts +953 -672
  35. package/src/core/config.ts +38 -30
  36. package/src/core/storage.ts +51 -0
  37. package/src/index.ts +54 -52
  38. package/src/snapshot/NativeScreenCapture.ts +62 -157
  39. package/src/snapshot/capture.ts +154 -142
  40. package/src/trackers/error.tracker.ts +211 -136
  41. package/src/trackers/navigation.tracker.ts +107 -98
  42. package/src/transport/api.client.ts +430 -327
  43. package/src/transport/batch-queue.ts +212 -95
  44. package/src/types/events.ts +72 -60
  45. package/android/src/main/java/com/repliqo/screencapture/FullContentCapture.java +0 -273
  46. package/android/src/main/java/com/repliqo/screencapture/PixelCopyScan.java +0 -279
  47. package/android/src/main/java/com/repliqo/screencapture/ScrollScanCapture.java +0 -248
  48. package/src/snapshot/ScreenCaptureManager.ts +0 -156
@@ -0,0 +1,366 @@
1
+ import { useCallback, useRef, useEffect } from 'react';
2
+ import {
3
+ Keyboard,
4
+ Dimensions,
5
+ type NativeSyntheticEvent,
6
+ type NativeScrollEvent,
7
+ type LayoutChangeEvent,
8
+ } from 'react-native';
9
+ import { AppAnalytics } from '../core/client';
10
+
11
+ /** Minimum scroll delta (px) to trigger a new tile capture. */
12
+ const MIN_DELTA_PX = 20;
13
+ /** Debounce: wait this long after scroll stops before capturing. */
14
+ const DEBOUNCE_MS = 250;
15
+ /** Throttle: minimum time between tile captures. */
16
+ const THROTTLE_MS = 400;
17
+ /**
18
+ * During continuous scroll, force a capture every this many ms.
19
+ * Ensures tiles are captured even during fast, uninterrupted scrolls
20
+ * where the user never pauses long enough for the debounce to fire.
21
+ */
22
+ const SCROLL_INTERVAL_MS = 1200;
23
+ /**
24
+ * During scroll, throttle window-bounds re-measurement to at most once per
25
+ * this many ms. Bounds rarely change while scrolling, but we still want to
26
+ * catch orientation changes / keyboard / overlay animations.
27
+ */
28
+ const REMEASURE_THROTTLE_MS = 500;
29
+ /**
30
+ * The continuous-scroll interval self-stops when no scroll event has been
31
+ * seen for this long. Programmatic scrolls (scrollTo / Animated) fire
32
+ * onScroll but never onScrollEndDrag/onMomentumScrollEnd — without this,
33
+ * the interval would keep running until unmount.
34
+ */
35
+ const INTERVAL_IDLE_STOP_MS = SCROLL_INTERVAL_MS * 2;
36
+
37
+ let scrollViewIdCounter = 0;
38
+ const nextScrollViewId = () => `repliqo-scroll-${++scrollViewIdCounter}`;
39
+
40
+ export interface RepliqoScrollHandlers {
41
+ /** Attach as the scrollable's ref (also forwards to the user's ref). */
42
+ setRef: (node: unknown) => void;
43
+ onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
44
+ onScrollEndDrag: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
45
+ onMomentumScrollEnd: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
46
+ onLayout: (event: LayoutChangeEvent) => void;
47
+ onContentSizeChange: (w: number, h: number) => void;
48
+ }
49
+
50
+ interface ForwardedHandlers {
51
+ onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
52
+ onScrollEndDrag?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
53
+ onMomentumScrollEnd?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
54
+ onLayout?: (event: LayoutChangeEvent) => void;
55
+ onContentSizeChange?: (w: number, h: number) => void;
56
+ }
57
+
58
+ /**
59
+ * Shared scroll-analytics behavior behind RepliqoScrollView and
60
+ * RepliqoFlatList:
61
+ *
62
+ * 1. Reports scroll offset for accurate heatmap touch coords
63
+ * 2. Captures viewport tiles on scroll stops AND during continuous
64
+ * scroll for collaborative screen building
65
+ * 3. Registers window-relative bounds with the SDK so touches are
66
+ * auto-classified as content (inside) vs fixed UI (outside)
67
+ *
68
+ * @param forwardedRef The consumer's ref (function or object).
69
+ * @param userHandlers The consumer's own scroll/layout handlers — always
70
+ * called after the SDK's.
71
+ */
72
+ export function useRepliqoScrollTracking(
73
+ forwardedRef: React.Ref<unknown>,
74
+ userHandlers: ForwardedHandlers,
75
+ ): RepliqoScrollHandlers {
76
+ const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
77
+ const scrollIntervalTimer = useRef<ReturnType<typeof setInterval> | null>(null);
78
+ const initialLayoutTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
79
+ const lastCaptureTime = useRef(0);
80
+ const lastCaptureY = useRef(-999);
81
+ const lastMeasureTime = useRef(0);
82
+ const lastScrollEventAt = useRef(0);
83
+ const initialCaptured = useRef(false);
84
+ const realContentHeight = useRef(0);
85
+ const scrollViewId = useRef<string>(nextScrollViewId());
86
+ const innerRef = useRef<unknown>(null);
87
+ const isMounted = useRef(true);
88
+ // Last measured window-relative top Y — passed to captureScrollTile so
89
+ // the backend can crop each tile to just the scroll viewport area.
90
+ const lastViewportTop = useRef<number | null>(null);
91
+ const scrollState = useRef<{
92
+ offsetY: number;
93
+ viewportHeight: number;
94
+ contentHeight: number;
95
+ } | null>(null);
96
+
97
+ // Keep the latest user handlers in a ref so our stable callbacks always
98
+ // call the current ones.
99
+ const handlersRef = useRef(userHandlers);
100
+ handlersRef.current = userHandlers;
101
+
102
+ // Forward the inner ref to the consumer's ref (if provided).
103
+ // IMPORTANT: do NOT clear innerRef on cleanup — RN sometimes calls the
104
+ // ref callback with null mid-update, and clearing would break async
105
+ // measurement callbacks that fire after the ref reattaches.
106
+ const setRef = useCallback(
107
+ (node: unknown) => {
108
+ if (node !== null) {
109
+ innerRef.current = node;
110
+ }
111
+ if (typeof forwardedRef === 'function') {
112
+ forwardedRef(node);
113
+ } else if (forwardedRef) {
114
+ (forwardedRef as React.MutableRefObject<unknown>).current = node;
115
+ }
116
+ },
117
+ [forwardedRef],
118
+ );
119
+
120
+ /**
121
+ * Resolve the node that actually supports measureInWindow. FlatList (and
122
+ * other VirtualizedList wrappers) don't expose it directly — their inner
123
+ * ScrollView does, reachable via getNativeScrollRef().
124
+ */
125
+ const resolveMeasurableNode = (): any => {
126
+ const node: any = innerRef.current;
127
+ if (!node) return null;
128
+ if (typeof node.measureInWindow === 'function') return node;
129
+ try {
130
+ const inner =
131
+ typeof node.getNativeScrollRef === 'function'
132
+ ? node.getNativeScrollRef()
133
+ : typeof node.getScrollableNode === 'function'
134
+ ? node.getScrollableNode()
135
+ : null;
136
+ if (inner && typeof inner.measureInWindow === 'function') return inner;
137
+ } catch {
138
+ /* ignore */
139
+ }
140
+ return null;
141
+ };
142
+
143
+ /**
144
+ * Measure window-relative bounds and register with the SDK.
145
+ * Idempotent + safe to call after unmount (guards via isMounted).
146
+ */
147
+ const measureAndRegister = useCallback(() => {
148
+ if (!isMounted.current) return;
149
+ const node = resolveMeasurableNode();
150
+ if (!node) return;
151
+ node.measureInWindow((x: number, y: number, width: number, height: number) => {
152
+ if (!isMounted.current) return;
153
+ // RN sometimes returns 0/0 dims before the view is positioned —
154
+ // ignore those, the next onLayout/onScroll will re-measure.
155
+ if (width <= 0 || height <= 0) return;
156
+ lastViewportTop.current = y;
157
+ try {
158
+ AppAnalytics.getInstance().registerScrollView(scrollViewId.current, {
159
+ x, y, width, height,
160
+ });
161
+ } catch {}
162
+ });
163
+ }, []);
164
+
165
+ // Cleanup all timers + unregister + listeners on unmount
166
+ useEffect(() => {
167
+ const id = scrollViewId.current;
168
+ isMounted.current = true;
169
+
170
+ // Re-measure when keyboard appears/disappears (causes layout shift)
171
+ const showSub = Keyboard.addListener('keyboardDidShow', measureAndRegister);
172
+ const hideSub = Keyboard.addListener('keyboardDidHide', measureAndRegister);
173
+
174
+ return () => {
175
+ isMounted.current = false;
176
+ if (debounceTimer.current) clearTimeout(debounceTimer.current);
177
+ if (scrollIntervalTimer.current) clearInterval(scrollIntervalTimer.current);
178
+ if (initialLayoutTimer.current) clearTimeout(initialLayoutTimer.current);
179
+ showSub.remove();
180
+ hideSub.remove();
181
+ try {
182
+ AppAnalytics.getInstance().unregisterScrollView(id);
183
+ } catch {}
184
+ };
185
+ }, [measureAndRegister]);
186
+
187
+ /**
188
+ * Core tile capture logic. Reads from refs (stable across renders).
189
+ * Checks throttle + delta before capturing.
190
+ *
191
+ * @param force Skip throttle check (used for interval captures)
192
+ */
193
+ const doCapture = (force = false) => {
194
+ if (!isMounted.current) return;
195
+ const state = scrollState.current;
196
+ if (!state || state.viewportHeight <= 0) return;
197
+
198
+ const now = Date.now();
199
+ if (!force && now - lastCaptureTime.current < THROTTLE_MS) return;
200
+
201
+ // Always check delta — no point re-capturing the same position
202
+ if (
203
+ Math.abs(state.offsetY - lastCaptureY.current) < MIN_DELTA_PX &&
204
+ initialCaptured.current
205
+ )
206
+ return;
207
+
208
+ lastCaptureTime.current = now;
209
+ lastCaptureY.current = state.offsetY;
210
+ initialCaptured.current = true;
211
+
212
+ try {
213
+ const windowHeight = Dimensions.get('window').height;
214
+ AppAnalytics.getInstance().captureScrollTile(
215
+ state.offsetY,
216
+ state.viewportHeight,
217
+ state.contentHeight,
218
+ lastViewportTop.current ?? undefined,
219
+ windowHeight,
220
+ );
221
+ } catch {}
222
+ };
223
+
224
+ /** Stop the continuous-scroll interval timer. */
225
+ const stopScrollInterval = () => {
226
+ if (scrollIntervalTimer.current !== null) {
227
+ clearInterval(scrollIntervalTimer.current);
228
+ scrollIntervalTimer.current = null;
229
+ }
230
+ };
231
+
232
+ /**
233
+ * Start the interval timer for continuous scroll captures. Self-stops
234
+ * when scroll events stop arriving (programmatic scrolls never fire the
235
+ * drag/momentum end events that would stop it otherwise).
236
+ */
237
+ const startScrollInterval = () => {
238
+ if (scrollIntervalTimer.current !== null) return;
239
+ scrollIntervalTimer.current = setInterval(() => {
240
+ if (Date.now() - lastScrollEventAt.current > INTERVAL_IDLE_STOP_MS) {
241
+ stopScrollInterval();
242
+ return;
243
+ }
244
+ doCapture(true); // force=true skips throttle
245
+ }, SCROLL_INTERVAL_MS);
246
+ };
247
+
248
+ const onScroll = useCallback(
249
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
250
+ const { contentOffset, layoutMeasurement, contentSize } =
251
+ event.nativeEvent;
252
+
253
+ lastScrollEventAt.current = Date.now();
254
+
255
+ // Update SDK with current scroll offset (both legacy + new registry)
256
+ try {
257
+ const sdk = AppAnalytics.getInstance();
258
+ sdk.setScrollOffset(contentOffset.y);
259
+ sdk.updateScrollViewOffset(scrollViewId.current, contentOffset.y);
260
+ } catch {}
261
+
262
+ // Re-measure bounds occasionally during scroll — catches layout
263
+ // shifts from animations, keyboard, orientation changes, etc.
264
+ const now = Date.now();
265
+ if (now - lastMeasureTime.current > REMEASURE_THROTTLE_MS) {
266
+ lastMeasureTime.current = now;
267
+ measureAndRegister();
268
+ }
269
+
270
+ // Update scroll state — use realContentHeight if available
271
+ const bestContentHeight =
272
+ realContentHeight.current > 0
273
+ ? realContentHeight.current
274
+ : contentSize.height;
275
+ scrollState.current = {
276
+ offsetY: contentOffset.y,
277
+ viewportHeight: layoutMeasurement.height,
278
+ contentHeight: bestContentHeight,
279
+ };
280
+
281
+ // Debounce: capture after scroll stops
282
+ if (debounceTimer.current) clearTimeout(debounceTimer.current);
283
+ debounceTimer.current = setTimeout(() => doCapture(), DEBOUNCE_MS);
284
+
285
+ // Start interval for continuous scroll captures
286
+ startScrollInterval();
287
+
288
+ // Capture initial tile (Y~0) on first scroll event with real dims
289
+ if (!initialCaptured.current && contentOffset.y < 10) {
290
+ if (initialLayoutTimer.current) clearTimeout(initialLayoutTimer.current);
291
+ initialLayoutTimer.current = setTimeout(() => doCapture(), 200);
292
+ }
293
+
294
+ handlersRef.current.onScroll?.(event);
295
+ },
296
+ [measureAndRegister],
297
+ );
298
+
299
+ const onScrollEndDrag = useCallback(
300
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
301
+ // User lifted finger — capture immediately
302
+ stopScrollInterval();
303
+ doCapture(true);
304
+ handlersRef.current.onScrollEndDrag?.(event);
305
+ },
306
+ [],
307
+ );
308
+
309
+ const onMomentumScrollEnd = useCallback(
310
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
311
+ if (debounceTimer.current) {
312
+ clearTimeout(debounceTimer.current);
313
+ debounceTimer.current = null;
314
+ }
315
+ stopScrollInterval();
316
+ doCapture(true);
317
+ handlersRef.current.onMomentumScrollEnd?.(event);
318
+ },
319
+ [],
320
+ );
321
+
322
+ const onLayout = useCallback(
323
+ (event: LayoutChangeEvent) => {
324
+ const { height } = event.nativeEvent.layout;
325
+ if (!initialCaptured.current && height > 0) {
326
+ scrollState.current = {
327
+ offsetY: 0,
328
+ viewportHeight: height,
329
+ contentHeight: realContentHeight.current || height,
330
+ };
331
+ // Clear any prior pending initial-capture timer before scheduling
332
+ if (initialLayoutTimer.current) clearTimeout(initialLayoutTimer.current);
333
+ initialLayoutTimer.current = setTimeout(() => doCapture(), 1000);
334
+ }
335
+ // Register window-relative bounds immediately (no setTimeout).
336
+ // measureInWindow itself is async; if it returns 0/0 dims, the
337
+ // next onLayout / onScroll will retry — no race window.
338
+ measureAndRegister();
339
+ handlersRef.current.onLayout?.(event);
340
+ },
341
+ [measureAndRegister],
342
+ );
343
+
344
+ /**
345
+ * Track the real content height from onContentSizeChange.
346
+ * This is more reliable than the first scroll event and ensures
347
+ * the initial tile at Y=0 has correct contentHeight metadata.
348
+ */
349
+ const onContentSizeChange = useCallback((w: number, h: number) => {
350
+ realContentHeight.current = h;
351
+ // Update scroll state if it exists
352
+ if (scrollState.current) {
353
+ scrollState.current.contentHeight = h;
354
+ }
355
+ handlersRef.current.onContentSizeChange?.(w, h);
356
+ }, []);
357
+
358
+ return {
359
+ setRef,
360
+ onScroll,
361
+ onScrollEndDrag,
362
+ onMomentumScrollEnd,
363
+ onLayout,
364
+ onContentSizeChange,
365
+ };
366
+ }