@akhilpulse/samadhaan-session-replay 0.2.7 → 0.2.9
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 +2 -1
- package/package.json +1 -1
- package/src/SessionReplayProvider.tsx +80 -18
package/README.md
CHANGED
|
@@ -81,11 +81,12 @@ const { pause, resume, sessionId, status } = useSessionReplay();
|
|
|
81
81
|
|
|
82
82
|
## Critical constraints (don't skip)
|
|
83
83
|
|
|
84
|
-
1. Never use RN `<Modal>` for transient consent UI — use absolutely-positioned `<View>`
|
|
84
|
+
1. Never use RN `<Modal>` for transient consent UI — use absolutely-positioned `<View>` (no `zIndex` — see #6).
|
|
85
85
|
2. Never animate colors with `useNativeDriver: false` — causes 60fps JS commits that break touch handling on Fabric.
|
|
86
86
|
3. The guided-tour overlay must NOT cover the screen center. Use 4 thin edge strips with `pointerEvents: "none"`.
|
|
87
87
|
4. Pause screenshot capture while the iOS keyboard is visible.
|
|
88
88
|
5. Auto-mount the guided-tour overlay inside the provider, and singleton-guard it.
|
|
89
|
+
6. **Android elevation/zIndex break screenshot capture.** `react-native-view-shot` (both `captureRef` and `captureScreen`) uses a software canvas draw on Android (`view.draw(canvas)`) that **silently skips any view promoted to a hardware compositor layer** — i.e. anything with `elevation: > 0` or `zIndex: > 0` (RN implements `zIndex` via `setElevation` on Android). The base view tree (including native `SurfaceView`s like `react-native-maps`) is captured fine, but FABs, modals, cards-with-shadow, and any RN overlay with elevation/zIndex will be invisible in screenshots and the live-streamed frames. **Fix in your app:** rely on tree order to control layering on Android (later siblings paint on top), and remove `elevation`/`zIndex` from anything that must appear in captures. Keep `shadowColor`/`shadowOpacity`/`shadowRadius` — those are iOS-only and don't trigger Android hardware layers. The SDK's own overlays (red pointer dot, end-tour pill, edge halos, consent card) follow this rule.
|
|
89
90
|
|
|
90
91
|
## Publishing
|
|
91
92
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akhilpulse/samadhaan-session-replay",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"description": "React Native SDK for Samadhaan Guided Support — session recording, live remote screen-share, take-control, and guided-tour overlays.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"module": "src/index.ts",
|
|
@@ -33,10 +33,10 @@ import {
|
|
|
33
33
|
Pressable,
|
|
34
34
|
} from "react-native";
|
|
35
35
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
36
|
-
import { captureRef } from "react-native-view-shot";
|
|
36
|
+
import { captureRef, captureScreen } from "react-native-view-shot";
|
|
37
37
|
|
|
38
38
|
const STORAGE_KEY = "@akhilpulse/samadhaan-session-replay/sessionId";
|
|
39
|
-
const SDK_VERSION = "0.2.
|
|
39
|
+
const SDK_VERSION = "0.2.9";
|
|
40
40
|
|
|
41
41
|
/** Default Samadhaan production endpoint. Override via `config.serverUrl` for staging. */
|
|
42
42
|
export const DEFAULT_SERVER_URL = "https://samadhaan.pulseenergy.io";
|
|
@@ -232,12 +232,19 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
232
232
|
const ref = rootRef.current;
|
|
233
233
|
if (ref) {
|
|
234
234
|
// Aggressively downscaled + low quality so capture+encode stays under ~50ms on mid devices.
|
|
235
|
-
|
|
236
|
-
|
|
235
|
+
// Android: captureRef misses views with `elevation` (they render in a separate
|
|
236
|
+
// hardware layer). Use captureScreen() which does an OS-level screenshot and
|
|
237
|
+
// includes elevated views (modals, FABs, cards with shadow, etc.).
|
|
238
|
+
// iOS: captureRef on the app root works correctly and stays scoped to the app.
|
|
239
|
+
const captureOpts = {
|
|
240
|
+
format: "jpg" as const,
|
|
237
241
|
quality: 0.25,
|
|
238
|
-
result: "base64",
|
|
242
|
+
result: "base64" as const,
|
|
239
243
|
width: 360,
|
|
240
|
-
}
|
|
244
|
+
};
|
|
245
|
+
const uri = Platform.OS === "android"
|
|
246
|
+
? await captureScreen(captureOpts)
|
|
247
|
+
: await captureRef(ref as any, captureOpts);
|
|
241
248
|
const blob = base64ToUint8Array(uri);
|
|
242
249
|
// Stream binary over WS — no REST upload in the hot path.
|
|
243
250
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
@@ -356,6 +363,47 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
356
363
|
// Captured root-View size (in points) so we can place the agent pointer correctly.
|
|
357
364
|
const viewSizeRef = useRef<{ w: number; h: number }>({ w: 0, h: 0 });
|
|
358
365
|
|
|
366
|
+
// ---- Draggable End-tour pill ----
|
|
367
|
+
// Animated translate offset relative to the pill's anchored position (top-right).
|
|
368
|
+
const endTourPan = useRef(new Animated.ValueXY({ x: 0, y: 0 })).current;
|
|
369
|
+
const endTourPanResponder = useMemo(
|
|
370
|
+
() =>
|
|
371
|
+
PanResponder.create({
|
|
372
|
+
// Don't claim taps — let the inner Pressable receive onPress.
|
|
373
|
+
onStartShouldSetPanResponder: () => false,
|
|
374
|
+
// Only claim once the user clearly starts dragging (>4px).
|
|
375
|
+
onMoveShouldSetPanResponder: (_e, g) => Math.abs(g.dx) > 4 || Math.abs(g.dy) > 4,
|
|
376
|
+
onPanResponderGrant: () => {
|
|
377
|
+
endTourPan.extractOffset();
|
|
378
|
+
},
|
|
379
|
+
onPanResponderMove: Animated.event(
|
|
380
|
+
[null, { dx: endTourPan.x, dy: endTourPan.y }],
|
|
381
|
+
{ useNativeDriver: false }
|
|
382
|
+
),
|
|
383
|
+
onPanResponderRelease: () => {
|
|
384
|
+
endTourPan.flattenOffset();
|
|
385
|
+
// Clamp inside the visible root-view bounds so the pill never escapes off-screen.
|
|
386
|
+
const { w, h } = viewSizeRef.current;
|
|
387
|
+
const PILL_W = 110, PILL_H = 36, ANCHOR_TOP = 64, ANCHOR_RIGHT = 12, MARGIN = 8;
|
|
388
|
+
// Pill sits at top:64, right:12 by default. Translate ranges:
|
|
389
|
+
// x: from -(w - ANCHOR_RIGHT - PILL_W - MARGIN) to 0 (can move left from right edge)
|
|
390
|
+
// y: from -(ANCHOR_TOP - MARGIN) to (h - ANCHOR_TOP - PILL_H - MARGIN)
|
|
391
|
+
const minX = -(Math.max(0, w - ANCHOR_RIGHT - PILL_W - MARGIN));
|
|
392
|
+
const maxX = 0;
|
|
393
|
+
const minY = -(ANCHOR_TOP - MARGIN);
|
|
394
|
+
const maxY = Math.max(0, h - ANCHOR_TOP - PILL_H - MARGIN);
|
|
395
|
+
const cx = (endTourPan.x as any)._value;
|
|
396
|
+
const cy = (endTourPan.y as any)._value;
|
|
397
|
+
const nx = Math.min(maxX, Math.max(minX, cx));
|
|
398
|
+
const ny = Math.min(maxY, Math.max(minY, cy));
|
|
399
|
+
if (nx !== cx || ny !== cy) {
|
|
400
|
+
Animated.spring(endTourPan, { toValue: { x: nx, y: ny }, useNativeDriver: false, friction: 7 }).start();
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
}),
|
|
404
|
+
[endTourPan]
|
|
405
|
+
);
|
|
406
|
+
|
|
359
407
|
function consentTour(accept: boolean) {
|
|
360
408
|
if (accept) {
|
|
361
409
|
setTourState((s) => ({ phase: "active", agentName: s.agentName }));
|
|
@@ -447,9 +495,14 @@ export function SessionReplayProvider({ config, children }: { config: Config; ch
|
|
|
447
495
|
<View pointerEvents="none" style={[styles.edge, { bottom: 0, left: 0, right: 0, height: 4 }]} />
|
|
448
496
|
<View pointerEvents="none" style={[styles.edge, { top: 0, bottom: 0, left: 0, width: 4 }]} />
|
|
449
497
|
<View pointerEvents="none" style={[styles.edge, { top: 0, bottom: 0, right: 0, width: 4 }]} />
|
|
450
|
-
<
|
|
451
|
-
|
|
452
|
-
|
|
498
|
+
<Animated.View
|
|
499
|
+
style={[styles.endTourPill, { transform: endTourPan.getTranslateTransform() }]}
|
|
500
|
+
{...endTourPanResponder.panHandlers}
|
|
501
|
+
>
|
|
502
|
+
<Pressable onPress={endTourLocal} style={({ pressed }) => [styles.endTourPressable, pressed && styles.btnPressed]} hitSlop={8}>
|
|
503
|
+
<Text style={styles.endTourText}>End tour</Text>
|
|
504
|
+
</Pressable>
|
|
505
|
+
</Animated.View>
|
|
453
506
|
{pointer && (
|
|
454
507
|
<>
|
|
455
508
|
<View pointerEvents="none" style={[styles.pointerHalo, { left: pointer.x - 24, top: pointer.y - 24 }]} />
|
|
@@ -511,15 +564,20 @@ const styles = StyleSheet.create({
|
|
|
511
564
|
position: "absolute", width: 40, height: 40, borderRadius: 20,
|
|
512
565
|
backgroundColor: "rgba(34,197,94,0.45)", borderWidth: 2, borderColor: "#22c55e",
|
|
513
566
|
},
|
|
567
|
+
// NOTE: We intentionally avoid `elevation` and `zIndex` on SDK overlays.
|
|
568
|
+
// On Android, both push the view into a hardware compositor layer that
|
|
569
|
+
// `react-native-view-shot`'s software canvas draw cannot capture, so the
|
|
570
|
+
// overlay would be invisible in screenshots/streamed frames. Tree order
|
|
571
|
+
// (overlays rendered AFTER children) already keeps them visually on top.
|
|
514
572
|
consentBackdrop: {
|
|
515
573
|
position: "absolute", top: 0, left: 0, right: 0, bottom: 0,
|
|
516
574
|
backgroundColor: "rgba(0,0,0,0.55)", justifyContent: "center", alignItems: "center",
|
|
517
|
-
paddingHorizontal: 24,
|
|
575
|
+
paddingHorizontal: 24,
|
|
518
576
|
},
|
|
519
577
|
consentCard: {
|
|
520
578
|
width: "100%", maxWidth: 360, backgroundColor: "#ffffff", borderRadius: 20,
|
|
521
579
|
paddingVertical: 24, paddingHorizontal: 22, alignItems: "center",
|
|
522
|
-
shadowColor: "#000", shadowOpacity: 0.25, shadowRadius: 24, shadowOffset: { width: 0, height: 12 },
|
|
580
|
+
shadowColor: "#000", shadowOpacity: 0.25, shadowRadius: 24, shadowOffset: { width: 0, height: 12 },
|
|
523
581
|
},
|
|
524
582
|
consentIconWrap: {
|
|
525
583
|
width: 56, height: 56, borderRadius: 28, alignItems: "center", justifyContent: "center",
|
|
@@ -545,23 +603,27 @@ const styles = StyleSheet.create({
|
|
|
545
603
|
btnGhostText: { color: "#475569", fontSize: 15, fontWeight: "600" },
|
|
546
604
|
btnPrimary: { backgroundColor: "#22c55e" },
|
|
547
605
|
btnPrimaryText: { color: "#fff", fontSize: 15, fontWeight: "600" },
|
|
548
|
-
edge: { position: "absolute", backgroundColor: "rgba(34,197,94,0.7)"
|
|
606
|
+
edge: { position: "absolute", backgroundColor: "rgba(34,197,94,0.7)" },
|
|
549
607
|
// End-tour pill: anchored to TOP-RIGHT with safe top inset (~64px) to clear iOS Dynamic Island & status bar.
|
|
608
|
+
// No `elevation`/`zIndex` — see consentBackdrop note above. Shadow is iOS-only and harmless on Android.
|
|
550
609
|
endTourPill: {
|
|
551
|
-
position: "absolute", top: 64, right: 12,
|
|
552
|
-
backgroundColor: "rgba(17,24,39,0.92)",
|
|
553
|
-
borderRadius: 999,
|
|
554
|
-
shadowColor: "#000", shadowOpacity: 0.3, shadowRadius: 6, shadowOffset: { width: 0, height: 2 },
|
|
610
|
+
position: "absolute", top: 64, right: 12,
|
|
611
|
+
backgroundColor: "rgba(17,24,39,0.92)",
|
|
612
|
+
borderRadius: 999,
|
|
613
|
+
shadowColor: "#000", shadowOpacity: 0.3, shadowRadius: 6, shadowOffset: { width: 0, height: 2 },
|
|
614
|
+
},
|
|
615
|
+
endTourPressable: {
|
|
616
|
+
paddingVertical: 8, paddingHorizontal: 14,
|
|
617
|
+
flexDirection: "row", alignItems: "center", borderRadius: 999,
|
|
555
618
|
},
|
|
556
619
|
endTourText: { color: "#fff", fontSize: 13, fontWeight: "600" },
|
|
557
620
|
pointer: {
|
|
558
621
|
position: "absolute", width: 24, height: 24, borderRadius: 12,
|
|
559
622
|
backgroundColor: "rgba(239,68,68,1)", borderWidth: 3, borderColor: "#fff",
|
|
560
623
|
shadowColor: "#ef4444", shadowOpacity: 0.9, shadowRadius: 8,
|
|
561
|
-
elevation: 12, zIndex: 10000,
|
|
562
624
|
},
|
|
563
625
|
pointerHalo: {
|
|
564
626
|
position: "absolute", width: 48, height: 48, borderRadius: 24,
|
|
565
|
-
backgroundColor: "rgba(239,68,68,0.25)",
|
|
627
|
+
backgroundColor: "rgba(239,68,68,0.25)",
|
|
566
628
|
},
|
|
567
629
|
});
|