@akhilpulse/samadhaan-session-replay 0.1.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.
- package/README.md +111 -0
- package/package.json +43 -0
- package/src/SessionReplayProvider.tsx +448 -0
- package/src/index.ts +6 -0
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# @akhilpulse/samadhaan-session-replay
|
|
2
|
+
|
|
3
|
+
React Native SDK for embedding **Samadhaan Guided Support** into your mobile app — session recording, live remote screen-share, take-control, and guided-tour overlays.
|
|
4
|
+
|
|
5
|
+
The SDK talks to the Samadhaan dashboard at **https://samadhaan.pulseenergy.io** by default.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @akhilpulse/samadhaan-session-replay react-native-view-shot @react-native-async-storage/async-storage
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
iOS:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
cd ios && pod install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import { SessionReplayProvider } from "@akhilpulse/samadhaan-session-replay";
|
|
23
|
+
|
|
24
|
+
export default function App() {
|
|
25
|
+
return (
|
|
26
|
+
<SessionReplayProvider
|
|
27
|
+
config={{
|
|
28
|
+
// serverUrl is optional — defaults to https://samadhaan.pulseenergy.io
|
|
29
|
+
appId: "app_xxx",
|
|
30
|
+
user: { userId: "user_123", email: "user@example.com" },
|
|
31
|
+
appVersion: "1.4.2",
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
<YourApp />
|
|
35
|
+
</SessionReplayProvider>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
To point at a staging/self-hosted dashboard, pass `serverUrl`:
|
|
41
|
+
|
|
42
|
+
```tsx
|
|
43
|
+
<SessionReplayProvider
|
|
44
|
+
config={{
|
|
45
|
+
serverUrl: "https://staging-samadhaan.pulseenergy.io",
|
|
46
|
+
appId: "app_xxx",
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Privacy masking
|
|
52
|
+
|
|
53
|
+
Wrap any subtree to redact it from screenshots:
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
import { PrivacyLayer } from "@akhilpulse/samadhaan-session-replay";
|
|
57
|
+
|
|
58
|
+
<PrivacyLayer redact>
|
|
59
|
+
<CreditCardForm />
|
|
60
|
+
</PrivacyLayer>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Pause / resume
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
import { useSessionReplay } from "@akhilpulse/samadhaan-session-replay";
|
|
67
|
+
|
|
68
|
+
const { pause, resume, sessionId, status } = useSessionReplay();
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## What it does
|
|
72
|
+
|
|
73
|
+
- Creates a session against `POST /api/guided-support/sessions` and persists `sessionId` in AsyncStorage so it can be resumed after relaunch.
|
|
74
|
+
- Captures gestures (tap, swipe) using a passive PanResponder + onTouchEnd bubble listener — host taps still work.
|
|
75
|
+
- Captures screenshots every 10 s in background mode, every 200 ms in live mode, via `react-native-view-shot`. Uploads via `POST /api/guided-support/storage/uploads/request-url` then PUTs to the returned URL.
|
|
76
|
+
- Auto-connects to `WS /ws/guided-support?sessionId=…&role=device&token=…` when the dashboard flips the session to `live`.
|
|
77
|
+
- Streams binary frames as `[ts u32 BE | width u16 BE | height u16 BE] + JPEG bytes`.
|
|
78
|
+
- Renders a remote-control overlay that visualizes incoming taps. Native touch dispatch (iOS UIEvent / Android AccessibilityService) is wired by the integrator via a small custom native module.
|
|
79
|
+
- Auto-mounts a guided-tour overlay (singleton-guarded). On `guided_tour_request`, shows an inline absolutely-positioned consent prompt (NOT `<Modal>` — RN Modals leave a stale native window that swallows touches). On accept, renders a thin glowing edge halo (4 strips with `pointerEvents: "none"`) plus a small centered "End Tour" pill. Streams the agent's pointer as a red laser-pointer dot.
|
|
80
|
+
- Pauses screenshot capture while the iOS keyboard is visible.
|
|
81
|
+
|
|
82
|
+
## Critical constraints (don't skip)
|
|
83
|
+
|
|
84
|
+
1. Never use RN `<Modal>` for transient consent UI — use absolutely-positioned `<View>` with `zIndex: 9999`.
|
|
85
|
+
2. Never animate colors with `useNativeDriver: false` — causes 60fps JS commits that break touch handling on Fabric.
|
|
86
|
+
3. The guided-tour overlay must NOT cover the screen center. Use 4 thin edge strips with `pointerEvents: "none"`.
|
|
87
|
+
4. Pause screenshot capture while the iOS keyboard is visible.
|
|
88
|
+
5. Auto-mount the guided-tour overlay inside the provider, and singleton-guard it.
|
|
89
|
+
|
|
90
|
+
## Publishing
|
|
91
|
+
|
|
92
|
+
This package is set up for the `@akhilpulse` npm scope.
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# 1. Make sure you're logged in to npm with publish rights to @akhilpulse
|
|
96
|
+
npm login
|
|
97
|
+
|
|
98
|
+
# 2. From the mobile-sdk/ directory:
|
|
99
|
+
npm install
|
|
100
|
+
npm run build # compiles src/ → dist/ via tsc
|
|
101
|
+
npm publish # uses publishConfig.access = "restricted"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
To bump the version:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npm version patch # or minor / major
|
|
108
|
+
npm publish
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
See `src/SessionReplayProvider.tsx` for the full implementation.
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@akhilpulse/samadhaan-session-replay",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React Native SDK for Samadhaan Guided Support — session recording, live remote screen-share, take-control, and guided-tour overlays.",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"module": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"react-native": "src/index.ts",
|
|
9
|
+
"source": "src/index.ts",
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "echo \"no tests\" && exit 0"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"react-native",
|
|
19
|
+
"session-replay",
|
|
20
|
+
"screen-share",
|
|
21
|
+
"remote-support",
|
|
22
|
+
"guided-tour",
|
|
23
|
+
"samadhaan",
|
|
24
|
+
"pulse-energy"
|
|
25
|
+
],
|
|
26
|
+
"author": "Akhil / Pulse Energy",
|
|
27
|
+
"license": "UNLICENSED",
|
|
28
|
+
"private": false,
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "https://samadhaan.pulseenergy.io"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://samadhaan.pulseenergy.io",
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"react": ">=17.0.0",
|
|
39
|
+
"react-native": ">=0.68.0",
|
|
40
|
+
"react-native-view-shot": ">=3.0.0",
|
|
41
|
+
"@react-native-async-storage/async-storage": ">=1.17.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @akhilpulse/samadhaan-session-replay — SessionReplayProvider
|
|
3
|
+
*
|
|
4
|
+
* Reference implementation. Drop this into a React Native app and wrap your
|
|
5
|
+
* root with <SessionReplayProvider config={...}>. Read mobile-sdk/README.md
|
|
6
|
+
* for the contract and the critical "do not skip" constraints.
|
|
7
|
+
*
|
|
8
|
+
* Peer deps:
|
|
9
|
+
* - react-native-view-shot
|
|
10
|
+
* - @react-native-async-storage/async-storage
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import React, {
|
|
14
|
+
createContext,
|
|
15
|
+
useCallback,
|
|
16
|
+
useContext,
|
|
17
|
+
useEffect,
|
|
18
|
+
useMemo,
|
|
19
|
+
useRef,
|
|
20
|
+
useState,
|
|
21
|
+
} from "react";
|
|
22
|
+
import {
|
|
23
|
+
View,
|
|
24
|
+
Text,
|
|
25
|
+
PanResponder,
|
|
26
|
+
Keyboard,
|
|
27
|
+
AppState,
|
|
28
|
+
Animated,
|
|
29
|
+
Platform,
|
|
30
|
+
UIManager,
|
|
31
|
+
findNodeHandle,
|
|
32
|
+
StyleSheet,
|
|
33
|
+
} from "react-native";
|
|
34
|
+
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
35
|
+
import { captureRef } from "react-native-view-shot";
|
|
36
|
+
|
|
37
|
+
const STORAGE_KEY = "@akhilpulse/samadhaan-session-replay/sessionId";
|
|
38
|
+
const SDK_VERSION = "0.1.0";
|
|
39
|
+
|
|
40
|
+
/** Default Samadhaan production endpoint. Override via `config.serverUrl` for staging. */
|
|
41
|
+
export const DEFAULT_SERVER_URL = "https://samadhaan.pulseenergy.io";
|
|
42
|
+
|
|
43
|
+
type SessionUser = {
|
|
44
|
+
userId?: string;
|
|
45
|
+
userName?: string;
|
|
46
|
+
email?: string;
|
|
47
|
+
phone?: string;
|
|
48
|
+
metadata?: Record<string, any>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type Config = {
|
|
52
|
+
/** Backend base URL. Defaults to https://samadhaan.pulseenergy.io. */
|
|
53
|
+
serverUrl?: string;
|
|
54
|
+
appId: string;
|
|
55
|
+
user?: SessionUser;
|
|
56
|
+
appVersion?: string;
|
|
57
|
+
/** Disable screenshot capture entirely */
|
|
58
|
+
disableScreenshots?: boolean;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type Ctx = {
|
|
62
|
+
sessionId: string | null;
|
|
63
|
+
status: "idle" | "active" | "live" | "ended";
|
|
64
|
+
pause: () => void;
|
|
65
|
+
resume: () => void;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const SessionReplayContext = createContext<Ctx>({
|
|
69
|
+
sessionId: null,
|
|
70
|
+
status: "idle",
|
|
71
|
+
pause: () => {},
|
|
72
|
+
resume: () => {},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export function useSessionReplay() {
|
|
76
|
+
return useContext(SessionReplayContext);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- Singleton guard for GuidedTourOverlay -------------------------------
|
|
80
|
+
let _tourSingletonClaimed = false;
|
|
81
|
+
|
|
82
|
+
// ---- Privacy layer -------------------------------------------------------
|
|
83
|
+
const PrivacyContext = createContext<boolean>(false);
|
|
84
|
+
export function PrivacyLayer({ redact = true, children }: { redact?: boolean; children: React.ReactNode }) {
|
|
85
|
+
return <PrivacyContext.Provider value={redact}>{children}</PrivacyContext.Provider>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---- Provider ------------------------------------------------------------
|
|
89
|
+
export function SessionReplayProvider({ config, children }: { config: Config; children: React.ReactNode }) {
|
|
90
|
+
const serverUrl = (config.serverUrl || DEFAULT_SERVER_URL).replace(/\/$/, "");
|
|
91
|
+
const [sessionId, setSessionId] = useState<string | null>(null);
|
|
92
|
+
const [ingestToken, setIngestToken] = useState<string | null>(null);
|
|
93
|
+
const [status, setStatus] = useState<Ctx["status"]>("idle");
|
|
94
|
+
const [paused, setPaused] = useState(false);
|
|
95
|
+
const [keyboardVisible, setKeyboardVisible] = useState(false);
|
|
96
|
+
const rootRef = useRef<View>(null);
|
|
97
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
98
|
+
const eventBufRef = useRef<any[]>([]);
|
|
99
|
+
const lastTapRef = useRef<{ x: number; y: number; t: number } | null>(null);
|
|
100
|
+
|
|
101
|
+
const flushEvents = useCallback(async () => {
|
|
102
|
+
if (!sessionId || !ingestToken || eventBufRef.current.length === 0) return;
|
|
103
|
+
const events = eventBufRef.current.splice(0, eventBufRef.current.length);
|
|
104
|
+
try {
|
|
105
|
+
await fetch(`${serverUrl}/api/guided-support/sessions/${sessionId}/events`, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: { "Content-Type": "application/json", "x-ingest-token": ingestToken },
|
|
108
|
+
body: JSON.stringify({ events }),
|
|
109
|
+
});
|
|
110
|
+
} catch {
|
|
111
|
+
// requeue on failure
|
|
112
|
+
eventBufRef.current.unshift(...events);
|
|
113
|
+
}
|
|
114
|
+
}, [sessionId, ingestToken, serverUrl]);
|
|
115
|
+
|
|
116
|
+
const queueEvent = useCallback((type: string, data: any = {}, screenshotPath?: string) => {
|
|
117
|
+
eventBufRef.current.push({ type, ts: Date.now(), data, screenshotPath: screenshotPath || null });
|
|
118
|
+
}, []);
|
|
119
|
+
|
|
120
|
+
// Periodic flush
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const id = setInterval(() => { flushEvents(); }, 2000);
|
|
123
|
+
return () => clearInterval(id);
|
|
124
|
+
}, [flushEvents]);
|
|
125
|
+
|
|
126
|
+
// ---- Session init / resume ----
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
let cancelled = false;
|
|
129
|
+
(async () => {
|
|
130
|
+
try {
|
|
131
|
+
const stored = await AsyncStorage.getItem(STORAGE_KEY);
|
|
132
|
+
if (stored && !cancelled) {
|
|
133
|
+
// resume via PUBLIC status endpoint (no auth needed) — also returns the ingest token
|
|
134
|
+
const r = await fetch(`${serverUrl}/api/guided-support/sessions/${stored}/status`);
|
|
135
|
+
if (r.ok) {
|
|
136
|
+
const j = await r.json();
|
|
137
|
+
if (!cancelled) { setSessionId(stored); setStatus(j.status || "active"); setIngestToken(j.ingestToken); }
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const res = await fetch(`${serverUrl}/api/guided-support/sessions`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: { "Content-Type": "application/json" },
|
|
144
|
+
body: JSON.stringify({
|
|
145
|
+
appId: config.appId,
|
|
146
|
+
user: config.user,
|
|
147
|
+
appVersion: config.appVersion,
|
|
148
|
+
sdkVersion: SDK_VERSION,
|
|
149
|
+
deviceInfo: {
|
|
150
|
+
os: Platform.OS,
|
|
151
|
+
osVersion: String(Platform.Version),
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
const j = await res.json();
|
|
156
|
+
if (!cancelled && j.sessionId) {
|
|
157
|
+
setSessionId(j.sessionId);
|
|
158
|
+
setIngestToken(j.ingestToken);
|
|
159
|
+
setStatus("active");
|
|
160
|
+
await AsyncStorage.setItem(STORAGE_KEY, j.sessionId);
|
|
161
|
+
}
|
|
162
|
+
} catch (e) {
|
|
163
|
+
// best-effort; SDK is non-blocking
|
|
164
|
+
}
|
|
165
|
+
})();
|
|
166
|
+
return () => { cancelled = true; };
|
|
167
|
+
}, [config.appId, serverUrl, config.appVersion, config.user]);
|
|
168
|
+
|
|
169
|
+
// ---- Keyboard tracking (iOS auto-pause) ----
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
const showSub = Keyboard.addListener(Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow", () => setKeyboardVisible(true));
|
|
172
|
+
const hideSub = Keyboard.addListener(Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide", () => setKeyboardVisible(false));
|
|
173
|
+
return () => { showSub.remove(); hideSub.remove(); };
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
176
|
+
// ---- Status polling via PUBLIC status endpoint (so we know when dashboard flips to live) ----
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (!sessionId) return;
|
|
179
|
+
const id = setInterval(async () => {
|
|
180
|
+
try {
|
|
181
|
+
const r = await fetch(`${serverUrl}/api/guided-support/sessions/${sessionId}/status`);
|
|
182
|
+
if (r.ok) {
|
|
183
|
+
const j = await r.json();
|
|
184
|
+
setStatus(j.status);
|
|
185
|
+
}
|
|
186
|
+
} catch {}
|
|
187
|
+
}, 5000);
|
|
188
|
+
return () => clearInterval(id);
|
|
189
|
+
}, [sessionId, serverUrl]);
|
|
190
|
+
|
|
191
|
+
// ---- Screenshot capture ----
|
|
192
|
+
const liveMode = status === "live";
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (config.disableScreenshots || !sessionId) return;
|
|
195
|
+
let stopped = false;
|
|
196
|
+
const interval = liveMode ? 200 : 10000;
|
|
197
|
+
const tick = async () => {
|
|
198
|
+
if (stopped) return;
|
|
199
|
+
if (paused || keyboardVisible) { setTimeout(tick, interval); return; }
|
|
200
|
+
try {
|
|
201
|
+
const ref = rootRef.current;
|
|
202
|
+
if (ref) {
|
|
203
|
+
const uri = await captureRef(ref as any, { format: "jpg", quality: liveMode ? 0.4 : 0.7, result: "base64" });
|
|
204
|
+
// upload via request-url then PUT
|
|
205
|
+
const blob = base64ToUint8Array(uri);
|
|
206
|
+
let path: string | null = null;
|
|
207
|
+
try {
|
|
208
|
+
const r = await fetch(`${serverUrl}/api/guided-support/storage/uploads/request-url`, {
|
|
209
|
+
method: "POST",
|
|
210
|
+
headers: { "Content-Type": "application/json", "x-ingest-token": ingestToken || "" },
|
|
211
|
+
body: JSON.stringify({ sessionId, name: `frame-${Date.now()}.jpg`, size: blob.length, contentType: "image/jpeg" }),
|
|
212
|
+
});
|
|
213
|
+
const meta = await r.json();
|
|
214
|
+
const fd = new FormData();
|
|
215
|
+
// @ts-ignore
|
|
216
|
+
fd.append("file", { uri: `data:image/jpeg;base64,${uri}`, name: "frame.jpg", type: "image/jpeg" });
|
|
217
|
+
const up = await fetch(`${serverUrl}${meta.uploadUrl}`, { method: "POST", body: fd as any });
|
|
218
|
+
if (up.ok) path = meta.path;
|
|
219
|
+
} catch {}
|
|
220
|
+
queueEvent("screenshot", { w: 0, h: 0 }, path || undefined);
|
|
221
|
+
if (liveMode && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
222
|
+
// also stream binary frame
|
|
223
|
+
sendBinaryFrame(wsRef.current, blob, 0, 0);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} catch {}
|
|
227
|
+
setTimeout(tick, interval);
|
|
228
|
+
};
|
|
229
|
+
setTimeout(tick, interval);
|
|
230
|
+
return () => { stopped = true; };
|
|
231
|
+
}, [sessionId, ingestToken, liveMode, paused, keyboardVisible, config.disableScreenshots, serverUrl, queueEvent]);
|
|
232
|
+
|
|
233
|
+
// ---- WebSocket (live mode) ----
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
if (!sessionId || !ingestToken || !liveMode) {
|
|
236
|
+
if (wsRef.current) { try { wsRef.current.close(); } catch {} wsRef.current = null; }
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const proto = serverUrl.startsWith("https") ? "wss" : "ws";
|
|
240
|
+
const host = serverUrl.replace(/^https?:\/\//, "");
|
|
241
|
+
const ws = new WebSocket(`${proto}://${host}/ws/guided-support?sessionId=${sessionId}&role=device&token=${encodeURIComponent(ingestToken)}`);
|
|
242
|
+
ws.binaryType = "arraybuffer" as any;
|
|
243
|
+
wsRef.current = ws;
|
|
244
|
+
ws.onmessage = (ev) => {
|
|
245
|
+
try {
|
|
246
|
+
const msg = typeof ev.data === "string" ? JSON.parse(ev.data) : null;
|
|
247
|
+
if (!msg) return;
|
|
248
|
+
handleAgentCommand(msg);
|
|
249
|
+
} catch {}
|
|
250
|
+
};
|
|
251
|
+
ws.onclose = () => { if (wsRef.current === ws) wsRef.current = null; };
|
|
252
|
+
return () => { try { ws.close(); } catch {} };
|
|
253
|
+
}, [sessionId, ingestToken, liveMode, serverUrl]);
|
|
254
|
+
|
|
255
|
+
// ---- Gesture capture (passive PanResponder) ----
|
|
256
|
+
const panResponder = useMemo(() => PanResponder.create({
|
|
257
|
+
onStartShouldSetPanResponder: () => false,
|
|
258
|
+
onMoveShouldSetPanResponder: () => false,
|
|
259
|
+
onStartShouldSetPanResponderCapture: () => false,
|
|
260
|
+
onMoveShouldSetPanResponderCapture: () => false,
|
|
261
|
+
}), []);
|
|
262
|
+
|
|
263
|
+
function onTouchEndCapture(e: any) {
|
|
264
|
+
const { pageX, pageY } = e.nativeEvent;
|
|
265
|
+
const now = Date.now();
|
|
266
|
+
const last = lastTapRef.current;
|
|
267
|
+
if (last && now - last.t < 1000 && Math.hypot(pageX - last.x, pageY - last.y) > 20) {
|
|
268
|
+
queueEvent("swipe", { fromX: last.x, fromY: last.y, toX: pageX, toY: pageY });
|
|
269
|
+
} else {
|
|
270
|
+
queueEvent("tap", { x: pageX, y: pageY });
|
|
271
|
+
}
|
|
272
|
+
lastTapRef.current = { x: pageX, y: pageY, t: now };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---- Agent command handling ----
|
|
276
|
+
function handleAgentCommand(msg: any) {
|
|
277
|
+
switch (msg.type) {
|
|
278
|
+
case "tap":
|
|
279
|
+
case "swipe":
|
|
280
|
+
case "scroll":
|
|
281
|
+
case "text_input":
|
|
282
|
+
case "back":
|
|
283
|
+
case "home":
|
|
284
|
+
queueEvent("agent_command", msg);
|
|
285
|
+
// Synthetic dispatch — implementations differ by platform:
|
|
286
|
+
// - iOS: use UIManager.dispatchViewManagerCommand on a custom native module that calls UIEvent injection.
|
|
287
|
+
// - Android: dispatch via accessibility service or a custom native module.
|
|
288
|
+
// This SDK includes the visual ripple; integrators wire native dispatch via a custom module.
|
|
289
|
+
showRipple(msg.x ?? msg.toX ?? 0, msg.y ?? msg.toY ?? 0);
|
|
290
|
+
break;
|
|
291
|
+
case "guided_tour_request":
|
|
292
|
+
setTourState({ phase: "consent", agentName: msg.agentName });
|
|
293
|
+
break;
|
|
294
|
+
case "guided_tour_pointer":
|
|
295
|
+
setPointer({ x: msg.x, y: msg.y, t: Date.now() });
|
|
296
|
+
break;
|
|
297
|
+
case "guided_tour_end":
|
|
298
|
+
setTourState({ phase: "idle" });
|
|
299
|
+
setPointer(null);
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---- Ripple overlay ----
|
|
305
|
+
const [ripples, setRipples] = useState<{ id: string; x: number; y: number }[]>([]);
|
|
306
|
+
function showRipple(x: number, y: number) {
|
|
307
|
+
const id = String(Date.now() + Math.random());
|
|
308
|
+
setRipples((r) => [...r, { id, x, y }]);
|
|
309
|
+
setTimeout(() => setRipples((r) => r.filter((p) => p.id !== id)), 600);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---- Guided tour state ----
|
|
313
|
+
const [tourState, setTourState] = useState<{ phase: "idle" | "consent" | "active"; agentName?: string }>({ phase: "idle" });
|
|
314
|
+
const [pointer, setPointer] = useState<{ x: number; y: number; t: number } | null>(null);
|
|
315
|
+
|
|
316
|
+
function consentTour(accept: boolean) {
|
|
317
|
+
if (accept) {
|
|
318
|
+
setTourState((s) => ({ phase: "active", agentName: s.agentName }));
|
|
319
|
+
} else {
|
|
320
|
+
setTourState({ phase: "idle" });
|
|
321
|
+
try { wsRef.current?.send(JSON.stringify({ type: "guided_tour_declined" })); } catch {}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function endTourLocal() {
|
|
325
|
+
setTourState({ phase: "idle" });
|
|
326
|
+
setPointer(null);
|
|
327
|
+
try { wsRef.current?.send(JSON.stringify({ type: "guided_tour_end" })); } catch {}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Singleton guard
|
|
331
|
+
const tourActiveHere = useRef(false);
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
if (!_tourSingletonClaimed) {
|
|
334
|
+
_tourSingletonClaimed = true;
|
|
335
|
+
tourActiveHere.current = true;
|
|
336
|
+
} else if (__DEV__) {
|
|
337
|
+
console.warn("[session-replay] Multiple GuidedTourOverlay instances mounted — only the first is active.");
|
|
338
|
+
}
|
|
339
|
+
return () => { if (tourActiveHere.current) _tourSingletonClaimed = false; };
|
|
340
|
+
}, []);
|
|
341
|
+
|
|
342
|
+
const ctx: Ctx = {
|
|
343
|
+
sessionId,
|
|
344
|
+
status,
|
|
345
|
+
pause: () => setPaused(true),
|
|
346
|
+
resume: () => setPaused(false),
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
return (
|
|
350
|
+
<SessionReplayContext.Provider value={ctx}>
|
|
351
|
+
<View
|
|
352
|
+
ref={rootRef}
|
|
353
|
+
collapsable={false}
|
|
354
|
+
style={{ flex: 1 }}
|
|
355
|
+
{...panResponder.panHandlers}
|
|
356
|
+
onTouchEndCapture={onTouchEndCapture}
|
|
357
|
+
>
|
|
358
|
+
{children}
|
|
359
|
+
|
|
360
|
+
{/* Ripple overlays for incoming agent taps */}
|
|
361
|
+
{ripples.map((r) => (
|
|
362
|
+
<View
|
|
363
|
+
key={r.id}
|
|
364
|
+
pointerEvents="none"
|
|
365
|
+
style={[styles.ripple, { left: r.x - 20, top: r.y - 20 }]}
|
|
366
|
+
/>
|
|
367
|
+
))}
|
|
368
|
+
|
|
369
|
+
{/* Guided tour consent — absolutely-positioned View, NOT <Modal> */}
|
|
370
|
+
{tourActiveHere.current && tourState.phase === "consent" && (
|
|
371
|
+
<View style={styles.consent} pointerEvents="auto">
|
|
372
|
+
<Text style={styles.consentTitle}>{tourState.agentName || "Support"} would like to guide you</Text>
|
|
373
|
+
<Text style={styles.consentBody}>They will see a pointer overlaid on your screen. They cannot tap for you.</Text>
|
|
374
|
+
<View style={styles.consentRow}>
|
|
375
|
+
<Text onPress={() => consentTour(false)} style={[styles.btn, styles.btnGhost]}>Decline</Text>
|
|
376
|
+
<Text onPress={() => consentTour(true)} style={[styles.btn, styles.btnPrimary]}>Accept</Text>
|
|
377
|
+
</View>
|
|
378
|
+
</View>
|
|
379
|
+
)}
|
|
380
|
+
|
|
381
|
+
{/* Guided tour active overlay — 4 thin edge strips, never centered */}
|
|
382
|
+
{tourActiveHere.current && tourState.phase === "active" && (
|
|
383
|
+
<>
|
|
384
|
+
<View pointerEvents="none" style={[styles.edge, { top: 0, left: 0, right: 0, height: 4 }]} />
|
|
385
|
+
<View pointerEvents="none" style={[styles.edge, { bottom: 0, left: 0, right: 0, height: 4 }]} />
|
|
386
|
+
<View pointerEvents="none" style={[styles.edge, { top: 0, bottom: 0, left: 0, width: 4 }]} />
|
|
387
|
+
<View pointerEvents="none" style={[styles.edge, { top: 0, bottom: 0, right: 0, width: 4 }]} />
|
|
388
|
+
<View style={styles.endTourPill}>
|
|
389
|
+
<Text onPress={endTourLocal} style={styles.endTourText}>End Tour</Text>
|
|
390
|
+
</View>
|
|
391
|
+
{pointer && (
|
|
392
|
+
<View pointerEvents="none" style={[styles.pointer, { left: pointer.x - 8, top: pointer.y - 8 }]} />
|
|
393
|
+
)}
|
|
394
|
+
</>
|
|
395
|
+
)}
|
|
396
|
+
</View>
|
|
397
|
+
</SessionReplayContext.Provider>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ---- helpers ----
|
|
402
|
+
function base64ToUint8Array(b64: string): Uint8Array {
|
|
403
|
+
const bin = globalThis.atob ? globalThis.atob(b64) : Buffer.from(b64, "base64").toString("binary");
|
|
404
|
+
const out = new Uint8Array(bin.length);
|
|
405
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function sendBinaryFrame(ws: WebSocket, jpeg: Uint8Array, w: number, h: number) {
|
|
410
|
+
const buf = new ArrayBuffer(8 + jpeg.length);
|
|
411
|
+
const view = new DataView(buf);
|
|
412
|
+
view.setUint32(0, Math.floor(Date.now() / 1000), false);
|
|
413
|
+
view.setUint16(4, w, false);
|
|
414
|
+
view.setUint16(6, h, false);
|
|
415
|
+
new Uint8Array(buf, 8).set(jpeg);
|
|
416
|
+
try { ws.send(buf); } catch {}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const styles = StyleSheet.create({
|
|
420
|
+
ripple: {
|
|
421
|
+
position: "absolute", width: 40, height: 40, borderRadius: 20,
|
|
422
|
+
backgroundColor: "rgba(34,197,94,0.45)", borderWidth: 2, borderColor: "#22c55e",
|
|
423
|
+
},
|
|
424
|
+
consent: {
|
|
425
|
+
position: "absolute", left: 16, right: 16, bottom: 32, padding: 16,
|
|
426
|
+
backgroundColor: "rgba(17,24,39,0.96)", borderRadius: 12, zIndex: 9999,
|
|
427
|
+
},
|
|
428
|
+
consentTitle: { color: "#fff", fontSize: 16, fontWeight: "600", marginBottom: 6 },
|
|
429
|
+
consentBody: { color: "#d1d5db", fontSize: 13, marginBottom: 12 },
|
|
430
|
+
consentRow: { flexDirection: "row", justifyContent: "flex-end", gap: 8 },
|
|
431
|
+
btn: { paddingVertical: 8, paddingHorizontal: 14, borderRadius: 6, fontSize: 14, overflow: "hidden" },
|
|
432
|
+
btnGhost: { color: "#9ca3af" },
|
|
433
|
+
btnPrimary: { color: "#fff", backgroundColor: "#22c55e" },
|
|
434
|
+
edge: { position: "absolute", backgroundColor: "rgba(34,197,94,0.7)", zIndex: 9998 },
|
|
435
|
+
endTourPill: {
|
|
436
|
+
position: "absolute", top: 12, alignSelf: "center", left: 0, right: 0,
|
|
437
|
+
flexDirection: "row", justifyContent: "center", zIndex: 9999,
|
|
438
|
+
},
|
|
439
|
+
endTourText: {
|
|
440
|
+
color: "#fff", backgroundColor: "rgba(17,24,39,0.92)", paddingVertical: 6, paddingHorizontal: 14,
|
|
441
|
+
borderRadius: 999, fontSize: 13, fontWeight: "600", overflow: "hidden",
|
|
442
|
+
},
|
|
443
|
+
pointer: {
|
|
444
|
+
position: "absolute", width: 16, height: 16, borderRadius: 8,
|
|
445
|
+
backgroundColor: "rgba(239,68,68,0.95)", borderWidth: 2, borderColor: "#fff",
|
|
446
|
+
shadowColor: "#ef4444", shadowOpacity: 0.7, shadowRadius: 6, zIndex: 9999,
|
|
447
|
+
},
|
|
448
|
+
});
|