@sendoracloud/sdk-react-native 1.4.0 → 1.6.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.cjs CHANGED
@@ -1760,15 +1760,16 @@ var import_react_native3 = require("react-native");
1760
1760
  var DEFAULT_IDLE_MS = 30 * 60 * 1e3;
1761
1761
  function resolveFlags(cfg) {
1762
1762
  if (cfg === false) {
1763
- return { appOpen: false, sessionStart: false, appBackground: false };
1763
+ return { appOpen: false, sessionStart: false, appBackground: false, engagement: false };
1764
1764
  }
1765
1765
  if (cfg === true || cfg === void 0) {
1766
- return { appOpen: true, sessionStart: true, appBackground: true };
1766
+ return { appOpen: true, sessionStart: true, appBackground: true, engagement: true };
1767
1767
  }
1768
1768
  return {
1769
1769
  appOpen: cfg.appOpen ?? true,
1770
1770
  sessionStart: cfg.sessionStart ?? true,
1771
- appBackground: cfg.appBackground ?? true
1771
+ appBackground: cfg.appBackground ?? true,
1772
+ engagement: cfg.engagement ?? true
1772
1773
  };
1773
1774
  }
1774
1775
  function installAutoTrack(args) {
@@ -1799,15 +1800,21 @@ function installAutoTrack(args) {
1799
1800
  if (flags.appOpen) {
1800
1801
  args.fire("app.opened", { sessionId });
1801
1802
  }
1802
- if (flags.appBackground) {
1803
+ if (flags.appBackground || flags.engagement) {
1803
1804
  const AppState = import_react_native3.AppState;
1804
1805
  if (AppState && typeof AppState.addEventListener === "function") {
1805
1806
  const handler = (state) => {
1806
1807
  ensureSession(true);
1807
1808
  if (state === "background" || state === "inactive") {
1808
- args.fire("app.backgrounded", { sessionId: args.getSessionId() });
1809
+ if (flags.engagement) args.onForeground?.(false);
1810
+ if (flags.appBackground) {
1811
+ args.fire("app.backgrounded", { sessionId: args.getSessionId() });
1812
+ }
1809
1813
  } else if (state === "active") {
1810
- args.fire("app.foregrounded", { sessionId: args.getSessionId() });
1814
+ if (flags.appBackground) {
1815
+ args.fire("app.foregrounded", { sessionId: args.getSessionId() });
1816
+ }
1817
+ if (flags.engagement) args.onForeground?.(true);
1811
1818
  }
1812
1819
  };
1813
1820
  const sub = AppState.addEventListener("change", handler);
@@ -1848,6 +1855,8 @@ var MAX_PUSH_METADATA_BYTES = 4096;
1848
1855
  var MAX_USER_ID_LEN = 256;
1849
1856
  var MAX_TRAITS_BYTES = 32 * 1024;
1850
1857
  var MAX_PROPERTIES_BYTES = 32 * 1024;
1858
+ var MIN_ENGAGEMENT_MS = 250;
1859
+ var MAX_ENGAGEMENT_MS = 6 * 60 * 60 * 1e3;
1851
1860
  function assertCSPRNG() {
1852
1861
  const g = globalThis;
1853
1862
  if (g.crypto?.getRandomValues) {
@@ -1898,6 +1907,12 @@ var SendoraSDK = class {
1898
1907
  this.debug = false;
1899
1908
  this.sessionId = "";
1900
1909
  this.autoTrackCleanup = null;
1910
+ // --- Engagement time (foreground-only, Wave 75) ---
1911
+ this.engEnabled = false;
1912
+ this.engScreen = null;
1913
+ this.engAccumMs = 0;
1914
+ // >0 = accumulating since this timestamp; 0 = paused (backgrounded).
1915
+ this.engSegmentStart = 0;
1901
1916
  /**
1902
1917
  * Lazy-allocated stable session id for chatbot conversations.
1903
1918
  * Survives only the JS lifetime — chatbot multi-turn context lives
@@ -1988,12 +2003,17 @@ var SendoraSDK = class {
1988
2003
  });
1989
2004
  this.ready = true;
1990
2005
  if (config.autoTrack !== false) {
2006
+ this.engEnabled = config.autoTrack === true || config.autoTrack === void 0 || config.autoTrack.engagement !== false;
1991
2007
  this.autoTrackCleanup = installAutoTrack({
1992
2008
  config: this.config,
1993
2009
  fire: (eventType, properties) => this.track(eventType, properties),
1994
2010
  getSessionId: () => this.sessionId,
1995
2011
  setSessionId: (id) => {
1996
2012
  this.sessionId = id;
2013
+ },
2014
+ onForeground: (active) => {
2015
+ if (active) this.engResume();
2016
+ else this.engFlush();
1997
2017
  }
1998
2018
  });
1999
2019
  }
@@ -2057,10 +2077,53 @@ var SendoraSDK = class {
2057
2077
  if (typeof name !== "string" || name.length === 0 || name.length > 256) {
2058
2078
  throw new Error("[sendoracloud] screen name must be a 1-256 char string.");
2059
2079
  }
2080
+ this.engFlush();
2060
2081
  void this.sendEvent("screen", {
2061
2082
  eventType: "screen.viewed",
2062
2083
  properties: { screenName: name, ...properties ?? {} }
2063
2084
  });
2085
+ this.engEnter(name);
2086
+ }
2087
+ // --- Engagement timer (foreground-only, Wave 75) ---
2088
+ /** Bank the in-flight foreground segment into the accumulator + pause. */
2089
+ engBank() {
2090
+ if (this.engSegmentStart > 0) {
2091
+ this.engAccumMs += Date.now() - this.engSegmentStart;
2092
+ this.engSegmentStart = 0;
2093
+ }
2094
+ }
2095
+ /**
2096
+ * Emit `app.engagement` for the current screen with foreground-only
2097
+ * durationMs, then reset the accumulator. Sub-threshold (redirect
2098
+ * flicker) spans are dropped; absurd spans clamp to a 6h ceiling.
2099
+ * Never throws — engagement is best-effort instrumentation.
2100
+ */
2101
+ engFlush() {
2102
+ if (!this.engEnabled || this.engScreen === null) return;
2103
+ this.engBank();
2104
+ let ms = this.engAccumMs;
2105
+ this.engAccumMs = 0;
2106
+ if (ms < MIN_ENGAGEMENT_MS) return;
2107
+ if (ms > MAX_ENGAGEMENT_MS) ms = MAX_ENGAGEMENT_MS;
2108
+ try {
2109
+ void this.sendEvent("track", {
2110
+ eventType: "app.engagement",
2111
+ properties: { durationMs: Math.round(ms), screen: this.engScreen, sessionId: this.sessionId }
2112
+ });
2113
+ } catch {
2114
+ }
2115
+ }
2116
+ /** Start measuring a new screen (foreground from now). */
2117
+ engEnter(name) {
2118
+ if (!this.engEnabled) return;
2119
+ this.engScreen = name;
2120
+ this.engAccumMs = 0;
2121
+ this.engSegmentStart = Date.now();
2122
+ }
2123
+ /** Resume accumulation after the app returns to foreground. */
2124
+ engResume() {
2125
+ if (!this.engEnabled || this.engScreen === null) return;
2126
+ if (this.engSegmentStart === 0) this.engSegmentStart = Date.now();
2064
2127
  }
2065
2128
  /**
2066
2129
  * Register a device push token (APNs or FCM) so server-side
@@ -2308,6 +2371,22 @@ var SendoraSDK = class {
2308
2371
  const parsed = await res.json();
2309
2372
  if (!parsed.success || !parsed.data) throw new Error("[sendoracloud] support.submitCsat: bad response");
2310
2373
  return parsed.data;
2374
+ },
2375
+ getUnreadCount: async (widgetId) => {
2376
+ if (!self.config || !widgetId) return 0;
2377
+ const token = await self.auth.getAccessToken();
2378
+ if (!token) return 0;
2379
+ try {
2380
+ const res = await fetch(
2381
+ `${self.config.apiUrl}/api/v1/widgets/${encodeURIComponent(widgetId)}/my-tickets/unread-count`,
2382
+ { headers: { Authorization: `Bearer ${token}` } }
2383
+ );
2384
+ if (!res.ok) return 0;
2385
+ const parsed = await res.json();
2386
+ return parsed?.data?.count ?? 0;
2387
+ } catch {
2388
+ return 0;
2389
+ }
2311
2390
  }
2312
2391
  };
2313
2392
  }
@@ -2605,6 +2684,7 @@ var SendoraSDK = class {
2605
2684
  }
2606
2685
  /** Tear down auto-track listeners. Call in test harnesses. */
2607
2686
  destroy() {
2687
+ this.engFlush();
2608
2688
  if (this.autoTrackCleanup) {
2609
2689
  this.autoTrackCleanup();
2610
2690
  this.autoTrackCleanup = null;
package/dist/index.d.cts CHANGED
@@ -728,6 +728,10 @@ interface SendoraConfig {
728
728
  * (idle >`sessionIdleMs` closes session). Default true.
729
729
  * - `appBackground` — emit `app.backgrounded`/`app.foregrounded` on
730
730
  * AppState transitions. Default true.
731
+ * - `engagement` — emit `app.engagement` with foreground-only `durationMs`
732
+ * per screen on the next `screen()` call + on app-background (GA4
733
+ * user_engagement parity). Default true. Only measures named screens —
734
+ * it has no effect until you start calling `screen()`.
731
735
  *
732
736
  * Note: screen view tracking is NOT auto — RN has no canonical router.
733
737
  * Call `SendoraCloud.screen(name)` from your navigation listener.
@@ -736,6 +740,7 @@ interface SendoraConfig {
736
740
  appOpen?: boolean;
737
741
  sessionStart?: boolean;
738
742
  appBackground?: boolean;
743
+ engagement?: boolean;
739
744
  };
740
745
  /** Idle threshold (ms) for session expiry; default 30 minutes. */
741
746
  sessionIdleMs?: number;
@@ -827,6 +832,10 @@ declare class SendoraSDK {
827
832
  private debug;
828
833
  private sessionId;
829
834
  private autoTrackCleanup;
835
+ private engEnabled;
836
+ private engScreen;
837
+ private engAccumMs;
838
+ private engSegmentStart;
830
839
  /** Lazy-initialised in init() once we know the apiUrl + publicKey. */
831
840
  auth: Auth;
832
841
  /** Lazy-initialised in init(). Deep-link surface: create + handleUniversalLink + matchDeferred. */
@@ -847,6 +856,19 @@ declare class SendoraSDK {
847
856
  track(eventType: string, properties?: TrackProperties): void;
848
857
  /** RN equivalent of page view — call on route / screen change. */
849
858
  screen(name: string, properties?: TrackProperties): void;
859
+ /** Bank the in-flight foreground segment into the accumulator + pause. */
860
+ private engBank;
861
+ /**
862
+ * Emit `app.engagement` for the current screen with foreground-only
863
+ * durationMs, then reset the accumulator. Sub-threshold (redirect
864
+ * flicker) spans are dropped; absurd spans clamp to a 6h ceiling.
865
+ * Never throws — engagement is best-effort instrumentation.
866
+ */
867
+ private engFlush;
868
+ /** Start measuring a new screen (foreground from now). */
869
+ private engEnter;
870
+ /** Resume accumulation after the app returns to foreground. */
871
+ private engResume;
850
872
  /**
851
873
  * Register a device push token (APNs or FCM) so server-side
852
874
  * workflows can target this user. The caller is responsible for
@@ -1008,6 +1030,13 @@ declare class SendoraSDK {
1008
1030
  rating: number;
1009
1031
  comment: string | null;
1010
1032
  }>;
1033
+ /**
1034
+ * Wave 73 — unread support-ticket count for the in-app badge.
1035
+ * Bearer JWT auth via the SDK's current end-user session.
1036
+ * Returns 0 if no auth (anon or identified) is available.
1037
+ * Recommended cadence: AppState foreground + every 60s.
1038
+ */
1039
+ getUnreadCount: (widgetId: string) => Promise<number>;
1011
1040
  };
1012
1041
  /**
1013
1042
  * Knowledge-base search namespace. Mirrors backend `GET /kb/search`
package/dist/index.d.ts CHANGED
@@ -728,6 +728,10 @@ interface SendoraConfig {
728
728
  * (idle >`sessionIdleMs` closes session). Default true.
729
729
  * - `appBackground` — emit `app.backgrounded`/`app.foregrounded` on
730
730
  * AppState transitions. Default true.
731
+ * - `engagement` — emit `app.engagement` with foreground-only `durationMs`
732
+ * per screen on the next `screen()` call + on app-background (GA4
733
+ * user_engagement parity). Default true. Only measures named screens —
734
+ * it has no effect until you start calling `screen()`.
731
735
  *
732
736
  * Note: screen view tracking is NOT auto — RN has no canonical router.
733
737
  * Call `SendoraCloud.screen(name)` from your navigation listener.
@@ -736,6 +740,7 @@ interface SendoraConfig {
736
740
  appOpen?: boolean;
737
741
  sessionStart?: boolean;
738
742
  appBackground?: boolean;
743
+ engagement?: boolean;
739
744
  };
740
745
  /** Idle threshold (ms) for session expiry; default 30 minutes. */
741
746
  sessionIdleMs?: number;
@@ -827,6 +832,10 @@ declare class SendoraSDK {
827
832
  private debug;
828
833
  private sessionId;
829
834
  private autoTrackCleanup;
835
+ private engEnabled;
836
+ private engScreen;
837
+ private engAccumMs;
838
+ private engSegmentStart;
830
839
  /** Lazy-initialised in init() once we know the apiUrl + publicKey. */
831
840
  auth: Auth;
832
841
  /** Lazy-initialised in init(). Deep-link surface: create + handleUniversalLink + matchDeferred. */
@@ -847,6 +856,19 @@ declare class SendoraSDK {
847
856
  track(eventType: string, properties?: TrackProperties): void;
848
857
  /** RN equivalent of page view — call on route / screen change. */
849
858
  screen(name: string, properties?: TrackProperties): void;
859
+ /** Bank the in-flight foreground segment into the accumulator + pause. */
860
+ private engBank;
861
+ /**
862
+ * Emit `app.engagement` for the current screen with foreground-only
863
+ * durationMs, then reset the accumulator. Sub-threshold (redirect
864
+ * flicker) spans are dropped; absurd spans clamp to a 6h ceiling.
865
+ * Never throws — engagement is best-effort instrumentation.
866
+ */
867
+ private engFlush;
868
+ /** Start measuring a new screen (foreground from now). */
869
+ private engEnter;
870
+ /** Resume accumulation after the app returns to foreground. */
871
+ private engResume;
850
872
  /**
851
873
  * Register a device push token (APNs or FCM) so server-side
852
874
  * workflows can target this user. The caller is responsible for
@@ -1008,6 +1030,13 @@ declare class SendoraSDK {
1008
1030
  rating: number;
1009
1031
  comment: string | null;
1010
1032
  }>;
1033
+ /**
1034
+ * Wave 73 — unread support-ticket count for the in-app badge.
1035
+ * Bearer JWT auth via the SDK's current end-user session.
1036
+ * Returns 0 if no auth (anon or identified) is available.
1037
+ * Recommended cadence: AppState foreground + every 60s.
1038
+ */
1039
+ getUnreadCount: (widgetId: string) => Promise<number>;
1011
1040
  };
1012
1041
  /**
1013
1042
  * Knowledge-base search namespace. Mirrors backend `GET /kb/search`
package/dist/index.js CHANGED
@@ -1722,15 +1722,16 @@ import { AppState as RnAppState2 } from "react-native";
1722
1722
  var DEFAULT_IDLE_MS = 30 * 60 * 1e3;
1723
1723
  function resolveFlags(cfg) {
1724
1724
  if (cfg === false) {
1725
- return { appOpen: false, sessionStart: false, appBackground: false };
1725
+ return { appOpen: false, sessionStart: false, appBackground: false, engagement: false };
1726
1726
  }
1727
1727
  if (cfg === true || cfg === void 0) {
1728
- return { appOpen: true, sessionStart: true, appBackground: true };
1728
+ return { appOpen: true, sessionStart: true, appBackground: true, engagement: true };
1729
1729
  }
1730
1730
  return {
1731
1731
  appOpen: cfg.appOpen ?? true,
1732
1732
  sessionStart: cfg.sessionStart ?? true,
1733
- appBackground: cfg.appBackground ?? true
1733
+ appBackground: cfg.appBackground ?? true,
1734
+ engagement: cfg.engagement ?? true
1734
1735
  };
1735
1736
  }
1736
1737
  function installAutoTrack(args) {
@@ -1761,15 +1762,21 @@ function installAutoTrack(args) {
1761
1762
  if (flags.appOpen) {
1762
1763
  args.fire("app.opened", { sessionId });
1763
1764
  }
1764
- if (flags.appBackground) {
1765
+ if (flags.appBackground || flags.engagement) {
1765
1766
  const AppState = RnAppState2;
1766
1767
  if (AppState && typeof AppState.addEventListener === "function") {
1767
1768
  const handler = (state) => {
1768
1769
  ensureSession(true);
1769
1770
  if (state === "background" || state === "inactive") {
1770
- args.fire("app.backgrounded", { sessionId: args.getSessionId() });
1771
+ if (flags.engagement) args.onForeground?.(false);
1772
+ if (flags.appBackground) {
1773
+ args.fire("app.backgrounded", { sessionId: args.getSessionId() });
1774
+ }
1771
1775
  } else if (state === "active") {
1772
- args.fire("app.foregrounded", { sessionId: args.getSessionId() });
1776
+ if (flags.appBackground) {
1777
+ args.fire("app.foregrounded", { sessionId: args.getSessionId() });
1778
+ }
1779
+ if (flags.engagement) args.onForeground?.(true);
1773
1780
  }
1774
1781
  };
1775
1782
  const sub = AppState.addEventListener("change", handler);
@@ -1810,6 +1817,8 @@ var MAX_PUSH_METADATA_BYTES = 4096;
1810
1817
  var MAX_USER_ID_LEN = 256;
1811
1818
  var MAX_TRAITS_BYTES = 32 * 1024;
1812
1819
  var MAX_PROPERTIES_BYTES = 32 * 1024;
1820
+ var MIN_ENGAGEMENT_MS = 250;
1821
+ var MAX_ENGAGEMENT_MS = 6 * 60 * 60 * 1e3;
1813
1822
  function assertCSPRNG() {
1814
1823
  const g = globalThis;
1815
1824
  if (g.crypto?.getRandomValues) {
@@ -1860,6 +1869,12 @@ var SendoraSDK = class {
1860
1869
  this.debug = false;
1861
1870
  this.sessionId = "";
1862
1871
  this.autoTrackCleanup = null;
1872
+ // --- Engagement time (foreground-only, Wave 75) ---
1873
+ this.engEnabled = false;
1874
+ this.engScreen = null;
1875
+ this.engAccumMs = 0;
1876
+ // >0 = accumulating since this timestamp; 0 = paused (backgrounded).
1877
+ this.engSegmentStart = 0;
1863
1878
  /**
1864
1879
  * Lazy-allocated stable session id for chatbot conversations.
1865
1880
  * Survives only the JS lifetime — chatbot multi-turn context lives
@@ -1950,12 +1965,17 @@ var SendoraSDK = class {
1950
1965
  });
1951
1966
  this.ready = true;
1952
1967
  if (config.autoTrack !== false) {
1968
+ this.engEnabled = config.autoTrack === true || config.autoTrack === void 0 || config.autoTrack.engagement !== false;
1953
1969
  this.autoTrackCleanup = installAutoTrack({
1954
1970
  config: this.config,
1955
1971
  fire: (eventType, properties) => this.track(eventType, properties),
1956
1972
  getSessionId: () => this.sessionId,
1957
1973
  setSessionId: (id) => {
1958
1974
  this.sessionId = id;
1975
+ },
1976
+ onForeground: (active) => {
1977
+ if (active) this.engResume();
1978
+ else this.engFlush();
1959
1979
  }
1960
1980
  });
1961
1981
  }
@@ -2019,10 +2039,53 @@ var SendoraSDK = class {
2019
2039
  if (typeof name !== "string" || name.length === 0 || name.length > 256) {
2020
2040
  throw new Error("[sendoracloud] screen name must be a 1-256 char string.");
2021
2041
  }
2042
+ this.engFlush();
2022
2043
  void this.sendEvent("screen", {
2023
2044
  eventType: "screen.viewed",
2024
2045
  properties: { screenName: name, ...properties ?? {} }
2025
2046
  });
2047
+ this.engEnter(name);
2048
+ }
2049
+ // --- Engagement timer (foreground-only, Wave 75) ---
2050
+ /** Bank the in-flight foreground segment into the accumulator + pause. */
2051
+ engBank() {
2052
+ if (this.engSegmentStart > 0) {
2053
+ this.engAccumMs += Date.now() - this.engSegmentStart;
2054
+ this.engSegmentStart = 0;
2055
+ }
2056
+ }
2057
+ /**
2058
+ * Emit `app.engagement` for the current screen with foreground-only
2059
+ * durationMs, then reset the accumulator. Sub-threshold (redirect
2060
+ * flicker) spans are dropped; absurd spans clamp to a 6h ceiling.
2061
+ * Never throws — engagement is best-effort instrumentation.
2062
+ */
2063
+ engFlush() {
2064
+ if (!this.engEnabled || this.engScreen === null) return;
2065
+ this.engBank();
2066
+ let ms = this.engAccumMs;
2067
+ this.engAccumMs = 0;
2068
+ if (ms < MIN_ENGAGEMENT_MS) return;
2069
+ if (ms > MAX_ENGAGEMENT_MS) ms = MAX_ENGAGEMENT_MS;
2070
+ try {
2071
+ void this.sendEvent("track", {
2072
+ eventType: "app.engagement",
2073
+ properties: { durationMs: Math.round(ms), screen: this.engScreen, sessionId: this.sessionId }
2074
+ });
2075
+ } catch {
2076
+ }
2077
+ }
2078
+ /** Start measuring a new screen (foreground from now). */
2079
+ engEnter(name) {
2080
+ if (!this.engEnabled) return;
2081
+ this.engScreen = name;
2082
+ this.engAccumMs = 0;
2083
+ this.engSegmentStart = Date.now();
2084
+ }
2085
+ /** Resume accumulation after the app returns to foreground. */
2086
+ engResume() {
2087
+ if (!this.engEnabled || this.engScreen === null) return;
2088
+ if (this.engSegmentStart === 0) this.engSegmentStart = Date.now();
2026
2089
  }
2027
2090
  /**
2028
2091
  * Register a device push token (APNs or FCM) so server-side
@@ -2270,6 +2333,22 @@ var SendoraSDK = class {
2270
2333
  const parsed = await res.json();
2271
2334
  if (!parsed.success || !parsed.data) throw new Error("[sendoracloud] support.submitCsat: bad response");
2272
2335
  return parsed.data;
2336
+ },
2337
+ getUnreadCount: async (widgetId) => {
2338
+ if (!self.config || !widgetId) return 0;
2339
+ const token = await self.auth.getAccessToken();
2340
+ if (!token) return 0;
2341
+ try {
2342
+ const res = await fetch(
2343
+ `${self.config.apiUrl}/api/v1/widgets/${encodeURIComponent(widgetId)}/my-tickets/unread-count`,
2344
+ { headers: { Authorization: `Bearer ${token}` } }
2345
+ );
2346
+ if (!res.ok) return 0;
2347
+ const parsed = await res.json();
2348
+ return parsed?.data?.count ?? 0;
2349
+ } catch {
2350
+ return 0;
2351
+ }
2273
2352
  }
2274
2353
  };
2275
2354
  }
@@ -2567,6 +2646,7 @@ var SendoraSDK = class {
2567
2646
  }
2568
2647
  /** Tear down auto-track listeners. Call in test harnesses. */
2569
2648
  destroy() {
2649
+ this.engFlush();
2570
2650
  if (this.autoTrackCleanup) {
2571
2651
  this.autoTrackCleanup();
2572
2652
  this.autoTrackCleanup = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendoracloud/sdk-react-native",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Sendora Cloud React Native + Expo SDK — analytics, identity, push-token registration, auth, deep links (Branch / Firebase Dynamic Links parity). Auth + analytics + deep links work in Expo Go; push token registration requires a Dev Client (EAS Build or `npx expo prebuild`).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",