@guidelytech/react-native 0.12.0 → 0.13.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/dist/index.js CHANGED
@@ -4,7 +4,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4
4
 
5
5
  // src/index.tsx
6
6
  import { useContext as useContext3, useEffect as useEffect9, useMemo as useMemo6, useRef as useRef7, useState as useState8 } from "react";
7
- import { AppState as AppState2, Dimensions, Linking, Platform as Platform6, StyleSheet as StyleSheet10, View as View12 } from "react-native";
7
+ import { AppState as AppState2, Dimensions, Linking, NativeModules, Platform as Platform6, StyleSheet as StyleSheet10, View as View12 } from "react-native";
8
8
  import { createStaticFlags as createStaticFlags2, stableStringify as stableStringify3 } from "@guidelytech/flags";
9
9
 
10
10
  // src/flags.tsx
@@ -2428,6 +2428,425 @@ var Messenger = class {
2428
2428
  }
2429
2429
  };
2430
2430
 
2431
+ // src/core/replay.ts
2432
+ var RrwebEvents = {
2433
+ IMG_ID: 8,
2434
+ TOUCH_START: 7,
2435
+ TOUCH_END: 9,
2436
+ CLICK: 2,
2437
+ CSS: "html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000}img{display:block;width:100%;height:100%;object-fit:contain}",
2438
+ dataUrl(base64) {
2439
+ return `data:image/jpeg;base64,${base64}`;
2440
+ },
2441
+ meta(timestamp, href, width, height) {
2442
+ return JSON.stringify({ type: 4, data: { href, width, height }, timestamp });
2443
+ },
2444
+ fullSnapshot(timestamp, dataUrl, width, height) {
2445
+ const style = { type: 2, id: 5, tagName: "style", attributes: {}, childNodes: [{ type: 3, id: 6, textContent: RrwebEvents.CSS, isStyle: true }] };
2446
+ const img = { type: 2, id: 8, tagName: "img", attributes: { src: dataUrl, width, height, alt: "" }, childNodes: [] };
2447
+ const html = { type: 2, id: 3, tagName: "html", attributes: {}, childNodes: [{ type: 2, id: 4, tagName: "head", attributes: {}, childNodes: [style] }, { type: 2, id: 7, tagName: "body", attributes: {}, childNodes: [img] }] };
2448
+ const node = { type: 0, id: 1, childNodes: [{ type: 1, id: 2, name: "html", publicId: "", systemId: "" }, html] };
2449
+ return JSON.stringify({ type: 2, data: { node, initialOffset: { left: 0, top: 0 } }, timestamp });
2450
+ },
2451
+ frame(timestamp, dataUrl) {
2452
+ return JSON.stringify({ type: 3, data: { source: 0, texts: [], attributes: [{ id: 8, attributes: { src: dataUrl } }], removes: [], adds: [] }, timestamp });
2453
+ },
2454
+ /** MouseInteraction (source 2): 7 TouchStart, 9 TouchEnd, 2 Click. */
2455
+ interaction(timestamp, type, x, y) {
2456
+ return JSON.stringify({ type: 3, data: { source: 2, type, id: 8, x: round1(x), y: round1(y) }, timestamp });
2457
+ },
2458
+ touchMove(timestamp, x, y) {
2459
+ return JSON.stringify({ type: 3, data: { source: 6, positions: [{ x: round1(x), y: round1(y), id: 8, timeOffset: 0 }] }, timestamp });
2460
+ },
2461
+ custom(timestamp, tag, payload) {
2462
+ return JSON.stringify({ type: 5, data: { tag, payload }, timestamp });
2463
+ }
2464
+ };
2465
+ var round1 = (v) => Math.round(v * 10) / 10;
2466
+ var UPLOAD_TIMEOUT_MS = 3e4;
2467
+ var ReplayUploader = class {
2468
+ constructor(opts) {
2469
+ __publicField(this, "opts", opts);
2470
+ __publicField(this, "windowId", randomId(16));
2471
+ __publicField(this, "buffer", null);
2472
+ __publicField(this, "queue", []);
2473
+ __publicField(this, "queuedBytes", 0);
2474
+ __publicField(this, "seqs", /* @__PURE__ */ new Map());
2475
+ __publicField(this, "inflight", null);
2476
+ __publicField(this, "flushTimer", null);
2477
+ __publicField(this, "retryTimer", null);
2478
+ __publicField(this, "attempt", 0);
2479
+ __publicField(this, "destroyed", false);
2480
+ __publicField(this, "offRecover");
2481
+ this.offRecover = opts.health?.onRecover?.(() => {
2482
+ this.clearRetry();
2483
+ this.attempt = 0;
2484
+ this.flush();
2485
+ }) ?? null;
2486
+ }
2487
+ get pendingChunks() {
2488
+ return this.queue.length;
2489
+ }
2490
+ get bufferedEvents() {
2491
+ return this.buffer?.events.length ?? 0;
2492
+ }
2493
+ push(sessionId, json) {
2494
+ if (this.destroyed) return;
2495
+ if (this.buffer && this.buffer.sessionId !== sessionId) this.seal();
2496
+ const chunk = this.buffer ?? (this.buffer = { sessionId, meta: this.opts.meta(), events: [], bytes: 0 });
2497
+ chunk.events.push(json);
2498
+ chunk.bytes += json.length;
2499
+ if (chunk.bytes > (this.opts.maxBufferBytes ?? 1e6)) this.flush();
2500
+ else if (!this.flushTimer) {
2501
+ this.flushTimer = setTimeout(() => {
2502
+ this.flushTimer = null;
2503
+ this.flush();
2504
+ }, this.opts.flushIntervalMs ?? 1e4);
2505
+ }
2506
+ }
2507
+ /** Seals the buffer and uploads the backlog (one chunk at a time). */
2508
+ flush() {
2509
+ if (this.destroyed) return;
2510
+ this.seal();
2511
+ if (this.retryTimer || this.inflight || !this.queue.length) return;
2512
+ void this.sendNext();
2513
+ }
2514
+ /** Drops everything buffered or queued (consent withdrawn, server disabled replay). */
2515
+ discard() {
2516
+ this.buffer = null;
2517
+ this.queue = [];
2518
+ this.queuedBytes = 0;
2519
+ this.clearFlushTimer();
2520
+ this.clearRetry();
2521
+ this.attempt = 0;
2522
+ }
2523
+ /** Uploads what is left once; no retries afterwards. */
2524
+ destroy() {
2525
+ this.flush();
2526
+ this.destroyed = true;
2527
+ this.offRecover?.();
2528
+ this.clearFlushTimer();
2529
+ this.clearRetry();
2530
+ }
2531
+ clearFlushTimer() {
2532
+ if (this.flushTimer) clearTimeout(this.flushTimer);
2533
+ this.flushTimer = null;
2534
+ }
2535
+ clearRetry() {
2536
+ if (this.retryTimer) clearTimeout(this.retryTimer);
2537
+ this.retryTimer = null;
2538
+ }
2539
+ scheduleRetry(ms) {
2540
+ this.retryTimer = setTimeout(() => {
2541
+ this.retryTimer = null;
2542
+ this.flush();
2543
+ }, ms);
2544
+ }
2545
+ seal() {
2546
+ this.clearFlushTimer();
2547
+ const chunk = this.buffer;
2548
+ this.buffer = null;
2549
+ if (!chunk || !chunk.events.length) return;
2550
+ this.queue.push(chunk);
2551
+ this.queuedBytes += chunk.bytes;
2552
+ const max = this.opts.maxQueuedBytes ?? 5e6;
2553
+ while (this.queuedBytes > max && this.queue.length > 1) {
2554
+ const i = this.inflight && this.queue[0] === this.inflight ? 1 : 0;
2555
+ const [dropped] = this.queue.splice(i, 1);
2556
+ if (!dropped) break;
2557
+ this.queuedBytes -= dropped.bytes;
2558
+ this.opts.log?.(`replay backlog full: dropped a chunk of ${dropped.events.length} events`);
2559
+ }
2560
+ }
2561
+ nextSeq(chunk) {
2562
+ if (chunk.seq === void 0) {
2563
+ const seq = this.seqs.get(chunk.sessionId) ?? 0;
2564
+ this.seqs.set(chunk.sessionId, seq + 1);
2565
+ chunk.seq = seq;
2566
+ }
2567
+ return chunk.seq;
2568
+ }
2569
+ body(chunk) {
2570
+ const head = JSON.stringify({ sessionId: chunk.sessionId, windowId: this.windowId, seq: this.nextSeq(chunk), ...chunk.meta });
2571
+ return `${head.slice(0, -1)},"events":[${chunk.events.join(",")}]}`;
2572
+ }
2573
+ async sendNext() {
2574
+ const chunk = this.queue[0];
2575
+ if (!chunk) return;
2576
+ const wait = this.destroyed ? 0 : this.opts.health?.waitMs() ?? 0;
2577
+ if (wait > 0) return this.scheduleRetry(wait);
2578
+ this.inflight = chunk;
2579
+ const result = await this.upload(this.body(chunk));
2580
+ this.onResult(chunk, result);
2581
+ }
2582
+ async upload(json) {
2583
+ const f = this.opts.fetch();
2584
+ if (!f) return "drop";
2585
+ const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
2586
+ const timer = ac ? setTimeout(() => ac.abort(), UPLOAD_TIMEOUT_MS) : null;
2587
+ try {
2588
+ let res;
2589
+ try {
2590
+ res = await f(this.opts.url(), { method: "POST", body: json, headers: { "Content-Type": "application/json" }, signal: ac?.signal });
2591
+ } catch {
2592
+ this.opts.health?.failure();
2593
+ return "retry";
2594
+ }
2595
+ if (res.status === 429 || res.status >= 500) this.opts.health?.failure();
2596
+ else this.opts.health?.success();
2597
+ if (res.ok) {
2598
+ const data = await res.json().catch(() => null);
2599
+ return data?.disabled ? "disabled" : "ok";
2600
+ }
2601
+ return res.status >= 400 && res.status < 500 && res.status !== 429 ? "drop" : "retry";
2602
+ } finally {
2603
+ if (timer) clearTimeout(timer);
2604
+ }
2605
+ }
2606
+ onResult(chunk, result) {
2607
+ if (this.inflight === chunk) this.inflight = null;
2608
+ if (result === "retry" && this.queue.includes(chunk)) {
2609
+ if (!this.destroyed) {
2610
+ const max = this.opts.maxAttempts ?? 5;
2611
+ this.attempt = Math.min(this.attempt + 1, max);
2612
+ const delay = this.attempt >= max ? this.opts.slowRetryMs ?? 6e4 : backoffMs(this.attempt - 1, 1e3, 8e3);
2613
+ return this.scheduleRetry(Math.max(delay, this.opts.health?.waitMs() ?? 0));
2614
+ }
2615
+ this.opts.log?.("replay upload failed after the recorder stopped; dropping the chunk");
2616
+ }
2617
+ this.attempt = 0;
2618
+ this.remove(chunk);
2619
+ if (result === "disabled") {
2620
+ for (const c of this.queue.slice()) if (c.sessionId === chunk.sessionId) this.remove(c);
2621
+ if (this.buffer?.sessionId === chunk.sessionId) this.buffer = null;
2622
+ this.opts.onDisabled(chunk.sessionId);
2623
+ }
2624
+ if (this.queue.length && !this.retryTimer && !this.inflight) void this.sendNext();
2625
+ }
2626
+ remove(chunk) {
2627
+ const i = this.queue.indexOf(chunk);
2628
+ if (i >= 0) {
2629
+ this.queue.splice(i, 1);
2630
+ this.queuedBytes -= chunk.bytes;
2631
+ }
2632
+ }
2633
+ };
2634
+ var TOUCH_SETTLE_MS = 200;
2635
+ var IDLE_FRAMES = 3;
2636
+ var MAX_IDLE_INTERVAL_MS = 5e3;
2637
+ var SessionRecorder = class {
2638
+ constructor(opts) {
2639
+ __publicField(this, "opts", opts);
2640
+ __publicField(this, "intervalMs");
2641
+ __publicField(this, "now");
2642
+ __publicField(this, "sid", null);
2643
+ __publicField(this, "foreground", true);
2644
+ __publicField(this, "generation", 0);
2645
+ __publicField(this, "timer", null);
2646
+ __publicField(this, "timerDue", 0);
2647
+ __publicField(this, "capturing", false);
2648
+ __publicField(this, "needKeyframe", true);
2649
+ __publicField(this, "hasKeyframe", false);
2650
+ __publicField(this, "lastFrame", null);
2651
+ __publicField(this, "unchanged", 0);
2652
+ __publicField(this, "captureSoon", false);
2653
+ __publicField(this, "pendingCustom", []);
2654
+ __publicField(this, "touchDown", false);
2655
+ __publicField(this, "downX", 0);
2656
+ __publicField(this, "downY", 0);
2657
+ __publicField(this, "downAt", 0);
2658
+ __publicField(this, "maxDistance", 0);
2659
+ __publicField(this, "lastMoveAt", 0);
2660
+ __publicField(this, "originX", 0);
2661
+ __publicField(this, "originY", 0);
2662
+ /** Viewport of the last frame (points), for chunk metadata. */
2663
+ __publicField(this, "viewportWidth", 0);
2664
+ __publicField(this, "viewportHeight", 0);
2665
+ this.intervalMs = Math.max(500, opts.intervalMs);
2666
+ this.now = opts.now ?? Date.now;
2667
+ }
2668
+ get sessionId() {
2669
+ return this.sid;
2670
+ }
2671
+ get isRecording() {
2672
+ return this.sid !== null;
2673
+ }
2674
+ get currentIntervalMs() {
2675
+ return this.unchanged >= IDLE_FRAMES ? Math.max(this.intervalMs, Math.min(4 * this.intervalMs, MAX_IDLE_INTERVAL_MS)) : this.intervalMs;
2676
+ }
2677
+ /** Starts (or keeps) recording `sessionId`; another session stops the current one first (uploading it). */
2678
+ start(sessionId) {
2679
+ if (this.sid === sessionId) return;
2680
+ if (this.sid !== null) this.stop(false);
2681
+ this.sid = sessionId;
2682
+ this.generation++;
2683
+ this.needKeyframe = true;
2684
+ this.hasKeyframe = false;
2685
+ this.lastFrame = null;
2686
+ this.unchanged = 0;
2687
+ this.captureSoon = false;
2688
+ this.pendingCustom = [];
2689
+ this.touchDown = false;
2690
+ if (this.foreground) this.schedule(0);
2691
+ }
2692
+ /** Stops recording; buffered events are uploaded unless `discard`. */
2693
+ stop(discard) {
2694
+ if (this.sid === null) return;
2695
+ this.sid = null;
2696
+ this.generation++;
2697
+ this.clearTimer();
2698
+ this.capturing = false;
2699
+ this.pendingCustom = [];
2700
+ this.touchDown = false;
2701
+ if (discard) this.opts.uploader.discard();
2702
+ else this.opts.uploader.flush();
2703
+ }
2704
+ /** App backgrounded: no captures; uploads what is buffered. */
2705
+ pause() {
2706
+ this.foreground = false;
2707
+ this.clearTimer();
2708
+ this.generation++;
2709
+ this.capturing = false;
2710
+ this.touchDown = false;
2711
+ this.opts.uploader.flush();
2712
+ }
2713
+ /** App foregrounded: resume with a keyframe. */
2714
+ resume() {
2715
+ if (this.foreground) return;
2716
+ this.foreground = true;
2717
+ this.needKeyframe = true;
2718
+ this.unchanged = 0;
2719
+ if (this.sid !== null) this.schedule(0);
2720
+ }
2721
+ /** A new screen: the next frame is a keyframe with the new href. */
2722
+ onScreen() {
2723
+ if (this.sid === null) return;
2724
+ this.needKeyframe = true;
2725
+ this.activity();
2726
+ }
2727
+ activity() {
2728
+ const wasIdle = this.unchanged >= IDLE_FRAMES;
2729
+ this.unchanged = 0;
2730
+ if (wasIdle && this.sid !== null && this.foreground && !this.capturing && !this.touchDown) this.schedule(this.intervalMs, true);
2731
+ }
2732
+ /** Adds the `guidely` marker for an analytics event. */
2733
+ addGuidelyEvent(name, customName, guideId) {
2734
+ const id = this.sid;
2735
+ if (id === null) return;
2736
+ const payload = { name };
2737
+ if (customName) payload.customName = customName;
2738
+ if (guideId) payload.guideId = guideId;
2739
+ this.activity();
2740
+ const now = this.now();
2741
+ if (!this.hasKeyframe) {
2742
+ if (this.pendingCustom.length < 50) this.pendingCustom.push([now, payload]);
2743
+ return;
2744
+ }
2745
+ this.opts.uploader.push(id, RrwebEvents.custom(now, "guidely", payload));
2746
+ }
2747
+ /** A primary-pointer touch at (x, y) points from the React root view. */
2748
+ touch(phase, rootX, rootY) {
2749
+ const id = this.sid;
2750
+ if (id === null || !this.foreground) return;
2751
+ const x = rootX + this.originX;
2752
+ const y = rootY + this.originY;
2753
+ const now = this.now();
2754
+ const emit = this.hasKeyframe;
2755
+ const push = (json) => emit && this.opts.uploader.push(id, json);
2756
+ switch (phase) {
2757
+ case "down":
2758
+ this.touchDown = true;
2759
+ this.unchanged = 0;
2760
+ this.downX = x;
2761
+ this.downY = y;
2762
+ this.downAt = now;
2763
+ this.maxDistance = 0;
2764
+ this.lastMoveAt = now;
2765
+ push(RrwebEvents.interaction(now, RrwebEvents.TOUCH_START, x, y));
2766
+ return;
2767
+ case "move":
2768
+ if (!this.touchDown) return;
2769
+ this.maxDistance = Math.max(this.maxDistance, Math.hypot(x - this.downX, y - this.downY));
2770
+ if (now - this.lastMoveAt < 50) return;
2771
+ this.lastMoveAt = now;
2772
+ push(RrwebEvents.touchMove(now, x, y));
2773
+ return;
2774
+ default:
2775
+ if (!this.touchDown) return;
2776
+ this.touchDown = false;
2777
+ this.unchanged = 0;
2778
+ this.maxDistance = Math.max(this.maxDistance, Math.hypot(x - this.downX, y - this.downY));
2779
+ push(RrwebEvents.interaction(now, RrwebEvents.TOUCH_END, x, y));
2780
+ if (phase === "up" && this.maxDistance <= 10 && now - this.downAt < 1e3) push(RrwebEvents.interaction(now, RrwebEvents.CLICK, x, y));
2781
+ if (this.capturing) this.captureSoon = true;
2782
+ else this.schedule(TOUCH_SETTLE_MS, true);
2783
+ }
2784
+ }
2785
+ clearTimer() {
2786
+ if (this.timer) clearTimeout(this.timer);
2787
+ this.timer = null;
2788
+ }
2789
+ /** Schedules the next capture `delay` ms from now, or keeps an earlier one when `ifSooner`. */
2790
+ schedule(delay, ifSooner = false) {
2791
+ const due = this.now() + delay;
2792
+ if (ifSooner && this.timer && this.timerDue <= due) return;
2793
+ this.clearTimer();
2794
+ this.timerDue = due;
2795
+ const gen = this.generation;
2796
+ this.timer = setTimeout(() => {
2797
+ this.timer = null;
2798
+ if (gen === this.generation) void this.tick();
2799
+ }, delay);
2800
+ }
2801
+ async tick() {
2802
+ if (this.sid === null || !this.foreground || this.capturing) return;
2803
+ if (this.touchDown) {
2804
+ this.schedule(this.intervalMs);
2805
+ return;
2806
+ }
2807
+ this.capturing = true;
2808
+ this.captureSoon = false;
2809
+ const gen = this.generation;
2810
+ let frame = null;
2811
+ try {
2812
+ frame = await this.opts.capturer(await this.opts.captureOptions());
2813
+ } catch (err) {
2814
+ this.opts.onError?.(err);
2815
+ }
2816
+ if (gen !== this.generation) return;
2817
+ this.capturing = false;
2818
+ if (frame) this.onFrame(frame, this.now());
2819
+ if (this.sid !== null && this.foreground) this.schedule(this.captureSoon ? TOUCH_SETTLE_MS : this.currentIntervalMs);
2820
+ }
2821
+ onFrame(frame, at) {
2822
+ const id = this.sid;
2823
+ if (id === null || !(frame.width > 0) || !(frame.height > 0) || !frame.base64) return;
2824
+ const sizeChanged = frame.width !== this.viewportWidth || frame.height !== this.viewportHeight;
2825
+ const keyframe = this.needKeyframe || !this.hasKeyframe || sizeChanged;
2826
+ if (!keyframe && this.lastFrame === frame.base64) {
2827
+ this.unchanged++;
2828
+ return;
2829
+ }
2830
+ this.unchanged = 0;
2831
+ this.lastFrame = frame.base64;
2832
+ this.originX = frame.originX ?? 0;
2833
+ this.originY = frame.originY ?? 0;
2834
+ this.viewportWidth = frame.width;
2835
+ this.viewportHeight = frame.height;
2836
+ const url = RrwebEvents.dataUrl(frame.base64);
2837
+ const up = this.opts.uploader;
2838
+ if (!keyframe) return up.push(id, RrwebEvents.frame(at, url));
2839
+ this.needKeyframe = false;
2840
+ up.push(id, RrwebEvents.meta(at, this.opts.href(), frame.width, frame.height));
2841
+ up.push(id, RrwebEvents.fullSnapshot(at, url, frame.width, frame.height));
2842
+ if (!this.hasKeyframe) {
2843
+ this.hasKeyframe = true;
2844
+ for (const [ts, payload] of this.pendingCustom) up.push(id, RrwebEvents.custom(Math.max(ts, at), "guidely", payload));
2845
+ this.pendingCustom = [];
2846
+ }
2847
+ }
2848
+ };
2849
+
2431
2850
  // src/core/storage.ts
2432
2851
  var PersistentStore = class {
2433
2852
  constructor(backing, onError = () => {
@@ -2481,7 +2900,7 @@ var PersistentStore = class {
2481
2900
 
2482
2901
  // src/core/client.ts
2483
2902
  var SDK_NAME = "@guidelytech/react-native";
2484
- var SDK_VERSION = "0.12.0";
2903
+ var SDK_VERSION = "0.13.0";
2485
2904
  var DEFAULT_API_URL = "https://guidelytech.com";
2486
2905
  var PAGE_SCOPED = /* @__PURE__ */ new Set(["page_load", "url_match", "element_click", "element_visible", "delay"]);
2487
2906
  var CACHED_START_MS = 1500;
@@ -2542,6 +2961,10 @@ var GuidelyClient = class {
2542
2961
  __publicField(this, "lastAudiences", /* @__PURE__ */ new Set());
2543
2962
  __publicField(this, "backgroundedAt", null);
2544
2963
  __publicField(this, "messenger", null);
2964
+ __publicField(this, "replayUploader", null);
2965
+ __publicField(this, "recorder", null);
2966
+ /** Replay decision for the current session (sampled once; forced by start/stopSessionRecording). */
2967
+ __publicField(this, "replayDecision", null);
2545
2968
  __publicField(this, "ui", { steps: [], checklists: [], survey: null, resourceCenterOpen: false, theme: {}, messenger: false });
2546
2969
  __publicField(this, "uiListeners", /* @__PURE__ */ new Set());
2547
2970
  /** Feature flags for this environment: `guidely.flags.isEnabled("new-checkout")`. */
@@ -2693,6 +3116,7 @@ var GuidelyClient = class {
2693
3116
  if (this.destroyed) return;
2694
3117
  this.isReady = true;
2695
3118
  if (this.config) this.emitter.emit("ready", this.config);
3119
+ this.syncReplay();
2696
3120
  this.notify();
2697
3121
  this.evaluate("ready");
2698
3122
  } catch (err) {
@@ -2719,6 +3143,7 @@ var GuidelyClient = class {
2719
3143
  this.syncMessenger();
2720
3144
  if (this.hydrated) this.checkLaunch();
2721
3145
  this.emitter.emit("config", this.config);
3146
+ this.syncReplay();
2722
3147
  this.log("config loaded", this.config.configVersion, `${this.config.guides.length} guides`);
2723
3148
  return true;
2724
3149
  }
@@ -2771,6 +3196,7 @@ var GuidelyClient = class {
2771
3196
  void this.outbox?.flush();
2772
3197
  void this.store.flush();
2773
3198
  void this.flags.flush();
3199
+ this.recorder?.pause();
2774
3200
  this.messenger?.setForeground(false);
2775
3201
  } else if (state === "active" && this.backgroundedAt !== null) {
2776
3202
  this.messenger?.setForeground(true);
@@ -2780,7 +3206,10 @@ var GuidelyClient = class {
2780
3206
  this.session = newSession();
2781
3207
  this.persistSession();
2782
3208
  this.firedDelays.clear();
3209
+ this.recorder?.stop(false);
2783
3210
  }
3211
+ this.recorder?.resume();
3212
+ this.syncReplay();
2784
3213
  this.cameToForeground();
2785
3214
  void this.flags.refresh();
2786
3215
  if (Date.now() - this.configFetchedAt > this.options.configRefreshMs && this.api.health.canSend()) void this.refresh();
@@ -2803,6 +3232,9 @@ var GuidelyClient = class {
2803
3232
  this.queued = [];
2804
3233
  this.messenger?.destroy();
2805
3234
  this.messenger = null;
3235
+ this.recorder?.stop(!this.analyticsEnabled());
3236
+ this.recorder = null;
3237
+ this.replayUploader?.destroy();
2806
3238
  this.visibilityWatchers.forEach((stop) => stop());
2807
3239
  this.visibilityWatchers.clear();
2808
3240
  this.timers.forEach((t) => clearTimeout(t));
@@ -2917,6 +3349,7 @@ var GuidelyClient = class {
2917
3349
  this.store.set(KEYS.anon, this.anonymousId);
2918
3350
  this.state = emptyState();
2919
3351
  this.persistState();
3352
+ this.recorder?.stop(false);
2920
3353
  this.session = newSession();
2921
3354
  this.persistSession();
2922
3355
  this.lastAudiences = /* @__PURE__ */ new Set();
@@ -2924,6 +3357,7 @@ var GuidelyClient = class {
2924
3357
  this.firstVisit = true;
2925
3358
  this.exposures.clear();
2926
3359
  this.syncFlagAttributes();
3360
+ this.syncReplay();
2927
3361
  this.emitter.emit("identify", { userId: null, anonymousId: this.anonymousId });
2928
3362
  this.notify();
2929
3363
  if (this.isReady) this.evaluate("identify");
@@ -2938,6 +3372,7 @@ var GuidelyClient = class {
2938
3372
  this.whenHydrated(() => {
2939
3373
  if (consent === "denied") this.queue?.clear();
2940
3374
  else if (consent === "granted") void this.queue?.flush();
3375
+ this.syncReplay();
2941
3376
  });
2942
3377
  }
2943
3378
  analyticsEnabled() {
@@ -2966,6 +3401,7 @@ var GuidelyClient = class {
2966
3401
  const locale = extra.guideId ? this.guideLocales.get(extra.guideId) : void 0;
2967
3402
  if (locale) event.properties = { ...event.properties ?? {}, locale };
2968
3403
  this.queue.enqueue(event);
3404
+ this.recorder?.addGuidelyEvent(name, event.customName, event.guideId);
2969
3405
  this.emitter.emit("track", event);
2970
3406
  return event;
2971
3407
  }
@@ -3003,6 +3439,7 @@ var GuidelyClient = class {
3003
3439
  }
3004
3440
  this.screenName = name;
3005
3441
  this.targets.setScreen(name);
3442
+ if (changed) this.recorder?.onScreen();
3006
3443
  if (changed) this.syncFlagAttributes();
3007
3444
  const url = this.currentUrl();
3008
3445
  if (changed) {
@@ -3037,6 +3474,112 @@ var GuidelyClient = class {
3037
3474
  if (this.isReady) this.evaluate("click", { targetId });
3038
3475
  }
3039
3476
  // ---------------------------------------------------------------------------
3477
+ // Session replay
3478
+ // ---------------------------------------------------------------------------
3479
+ replaySettings() {
3480
+ const r = this.config?.settings?.replay;
3481
+ if (!r?.enabled || !this.options.sessionReplay?.enabled || !this.bridge.capturer) return null;
3482
+ return r;
3483
+ }
3484
+ /** Reconciles recording with config, consent, readiness, the session and its sample decision. Idempotent. */
3485
+ syncReplay() {
3486
+ if (this.destroyed || !this.hydrated) return;
3487
+ const r = this.isReady && this.analyticsEnabled() ? this.replaySettings() : null;
3488
+ const id = this.session.id;
3489
+ if (r && this.replayDecision?.sessionId !== id) {
3490
+ this.replayDecision = { sessionId: id, record: rolloutBucket(id, "replay") < (r.sampleRate ?? 100) };
3491
+ this.log(`replay ${this.replayDecision.record ? "sampled" : "not sampled"} for session ${id}`);
3492
+ }
3493
+ if (!r || !this.replayDecision?.record) {
3494
+ if (this.recorder?.isRecording) this.recorder.stop(!this.analyticsEnabled());
3495
+ return;
3496
+ }
3497
+ const rec = this.recorder ?? this.createRecorder();
3498
+ if (rec && rec.sessionId !== id) {
3499
+ this.log(`recording session ${id}`);
3500
+ rec.start(id);
3501
+ }
3502
+ }
3503
+ createRecorder() {
3504
+ const capturer = this.bridge.capturer;
3505
+ if (!capturer) return null;
3506
+ const uploader = this.replayUploader ?? (this.replayUploader = new ReplayUploader({
3507
+ url: () => this.api.url("/replay"),
3508
+ fetch: () => this.options.fetch ?? (typeof fetch === "function" ? fetch : null),
3509
+ health: this.api.health,
3510
+ meta: () => {
3511
+ const rec2 = this.recorder;
3512
+ return {
3513
+ anonymousId: this.anonymousId,
3514
+ userId: this.user?.id || null,
3515
+ ...this.collectUrls() && this.screenName ? { url: this.currentUrl() } : {},
3516
+ device: { type: this.bridge.device.deviceType, os: this.bridge.device.platform },
3517
+ ...rec2 && rec2.viewportWidth > 0 && rec2.viewportHeight > 0 ? { viewport: { width: rec2.viewportWidth, height: rec2.viewportHeight } } : {},
3518
+ sdk: { name: SDK_NAME, version: SDK_VERSION }
3519
+ };
3520
+ },
3521
+ onDisabled: (sid) => {
3522
+ if (sid !== this.session.id) return;
3523
+ this.log(`replay disabled by the server for session ${sid}`);
3524
+ this.replayDecision = { sessionId: sid, record: false };
3525
+ this.recorder?.stop(true);
3526
+ },
3527
+ log: (m) => this.log(m)
3528
+ }));
3529
+ const opts = this.options.sessionReplay ?? {};
3530
+ const rec = new SessionRecorder({
3531
+ capturer,
3532
+ uploader,
3533
+ intervalMs: opts.captureIntervalMs ?? 1e3,
3534
+ captureOptions: () => this.replayCaptureOptions(),
3535
+ href: () => this.currentUrl(),
3536
+ onError: (err) => this.reportError(err)
3537
+ });
3538
+ if (this.backgroundedAt !== null) rec.pause();
3539
+ this.recorder = rec;
3540
+ return rec;
3541
+ }
3542
+ async replayCaptureOptions() {
3543
+ const r = this.config?.settings?.replay;
3544
+ const opts = this.options.sessionReplay ?? {};
3545
+ const ids = new Set((r?.blockSelectors ?? []).map((sel) => guidelyIdFromSelector(sel)).filter((id) => !!id));
3546
+ const rects = await Promise.all(Array.from(ids, (id) => this.targets.measure(id)));
3547
+ return { maskAllText: !!opts.maskAllText || !!r?.maskAllText, maskAllImages: !!opts.maskAllImages, blockRects: rects.filter((rect) => !!rect) };
3548
+ }
3549
+ /**
3550
+ * Records this session even when it was not sampled (still needs `sessionReplay.enabled`, replay
3551
+ * enabled for the project, analytics consent and the native replay module). Sticks until the session ends.
3552
+ */
3553
+ startSessionRecording() {
3554
+ this.whenHydrated(() => {
3555
+ this.replayDecision = { sessionId: this.session.id, record: true };
3556
+ this.syncReplay();
3557
+ });
3558
+ }
3559
+ /** Stops recording for the rest of this session; what was recorded is uploaded. */
3560
+ stopSessionRecording() {
3561
+ this.whenHydrated(() => {
3562
+ this.replayDecision = { sessionId: this.session.id, record: false };
3563
+ this.recorder?.stop(false);
3564
+ });
3565
+ }
3566
+ /** True while this session is being recorded. */
3567
+ isRecording() {
3568
+ return !!this.recorder?.isRecording;
3569
+ }
3570
+ /** The current analytics session id (also the replay id). */
3571
+ getSessionId() {
3572
+ return this.session.id;
3573
+ }
3574
+ /** True when touches should be passed to {@link recordTouch}. */
3575
+ isRecordingTouches() {
3576
+ return !!this.recorder?.isRecording;
3577
+ }
3578
+ /** A primary-pointer touch on the app, in window points (the provider's touch observer calls this). */
3579
+ recordTouch(phase, x, y) {
3580
+ this.recorder?.touch(phase, x, y);
3581
+ }
3582
+ // ---------------------------------------------------------------------------
3040
3583
  // Product analytics (autocapture)
3041
3584
  // ---------------------------------------------------------------------------
3042
3585
  captureAllowed() {
@@ -3083,7 +3626,7 @@ var GuidelyClient = class {
3083
3626
  }
3084
3627
  if (this.destroyed) return;
3085
3628
  const platform = this.bridge.device.platform;
3086
- const maskAll = !!this.config?.settings?.replay?.maskAllText;
3629
+ const maskAll = !!this.config?.settings?.replay?.maskAllText || !!this.options.sessionReplay?.maskAllText;
3087
3630
  const eventUrl = this.collectUrls() && url ? url : void 0;
3088
3631
  if (on.clicks && el) this.enqueue("click", { url: eventUrl, properties: { ...tapProperties(el, tap.x, tap.y, platform, maskAll) } });
3089
3632
  const count = this.rage.add(tap.x, tap.y, t);
@@ -6052,8 +6595,9 @@ function rcLauncherLabel(client) {
6052
6595
 
6053
6596
  // src/ui/Autocapture.tsx
6054
6597
  import { jsx as jsx11 } from "react/jsx-runtime";
6598
+ var GUIDELY_MASK_NATIVE_ID = "guidely-mask";
6055
6599
  function GuidelyMask({ children, ...rest }) {
6056
- return /* @__PURE__ */ jsx11(View10, { ...rest, children });
6600
+ return /* @__PURE__ */ jsx11(View10, { collapsable: false, ...rest, nativeID: GUIDELY_MASK_NATIVE_ID, children });
6057
6601
  }
6058
6602
  var RESOLVE = {
6059
6603
  isIgnored: (type) => type === GuidelyOverlay,
@@ -6064,6 +6608,18 @@ function useTapObserver(client) {
6064
6608
  clientRef.current = client;
6065
6609
  return useMemo5(() => {
6066
6610
  const tracker = new TapTracker();
6611
+ let replayPointer = null;
6612
+ const replay = (phase, e) => {
6613
+ const c = clientRef.current;
6614
+ const n = e.nativeEvent;
6615
+ if (!c?.isRecordingTouches()) return void (replayPointer = null);
6616
+ if (phase === "down") {
6617
+ if (replayPointer !== null) return;
6618
+ replayPointer = n.identifier;
6619
+ } else if (n.identifier !== replayPointer) return;
6620
+ if (phase === "up" || phase === "cancel") replayPointer = null;
6621
+ c.recordTouch(phase, n.pageX, n.pageY);
6622
+ };
6067
6623
  const guard = (fn) => (e) => {
6068
6624
  try {
6069
6625
  fn(e);
@@ -6073,12 +6629,17 @@ function useTapObserver(client) {
6073
6629
  };
6074
6630
  return {
6075
6631
  onTouchStartCapture: guard((e) => {
6632
+ replay("down", e);
6076
6633
  const n = e.nativeEvent;
6077
6634
  if (!clientRef.current?.isTapCaptureOn()) return tracker.cancel();
6078
6635
  tracker.begin(n.pageX, n.pageY, Date.now(), Array.isArray(n.touches) ? n.touches.length : 1, n.identifier);
6079
6636
  }),
6080
- onTouchMoveCapture: guard((e) => tracker.move(e.nativeEvent.pageX, e.nativeEvent.pageY, e.nativeEvent.identifier)),
6637
+ onTouchMoveCapture: guard((e) => {
6638
+ replay("move", e);
6639
+ tracker.move(e.nativeEvent.pageX, e.nativeEvent.pageY, e.nativeEvent.identifier);
6640
+ }),
6081
6641
  onTouchEndCapture: guard((e) => {
6642
+ replay("up", e);
6082
6643
  const n = e.nativeEvent;
6083
6644
  const tap = tracker.end(n.pageX, n.pageY, Date.now(), n.identifier);
6084
6645
  const c = clientRef.current;
@@ -6086,7 +6647,10 @@ function useTapObserver(client) {
6086
6647
  const r = resolveTapTarget(e, RESOLVE);
6087
6648
  if (!r.ignored) c.recordTap({ x: tap.x, y: tap.y, t: Date.now(), element: r.element, hit: r.hit });
6088
6649
  }),
6089
- onTouchCancelCapture: guard(() => tracker.cancel())
6650
+ onTouchCancelCapture: guard((e) => {
6651
+ replay("cancel", e);
6652
+ tracker.cancel();
6653
+ })
6090
6654
  };
6091
6655
  }, []);
6092
6656
  }
@@ -6180,9 +6744,15 @@ function defaultBridge(appVersion) {
6180
6744
  },
6181
6745
  openExternal: (url) => void Linking.openURL(url).catch(() => {
6182
6746
  }),
6183
- windowSize: () => Dimensions.get("window")
6747
+ windowSize: () => Dimensions.get("window"),
6748
+ capturer: nativeCapturer()
6184
6749
  };
6185
6750
  }
6751
+ function nativeCapturer() {
6752
+ const mod = NativeModules.GuidelyReplay;
6753
+ if (!mod || typeof mod.capture !== "function") return void 0;
6754
+ return (options) => mod.capture(options);
6755
+ }
6186
6756
  function GuidelyProvider({ children, user, insets, launcherOffset = 72, flagsPayload, flagAttributes, ...options }) {
6187
6757
  const [client, setClient] = useState8(null);
6188
6758
  const optionsRef = useRef7(options);
@@ -6252,7 +6822,7 @@ function GuidelyProvider({ children, user, insets, launcherOffset = 72, flagsPay
6252
6822
  const flags = useMemo6(() => ({ client: client?.flags ?? null, bootstrap }), [client, bootstrap]);
6253
6823
  const tapObserver = useTapObserver(client);
6254
6824
  return /* @__PURE__ */ jsx13(GuidelyContext.Provider, { value: client, children: /* @__PURE__ */ jsxs9(FlagsContext.Provider, { value: flags, children: [
6255
- options.autocapture === false ? children : /* @__PURE__ */ jsx13(View12, { style: styles10.root, ...tapObserver, children }),
6825
+ options.autocapture === false && !options.sessionReplay?.enabled ? children : /* @__PURE__ */ jsx13(View12, { style: styles10.root, ...tapObserver, children }),
6256
6826
  client ? /* @__PURE__ */ jsx13(GuidelyOverlay, { client, insets, launcherOffset }) : null
6257
6827
  ] }) });
6258
6828
  }
@@ -6272,6 +6842,9 @@ var NOOP_RESULTS = {
6272
6842
  getMessenger: null,
6273
6843
  isGuideCompleted: false,
6274
6844
  isClientReady: false,
6845
+ isRecording: false,
6846
+ isRecordingTouches: false,
6847
+ getSessionId: "",
6275
6848
  getScreen: "",
6276
6849
  on: () => {
6277
6850
  },
@@ -6405,8 +6978,11 @@ export {
6405
6978
  InMemoryStickyBucketStore,
6406
6979
  Messenger,
6407
6980
  RageClickDetector,
6981
+ ReplayUploader,
6982
+ RrwebEvents,
6408
6983
  SDK_NAME,
6409
6984
  SDK_VERSION,
6985
+ SessionRecorder,
6410
6986
  TapTracker,
6411
6987
  TargetRegistry,
6412
6988
  createFlagsClient3 as createFlagsClient,