@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.
@@ -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 automatically
5
- * reports the scroll offset to Repliqo for accurate heatmaps.
4
+ * Drop-in replacement for React Native's ScrollView that:
6
5
  *
7
- * Without this, touch coordinates on scrollable screens are relative to
8
- * the visible viewport. With this, they're relative to the full content,
9
- * so heatmaps accurately show which content elements users touch.
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 automatically
42
- * reports the scroll offset to Repliqo for accurate heatmaps.
47
+ * Drop-in replacement for React Native's ScrollView that:
43
48
  *
44
- * Without this, touch coordinates on scrollable screens are relative to
45
- * the visible viewport. With this, they're relative to the full content,
46
- * so heatmaps accurately show which content elements users touch.
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
- // Report scroll offset to the SDK
90
+ const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent;
91
+ // Report scroll offset for touch coords
66
92
  try {
67
- const offsetY = event.nativeEvent.contentOffset.y;
68
- client_1.AppAnalytics.getInstance().setScrollOffset(offsetY);
93
+ client_1.AppAnalytics.getInstance().setScrollOffset(contentOffset.y);
69
94
  }
70
- catch {
71
- // SDK not initialized, ignore
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
- return (<react_native_1.ScrollView ref={ref} {...props} onScroll={handleScroll} scrollEventThrottle={props.scrollEventThrottle ?? 16}/>);
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';
@@ -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>;
@@ -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
- // Native multi-window capture is used automatically via captureScreenshot()
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
- // Trigger full-content capture for heatmap backgrounds (async, non-blocking)
244
- this.screenCaptureManager?.onScreenEnter(screenName);
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.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 automatically
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
- * // Before:
24
- * <ScrollView>...</ScrollView>
25
- *
26
- * // After:
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
- // Report scroll offset to the SDK
73
+ const { contentOffset, layoutMeasurement, contentSize } =
74
+ event.nativeEvent;
75
+
76
+ // Report scroll offset for touch coords
38
77
  try {
39
- const offsetY = event.nativeEvent.contentOffset.y;
40
- AppAnalytics.getInstance().setScrollOffset(offsetY);
41
- } catch {
42
- // SDK not initialized, ignore
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
  );
@@ -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
- // Native multi-window capture is used automatically via captureScreenshot()
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
- // Trigger full-content capture for heatmap backgrounds (async, non-blocking)
349
- this.screenCaptureManager?.onScreenEnter(screenName);
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,