@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 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
+ });
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export {
2
+ SessionReplayProvider,
3
+ PrivacyLayer,
4
+ useSessionReplay,
5
+ DEFAULT_SERVER_URL,
6
+ } from "./SessionReplayProvider";