@mushi-mushi/react-native 0.20.1 → 0.22.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
@@ -49,7 +49,8 @@ module.exports = __toCommonJS(index_exports);
49
49
 
50
50
  // src/provider.tsx
51
51
  var import_react4 = require("react");
52
- var import_core6 = require("@mushi-mushi/core");
52
+ var import_react_native5 = require("react-native");
53
+ var import_core8 = require("@mushi-mushi/core");
53
54
 
54
55
  // src/capture/console-capture.ts
55
56
  var import_core = require("@mushi-mushi/core");
@@ -543,6 +544,7 @@ var MushiBottomSheet = ({
543
544
  const [category, setCategory] = (0, import_react.useState)(null);
544
545
  const [description, setDescription] = (0, import_react.useState)("");
545
546
  const [phase, setPhase] = (0, import_react.useState)("form");
547
+ const [submitError, setSubmitError] = (0, import_react.useState)(null);
546
548
  const [sheetTab, setSheetTab] = (0, import_react.useState)("report");
547
549
  const [inboxReports, setInboxReports] = (0, import_react.useState)([]);
548
550
  const [inboxLoading, setInboxLoading] = (0, import_react.useState)(false);
@@ -616,6 +618,8 @@ var MushiBottomSheet = ({
616
618
  ...prev,
617
619
  { role: "bot", text: botText, options: reply.options }
618
620
  ]);
621
+ } catch {
622
+ setAssistantError("Could not reach the assistant. Try again.");
619
623
  } finally {
620
624
  setAssistantSending(false);
621
625
  }
@@ -689,24 +693,46 @@ var MushiBottomSheet = ({
689
693
  ).current;
690
694
  const handleSubmit = async () => {
691
695
  if (!category || !description.trim() || !mushi) return;
696
+ const trimmed = description.trim();
697
+ if (trimmed.length < 20) {
698
+ setSubmitError("Please write at least 20 characters so we can understand the issue.");
699
+ return;
700
+ }
701
+ setSubmitError(null);
692
702
  setPhase("sending");
693
703
  try {
694
- await mushi.submitReport({
704
+ const outcome = await mushi.submitReport({
695
705
  category,
696
- description: description.trim(),
706
+ description: trimmed,
697
707
  screenshotDataUrl: screenshotDataUrl && screenshotAttached ? screenshotDataUrl : void 0
698
708
  });
709
+ if (!outcome.ok) {
710
+ if (outcome.failureKind === "credentials" || outcome.failureKind === "quota") {
711
+ setSubmitError("Could not send \u2014 check the project API key or plan quota.");
712
+ setPhase("error");
713
+ return;
714
+ }
715
+ if (outcome.failureKind === "rate_limited") {
716
+ setSubmitError("Sending too fast \u2014 we queued this and will retry shortly.");
717
+ setPhase("error");
718
+ return;
719
+ }
720
+ setSubmitError("Queued for retry \u2014 we will send it when the connection is back.");
721
+ setPhase("error");
722
+ return;
723
+ }
699
724
  setPhase("sent");
700
725
  setTimeout(handleClose, 1400);
701
726
  } catch {
702
- setPhase("form");
727
+ setSubmitError("Something went wrong. Please try again.");
728
+ setPhase("error");
703
729
  }
704
730
  };
705
731
  const activeScreenshot = screenshotDataUrl && screenshotAttached ? screenshotDataUrl : null;
706
732
  const pal = (0, import_core3.mushiPalette)(dark ? "dark" : "light");
707
- const colors = dark ? { bg: pal.paper, text: pal.ink, sub: pal.inkMuted, card: pal.paperRaised, accent: import_core3.MUSHI_BANNER_NEON.bg, accentInk: import_core3.MUSHI_BANNER_NEON.fg, border: pal.ruleStrong, backdrop: "rgba(0,0,0,0.6)", disabled: "#3a3a3c", disabledText: pal.inkFaint } : { bg: pal.paperRaised, text: pal.ink, sub: pal.inkMuted, card: pal.paper, accent: import_core3.MUSHI_BANNER_NEON.bg, accentInk: import_core3.MUSHI_BANNER_NEON.fg, border: pal.ruleStrong, backdrop: "rgba(0,0,0,0.35)", disabled: "#d1d1d6", disabledText: pal.inkFaint };
733
+ const colors = dark ? { bg: pal.paper, text: pal.ink, sub: pal.inkMuted, card: pal.paperRaised, accent: import_core3.MUSHI_BANNER_NEON.bg, accentInk: import_core3.MUSHI_BANNER_NEON.fg, border: pal.ruleStrong, backdrop: "rgba(0,0,0,0.6)", disabled: import_core3.MUSHI_CONTROL_DISABLED.dark, disabledText: pal.inkFaint } : { bg: pal.paperRaised, text: pal.ink, sub: pal.inkMuted, card: pal.paper, accent: import_core3.MUSHI_BANNER_NEON.bg, accentInk: import_core3.MUSHI_BANNER_NEON.fg, border: pal.ruleStrong, backdrop: "rgba(0,0,0,0.35)", disabled: import_core3.MUSHI_CONTROL_DISABLED.light, disabledText: pal.inkFaint };
708
734
  const sheetTabs = ["report", "inbox", ...assistantEnabled ? ["assistant"] : []];
709
- const canSubmit = !!category && description.trim().length > 0 && phase === "form";
735
+ const canSubmit = !!category && description.trim().length >= 20 && (phase === "form" || phase === "error");
710
736
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Modal, { visible, transparent: true, animationType: "none", statusBarTranslucent: true, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
711
737
  import_react_native2.KeyboardAvoidingView,
712
738
  {
@@ -734,7 +760,7 @@ var MushiBottomSheet = ({
734
760
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.brandEyebrow, { color: import_core3.MUSHI_BANNER_NEON.fg }], children: "MUSHI \xB7 BETA" }),
735
761
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.brandTitle, { color: import_core3.MUSHI_BANNER_NEON.fg }], children: sheetTab === "assistant" ? assistantLabel : sheetTab === "inbox" ? t.flows.reports.title : t.widget.title })
736
762
  ] }),
737
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.View, { style: s.tabRow, children: sheetTabs.map((tab) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
763
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.View, { style: [s.tabRow, { borderBottomColor: colors.border }], children: sheetTabs.map((tab) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
738
764
  import_react_native2.TouchableOpacity,
739
765
  {
740
766
  onPress: () => {
@@ -887,16 +913,22 @@ var MushiBottomSheet = ({
887
913
  multiline: true,
888
914
  textAlignVertical: "top",
889
915
  value: description,
890
- onChangeText: setDescription,
891
- editable: phase === "form"
916
+ onChangeText: (text) => {
917
+ setDescription(text.slice(0, 4e3));
918
+ if (submitError) setSubmitError(null);
919
+ if (phase === "error") setPhase("form");
920
+ },
921
+ editable: phase === "form" || phase === "error",
922
+ maxLength: 4e3
892
923
  }
893
924
  ),
925
+ submitError ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: { color: colors.accent, marginTop: 6, fontSize: 12 }, children: submitError }) : null,
894
926
  activeScreenshot ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.View, { style: s.screenshotRow, children: [
895
927
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
896
928
  import_react_native2.Image,
897
929
  {
898
930
  source: { uri: activeScreenshot },
899
- style: s.screenshotThumb,
931
+ style: [s.screenshotThumb, { backgroundColor: colors.text }],
900
932
  accessibilityLabel: t.step3.screenshotPreviewAlt
901
933
  }
902
934
  ),
@@ -957,10 +989,10 @@ var s = import_react_native2.StyleSheet.create({
957
989
  left: 0,
958
990
  right: 0,
959
991
  minHeight: SHEET_HEIGHT,
960
- borderTopLeftRadius: 20,
961
- borderTopRightRadius: 20,
962
- paddingBottom: import_react_native2.Platform.OS === "ios" ? 34 : 16,
963
- shadowColor: "#000",
992
+ borderTopLeftRadius: import_core3.MUSHI_RADIUS.sheet,
993
+ borderTopRightRadius: import_core3.MUSHI_RADIUS.sheet,
994
+ paddingBottom: import_react_native2.Platform.OS === "ios" ? 34 : import_core3.MUSHI_SPACING.roomy,
995
+ shadowColor: import_core3.MUSHI_SHADOW_INK,
964
996
  shadowOffset: { width: 0, height: -3 },
965
997
  shadowOpacity: 0.12,
966
998
  shadowRadius: 8,
@@ -968,22 +1000,22 @@ var s = import_react_native2.StyleSheet.create({
968
1000
  },
969
1001
  handleArea: {
970
1002
  alignItems: "center",
971
- paddingVertical: 10
1003
+ paddingVertical: import_core3.MUSHI_SPACING.comfy
972
1004
  },
973
1005
  handle: {
974
1006
  width: 40,
975
1007
  height: 5,
976
- borderRadius: 3,
1008
+ borderRadius: import_core3.MUSHI_RADIUS.control,
977
1009
  opacity: 0.5
978
1010
  },
979
1011
  brandHeader: {
980
- paddingHorizontal: 20,
981
- paddingTop: 10,
982
- paddingBottom: 12,
1012
+ paddingHorizontal: import_core3.MUSHI_SPACING.lounge,
1013
+ paddingTop: import_core3.MUSHI_SPACING.comfy,
1014
+ paddingBottom: import_core3.MUSHI_SPACING.comfy,
983
1015
  borderBottomWidth: 1.5
984
1016
  },
985
1017
  brandEyebrow: {
986
- fontSize: 10,
1018
+ fontSize: import_core3.MUSHI_TYPE.sizeLabel,
987
1019
  fontWeight: "700",
988
1020
  letterSpacing: 1.2,
989
1021
  marginBottom: 2,
@@ -995,59 +1027,61 @@ var s = import_react_native2.StyleSheet.create({
995
1027
  letterSpacing: -0.2
996
1028
  },
997
1029
  body: {
998
- paddingHorizontal: 20,
999
- paddingTop: 4
1030
+ paddingHorizontal: import_core3.MUSHI_SPACING.lounge,
1031
+ paddingTop: import_core3.MUSHI_SPACING.tight
1000
1032
  },
1001
1033
  title: {
1002
1034
  fontSize: 20,
1003
1035
  fontWeight: "700",
1004
- marginBottom: 16
1036
+ marginBottom: import_core3.MUSHI_SPACING.roomy
1005
1037
  },
1006
1038
  stepLabel: {
1007
- fontSize: 12,
1039
+ fontSize: import_core3.MUSHI_TYPE.sizeLabel,
1008
1040
  fontWeight: "600",
1009
1041
  letterSpacing: 0.2,
1010
- marginTop: 8,
1011
- marginBottom: 10,
1042
+ marginTop: import_core3.MUSHI_SPACING.snug,
1043
+ marginBottom: import_core3.MUSHI_SPACING.comfy,
1012
1044
  textTransform: "uppercase"
1013
1045
  },
1014
1046
  catRow: {
1015
1047
  flexDirection: "row",
1016
1048
  justifyContent: "space-between",
1017
- marginBottom: 16
1049
+ marginBottom: import_core3.MUSHI_SPACING.roomy
1018
1050
  },
1019
1051
  catBtn: {
1020
1052
  alignItems: "center",
1021
- paddingVertical: 10,
1053
+ paddingVertical: import_core3.MUSHI_SPACING.comfy,
1022
1054
  paddingHorizontal: 6,
1023
- borderRadius: 12,
1055
+ borderRadius: import_core3.MUSHI_RADIUS.card,
1024
1056
  borderWidth: 1,
1025
1057
  flex: 1,
1026
- marginHorizontal: 3
1058
+ marginHorizontal: 3,
1059
+ minHeight: 44
1027
1060
  },
1028
1061
  catEmoji: {
1029
1062
  fontSize: 22,
1030
- marginBottom: 4
1063
+ marginBottom: import_core3.MUSHI_SPACING.tight
1031
1064
  },
1032
1065
  catLabel: {
1033
- fontSize: 11,
1066
+ fontSize: import_core3.MUSHI_TYPE.sizeLabel,
1034
1067
  fontWeight: "600"
1035
1068
  },
1036
1069
  input: {
1037
1070
  borderWidth: 1,
1038
- borderRadius: 12,
1071
+ borderRadius: import_core3.MUSHI_RADIUS.card,
1039
1072
  padding: 14,
1040
1073
  fontSize: 15,
1041
1074
  minHeight: 100,
1042
- marginBottom: 16
1075
+ marginBottom: import_core3.MUSHI_SPACING.roomy
1043
1076
  },
1044
1077
  submitBtn: {
1045
- borderRadius: 12,
1078
+ borderRadius: import_core3.MUSHI_RADIUS.card,
1046
1079
  paddingVertical: 14,
1047
- alignItems: "center"
1080
+ alignItems: "center",
1081
+ minHeight: 44
1048
1082
  },
1049
1083
  submitText: {
1050
- color: "#fff",
1084
+ color: import_core3.MUSHI_INVERSE,
1051
1085
  fontSize: 16,
1052
1086
  fontWeight: "700"
1053
1087
  },
@@ -1078,7 +1112,6 @@ var s = import_react_native2.StyleSheet.create({
1078
1112
  width: 48,
1079
1113
  height: 36,
1080
1114
  borderRadius: 4,
1081
- backgroundColor: "#000",
1082
1115
  flexShrink: 0
1083
1116
  },
1084
1117
  screenshotMeta: {
@@ -1100,7 +1133,6 @@ var s = import_react_native2.StyleSheet.create({
1100
1133
  tabRow: {
1101
1134
  flexDirection: "row",
1102
1135
  borderBottomWidth: import_react_native2.StyleSheet.hairlineWidth,
1103
- borderBottomColor: "#ccc",
1104
1136
  marginBottom: 8
1105
1137
  },
1106
1138
  tabBtn: {
@@ -1212,7 +1244,8 @@ var MushiFloatingButton = ({
1212
1244
  const currentX = (0, import_react2.useRef)(baseX);
1213
1245
  const currentY = (0, import_react2.useRef)(0);
1214
1246
  const isDragging = (0, import_react2.useRef)(false);
1215
- const accentColor = accent ?? (0, import_core4.mushiPalette)(dark ? "dark" : "light").accent;
1247
+ const pal = (0, import_core4.mushiPalette)(dark ? "dark" : "light");
1248
+ const accentColor = accent ?? pal.accent;
1216
1249
  (0, import_react2.useEffect)(() => {
1217
1250
  if (!persist) return;
1218
1251
  void loadStoredPos().then((pos) => {
@@ -1315,7 +1348,7 @@ var MushiFloatingButton = ({
1315
1348
  left: posX,
1316
1349
  bottom: import_react_native3.Animated.add(new import_react_native3.Animated.Value(baseBottom), import_react_native3.Animated.multiply(posY, -1)),
1317
1350
  backgroundColor: accentColor,
1318
- shadowColor: dark ? accentColor : "#000",
1351
+ shadowColor: dark ? accentColor : pal.ink,
1319
1352
  transform: [{ scale }]
1320
1353
  }
1321
1354
  ],
@@ -1404,7 +1437,7 @@ var s3 = import_react_native4.StyleSheet.create({
1404
1437
  backgroundColor: import_core5.MUSHI_BANNER_NEON.bg,
1405
1438
  borderTopWidth: 1,
1406
1439
  borderTopColor: import_core5.MUSHI_BANNER_NEON.border,
1407
- zIndex: 99998,
1440
+ zIndex: import_core5.MUSHI_Z.banner,
1408
1441
  elevation: 8,
1409
1442
  paddingBottom: import_react_native4.Platform.OS === "ios" ? 0 : 0
1410
1443
  },
@@ -1412,12 +1445,13 @@ var s3 = import_react_native4.StyleSheet.create({
1412
1445
  flex: 1,
1413
1446
  flexDirection: "row",
1414
1447
  alignItems: "center",
1415
- gap: 8,
1416
- paddingHorizontal: 12,
1417
- paddingVertical: 8
1448
+ gap: import_core5.MUSHI_SPACING.snug,
1449
+ paddingHorizontal: import_core5.MUSHI_SPACING.comfy,
1450
+ paddingVertical: import_core5.MUSHI_SPACING.snug,
1451
+ minHeight: 44
1418
1452
  },
1419
1453
  eyebrow: {
1420
- fontSize: 10,
1454
+ fontSize: import_core5.MUSHI_TYPE.sizeLabel,
1421
1455
  fontWeight: "700",
1422
1456
  letterSpacing: 1.2,
1423
1457
  color: import_core5.MUSHI_BANNER_NEON.fg
@@ -1446,13 +1480,336 @@ var s3 = import_react_native4.StyleSheet.create({
1446
1480
  }
1447
1481
  });
1448
1482
 
1483
+ // src/analytics/event-tracker.ts
1484
+ var import_core6 = require("@mushi-mushi/core");
1485
+ var CONSENT_BUFFER_MAX = 50;
1486
+ var SPILL_MAX = 200;
1487
+ var SPILL_TTL_MS = 24 * 60 * 60 * 1e3;
1488
+ var SURFACE = "mobile";
1489
+ async function defaultStorage() {
1490
+ try {
1491
+ const mod = await import("@react-native-async-storage/async-storage");
1492
+ return mod.default;
1493
+ } catch {
1494
+ return null;
1495
+ }
1496
+ }
1497
+ function createRNEventTracker(opts) {
1498
+ const cfg = opts.config ?? {};
1499
+ const enabled = cfg.enabled !== false;
1500
+ const consentMode = cfg.consent ?? "implied";
1501
+ const allowlist = cfg.propertyAllowlist ?? [];
1502
+ const spillKey = `@mushi:events_spill_${opts.projectId}`;
1503
+ const consentKey = `@mushi:analytics_consent_${opts.projectId}`;
1504
+ let buffer = [];
1505
+ let consentBuffer = [];
1506
+ let consent = consentMode === "required" ? "pending" : "granted";
1507
+ let userId = null;
1508
+ let userTraits = null;
1509
+ let pendingIdentify = false;
1510
+ let flushing = false;
1511
+ let destroyed = false;
1512
+ let timer = null;
1513
+ let storage = null;
1514
+ const consentListeners = /* @__PURE__ */ new Set();
1515
+ const loadStorage = opts.storage ?? defaultStorage;
1516
+ const resolveClient = () => typeof opts.client === "function" ? opts.client() : opts.client;
1517
+ async function readSpill() {
1518
+ if (!storage) return [];
1519
+ try {
1520
+ const raw = await storage.getItem(spillKey);
1521
+ if (!raw) return [];
1522
+ const parsed = JSON.parse(raw);
1523
+ if (!parsed || Date.now() - parsed.at > SPILL_TTL_MS) {
1524
+ await storage.removeItem(spillKey);
1525
+ return [];
1526
+ }
1527
+ return Array.isArray(parsed.events) ? parsed.events.slice(0, SPILL_MAX) : [];
1528
+ } catch {
1529
+ return [];
1530
+ }
1531
+ }
1532
+ async function writeSpill(events) {
1533
+ if (!storage) return;
1534
+ try {
1535
+ if (events.length === 0) await storage.removeItem(spillKey);
1536
+ else await storage.setItem(spillKey, JSON.stringify({ at: Date.now(), events: events.slice(-SPILL_MAX) }));
1537
+ } catch {
1538
+ }
1539
+ }
1540
+ async function flushNow() {
1541
+ if (destroyed && buffer.length === 0) return;
1542
+ await ready;
1543
+ if (flushing || consent !== "granted") return;
1544
+ if (buffer.length === 0 && !pendingIdentify) return;
1545
+ const client = resolveClient();
1546
+ if (!client) return;
1547
+ flushing = true;
1548
+ const batch = buffer.splice(0, import_core6.EVENT_PROPERTY_LIMITS.maxServerBatch);
1549
+ if (pendingIdentify) {
1550
+ batch.unshift({ name: "identify", ts: (/* @__PURE__ */ new Date()).toISOString(), properties: {} });
1551
+ pendingIdentify = false;
1552
+ }
1553
+ try {
1554
+ const anonId = await Promise.resolve(opts.getAnonId());
1555
+ const payload = {
1556
+ anon_id: anonId,
1557
+ user_id: userId ?? opts.getUserId?.() ?? null,
1558
+ user_traits: userTraits ?? opts.getUserTraits?.() ?? null,
1559
+ session_id: null,
1560
+ sdk_version: opts.sdkVersion ?? null,
1561
+ surface: SURFACE,
1562
+ events: batch
1563
+ };
1564
+ const res = await client.postProductEvents(payload);
1565
+ if (!res.ok) await writeSpill([...await readSpill(), ...batch.filter((e) => e.name !== "identify")]);
1566
+ else await writeSpill([]);
1567
+ } catch {
1568
+ await writeSpill([...await readSpill(), ...batch.filter((e) => e.name !== "identify")]);
1569
+ } finally {
1570
+ flushing = false;
1571
+ }
1572
+ if (buffer.length >= import_core6.EVENT_PROPERTY_LIMITS.maxClientBatch) void flushNow();
1573
+ }
1574
+ function enqueue(ev) {
1575
+ if (consent === "denied") return;
1576
+ if (consent === "pending") {
1577
+ if (consentBuffer.length < CONSENT_BUFFER_MAX) consentBuffer.push(ev);
1578
+ return;
1579
+ }
1580
+ buffer.push(ev);
1581
+ if (buffer.length >= import_core6.EVENT_PROPERTY_LIMITS.maxClientBatch) void flushNow();
1582
+ }
1583
+ const ready = enabled ? (async () => {
1584
+ storage = await loadStorage();
1585
+ if (destroyed) return;
1586
+ let stored = null;
1587
+ try {
1588
+ stored = storage ? await storage.getItem(consentKey) : null;
1589
+ } catch {
1590
+ stored = null;
1591
+ }
1592
+ if (stored === "denied") {
1593
+ consent = "denied";
1594
+ buffer = [];
1595
+ consentBuffer = [];
1596
+ pendingIdentify = false;
1597
+ await writeSpill([]);
1598
+ return;
1599
+ }
1600
+ if (stored === "granted" && consent === "pending") {
1601
+ consent = "granted";
1602
+ buffer.push(...consentBuffer);
1603
+ consentBuffer = [];
1604
+ }
1605
+ const spill = await readSpill();
1606
+ if (spill.length > 0) {
1607
+ buffer.unshift(...spill);
1608
+ await writeSpill([]);
1609
+ }
1610
+ })().catch(() => void 0) : Promise.resolve();
1611
+ if (enabled) {
1612
+ const interval = Math.max(1e3, cfg.flushIntervalMs ?? 5e3);
1613
+ timer = setInterval(() => {
1614
+ void flushNow();
1615
+ }, interval);
1616
+ }
1617
+ return {
1618
+ ready,
1619
+ track(name, properties, trackOpts) {
1620
+ if (!enabled || destroyed) return false;
1621
+ if (!(0, import_core6.isValidEventName)(name)) return false;
1622
+ const { properties: props } = (0, import_core6.sanitizeEventProperties)(properties, { allowlist, scrub: opts.scrub });
1623
+ let merged = props;
1624
+ if (trackOpts?.reserved) {
1625
+ const { properties: res } = (0, import_core6.sanitizeEventProperties)(trackOpts.reserved, { allowReserved: true, scrub: opts.scrub });
1626
+ merged = { ...props, ...res };
1627
+ }
1628
+ merged.$surface = SURFACE;
1629
+ const route = opts.getRoute?.();
1630
+ if (route && merged.$route === void 0) merged.$route = route;
1631
+ if (!(0, import_core6.propertiesWithinByteLimit)(merged)) return false;
1632
+ enqueue({
1633
+ name,
1634
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1635
+ properties: merged,
1636
+ ...trackOpts?.dedupKey ? { dedup_key: trackOpts.dedupKey } : {}
1637
+ });
1638
+ return true;
1639
+ },
1640
+ setConsent(state) {
1641
+ void ready.then(() => storage?.setItem(consentKey, state)).catch(() => void 0);
1642
+ if (state === "denied") {
1643
+ consent = "denied";
1644
+ buffer = [];
1645
+ consentBuffer = [];
1646
+ pendingIdentify = false;
1647
+ void writeSpill([]);
1648
+ } else {
1649
+ const wasPending = consent === "pending";
1650
+ consent = "granted";
1651
+ if (wasPending && consentBuffer.length > 0) {
1652
+ buffer.push(...consentBuffer);
1653
+ consentBuffer = [];
1654
+ }
1655
+ if (buffer.length > 0 || pendingIdentify) void flushNow();
1656
+ }
1657
+ for (const listener of [...consentListeners]) {
1658
+ try {
1659
+ listener(state);
1660
+ } catch {
1661
+ }
1662
+ }
1663
+ },
1664
+ setIdentity(id, traits) {
1665
+ userId = id;
1666
+ userTraits = traits ?? null;
1667
+ if (id && enabled && consent !== "denied") {
1668
+ pendingIdentify = true;
1669
+ void flushNow();
1670
+ }
1671
+ },
1672
+ consentState() {
1673
+ return enabled ? consent : "denied";
1674
+ },
1675
+ onConsentChange(listener) {
1676
+ consentListeners.add(listener);
1677
+ return () => {
1678
+ consentListeners.delete(listener);
1679
+ };
1680
+ },
1681
+ async flush() {
1682
+ await ready;
1683
+ await flushNow();
1684
+ },
1685
+ async destroy() {
1686
+ if (timer != null) {
1687
+ clearInterval(timer);
1688
+ timer = null;
1689
+ }
1690
+ await flushNow().catch(() => void 0);
1691
+ destroyed = true;
1692
+ buffer = [];
1693
+ consentBuffer = [];
1694
+ pendingIdentify = false;
1695
+ }
1696
+ };
1697
+ }
1698
+
1699
+ // src/analytics/session-tracker.ts
1700
+ var import_core7 = require("@mushi-mushi/core");
1701
+ var HEARTBEAT_INTERVAL_MS = 6e4;
1702
+ var RESUME_WINDOW_MS = 30 * 6e4;
1703
+ function createRNSessionTracker(opts) {
1704
+ const clock = opts.now ?? Date.now;
1705
+ const resolveClient = () => typeof opts.client === "function" ? opts.client() : opts.client;
1706
+ let sessionId = opts.sessionId ?? `sess_${(0, import_core7.newUuid)()}`;
1707
+ let pageViewCount = 0;
1708
+ let started = false;
1709
+ let allowed = false;
1710
+ let foreground = true;
1711
+ let destroyed = false;
1712
+ let backgroundedAt = null;
1713
+ let heartbeat = null;
1714
+ function route() {
1715
+ return opts.getRoute?.() ?? null;
1716
+ }
1717
+ function send(kind, extra) {
1718
+ const client = resolveClient();
1719
+ if (!client || !allowed || destroyed) return;
1720
+ const base = {
1721
+ kind,
1722
+ session_id: sessionId,
1723
+ ts: new Date(clock()).toISOString(),
1724
+ page_view_count: pageViewCount,
1725
+ user_agent: opts.userAgent ?? null,
1726
+ sdk_version: opts.sdkVersion
1727
+ };
1728
+ void Promise.resolve(opts.getReporterToken()).then((token) => client.postSessionEvent({ ...base, reporter_token_hash: token, ...extra })).catch(() => void 0);
1729
+ }
1730
+ function stopHeartbeat() {
1731
+ if (heartbeat != null) {
1732
+ clearInterval(heartbeat);
1733
+ heartbeat = null;
1734
+ }
1735
+ }
1736
+ function startHeartbeat() {
1737
+ stopHeartbeat();
1738
+ heartbeat = setInterval(() => send("session_heartbeat", { route: route() }), HEARTBEAT_INTERVAL_MS);
1739
+ }
1740
+ function startSession() {
1741
+ started = true;
1742
+ pageViewCount = 1;
1743
+ send("session_start", { route: route() });
1744
+ startHeartbeat();
1745
+ }
1746
+ function activate() {
1747
+ if (allowed || destroyed) return;
1748
+ allowed = true;
1749
+ if (!foreground) return;
1750
+ if (!started) startSession();
1751
+ else startHeartbeat();
1752
+ }
1753
+ function deactivate() {
1754
+ allowed = false;
1755
+ stopHeartbeat();
1756
+ }
1757
+ const unsubscribe = opts.consent.onConsentChange(() => {
1758
+ if (opts.consent.consentState() === "granted") activate();
1759
+ else deactivate();
1760
+ });
1761
+ void opts.consent.ready.then(() => {
1762
+ if (opts.consent.consentState() === "granted") activate();
1763
+ }).catch(() => void 0);
1764
+ return {
1765
+ onAppStateChange(state) {
1766
+ if (destroyed) return;
1767
+ if (state === "active") {
1768
+ if (foreground) return;
1769
+ foreground = true;
1770
+ const away = backgroundedAt == null ? 0 : clock() - backgroundedAt;
1771
+ backgroundedAt = null;
1772
+ if (!allowed) return;
1773
+ if (!started || away >= RESUME_WINDOW_MS) {
1774
+ sessionId = `sess_${(0, import_core7.newUuid)()}`;
1775
+ startSession();
1776
+ } else {
1777
+ send("session_heartbeat", { route: route() });
1778
+ startHeartbeat();
1779
+ }
1780
+ return;
1781
+ }
1782
+ if (state !== "background" || !foreground) return;
1783
+ foreground = false;
1784
+ backgroundedAt = clock();
1785
+ stopHeartbeat();
1786
+ if (started) send("session_end", { route: route() });
1787
+ },
1788
+ pageView(next) {
1789
+ if (!allowed || !started || !foreground) return;
1790
+ pageViewCount += 1;
1791
+ send("page_view", { route: next });
1792
+ },
1793
+ destroy() {
1794
+ destroyed = true;
1795
+ deactivate();
1796
+ unsubscribe();
1797
+ }
1798
+ };
1799
+ }
1800
+
1449
1801
  // src/version.ts
1450
1802
  var MUSHI_SDK_PACKAGE = "@mushi-mushi/react-native";
1451
- var MUSHI_SDK_VERSION = "0.20.1" ? "0.20.1" : "0.17.0";
1803
+ var MUSHI_SDK_VERSION = "0.22.0" ? "0.22.0" : "0.17.0";
1452
1804
 
1453
1805
  // src/provider.tsx
1454
1806
  var import_jsx_runtime4 = require("react/jsx-runtime");
1455
1807
  var MushiContext = (0, import_react4.createContext)(null);
1808
+ function nativeUserAgent() {
1809
+ const os = import_react_native5.Platform?.OS ?? "native";
1810
+ const version = import_react_native5.Platform?.Version;
1811
+ return `${MUSHI_SDK_PACKAGE}/${MUSHI_SDK_VERSION} (${version != null ? `${os} ${String(version)}` : os})`;
1812
+ }
1456
1813
  var DEFAULT_SCREENSHOT_HINT = "Check the preview \u2014 remove it if any private info (balances, personal details) is visible.";
1457
1814
  function resolveScreenshotHint(v) {
1458
1815
  if (v === false) return null;
@@ -1495,9 +1852,9 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1495
1852
  const networkRef = (0, import_react4.useRef)(null);
1496
1853
  const queueRef = (0, import_react4.useRef)(null);
1497
1854
  const apiClientRef = (0, import_react4.useRef)(null);
1498
- const sessionIdRef = (0, import_react4.useRef)(`sess_${(0, import_core6.newUuid)()}`);
1855
+ const sessionIdRef = (0, import_react4.useRef)(`sess_${(0, import_core8.newUuid)()}`);
1499
1856
  const breadcrumbsRef = (0, import_react4.useRef)(
1500
- (0, import_core6.createBreadcrumbBuffer)({ max: config.capture?.maxBreadcrumbs ?? 50 })
1857
+ (0, import_core8.createBreadcrumbBuffer)({ max: config.capture?.maxBreadcrumbs ?? 50 })
1501
1858
  );
1502
1859
  const [sheetVisible, setSheetVisible] = (0, import_react4.useState)(false);
1503
1860
  const [sheetPreferredTab, setSheetPreferredTab] = (0, import_react4.useState)("report");
@@ -1522,7 +1879,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1522
1879
  if (config.endpoint && !config.apiEndpoint && __DEV__) {
1523
1880
  console.warn("[MushiProvider] `endpoint` is deprecated \u2014 use `apiEndpoint` instead.");
1524
1881
  }
1525
- const apiEndpoint = rawEndpoint ?? import_core6.DEFAULT_API_ENDPOINT;
1882
+ const apiEndpoint = rawEndpoint ?? import_core8.DEFAULT_API_ENDPOINT;
1526
1883
  (0, import_react4.useEffect)(() => {
1527
1884
  if (config.capture?.console !== false) {
1528
1885
  consoleRef.current = setupConsoleCapture(config.capture?.maxConsoleEntries);
@@ -1530,7 +1887,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1530
1887
  if (config.capture?.network !== false) {
1531
1888
  networkRef.current = setupNetworkCapture(config.capture?.maxNetworkEntries, apiEndpoint);
1532
1889
  }
1533
- apiClientRef.current = (0, import_core6.createApiClient)({
1890
+ apiClientRef.current = (0, import_core8.createApiClient)({
1534
1891
  projectId: config.projectId,
1535
1892
  apiKey: config.apiKey,
1536
1893
  apiEndpoint,
@@ -1567,14 +1924,14 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1567
1924
  if (existing && existing.startsWith("mushi_")) {
1568
1925
  reporterTokenRef.current = existing;
1569
1926
  } else {
1570
- const fresh = `mushi_${(0, import_core6.newUuid)()}`;
1927
+ const fresh = `mushi_${(0, import_core8.newUuid)()}`;
1571
1928
  reporterTokenRef.current = fresh;
1572
1929
  await saveReporterToken(fresh, secureStorage);
1573
1930
  }
1574
1931
  resolveReporterTokenReady();
1575
1932
  } catch {
1576
1933
  if (reporterTokenRef.current === `rn-${config.projectId}-anon`) {
1577
- reporterTokenRef.current = `mushi_${(0, import_core6.newUuid)()}`;
1934
+ reporterTokenRef.current = `mushi_${(0, import_core8.newUuid)()}`;
1578
1935
  }
1579
1936
  resolveReporterTokenReady();
1580
1937
  }
@@ -1694,7 +2051,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1694
2051
  const merged = Object.keys(metadataRef.current).length > 0 ? { ...contextMeta, ...metadataRef.current } : Object.keys(contextMeta).length > 0 ? contextMeta : void 0;
1695
2052
  const breadcrumbs = breadcrumbsRef.current.getAll().map((c) => ({
1696
2053
  ...c,
1697
- message: (0, import_core6.scrubPii)(c.message)
2054
+ message: (0, import_core8.scrubPii)(c.message)
1698
2055
  }));
1699
2056
  const timeline = breadcrumbs.map((c) => ({
1700
2057
  ts: c.timestamp,
@@ -1703,10 +2060,10 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1703
2060
  }));
1704
2061
  const fingerprintHash = getDeviceFingerprintHash(deviceInfo);
1705
2062
  const report = {
1706
- id: (0, import_core6.newUuid)(),
2063
+ id: (0, import_core8.newUuid)(),
1707
2064
  projectId: config.projectId,
1708
2065
  category: data.category,
1709
- description: (0, import_core6.scrubPii)(data.description),
2066
+ description: (0, import_core8.scrubPii)(data.description),
1710
2067
  environment: {
1711
2068
  userAgent: deviceInfo.systemName ?? "ReactNative",
1712
2069
  platform: deviceInfo.platform ?? "mobile",
@@ -1719,7 +2076,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1719
2076
  },
1720
2077
  consoleLogs: (consoleRef.current?.getEntries() ?? []).map((entry) => ({
1721
2078
  ...entry,
1722
- message: (0, import_core6.scrubPii)(entry.message)
2079
+ message: (0, import_core8.scrubPii)(entry.message)
1723
2080
  })),
1724
2081
  networkLogs: networkRef.current?.getEntries().map((entry) => ({
1725
2082
  ...entry,
@@ -1738,21 +2095,34 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1738
2095
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1739
2096
  };
1740
2097
  const client = apiClientRef.current;
1741
- if (!client) return;
2098
+ if (!client) {
2099
+ console.error(
2100
+ "[Mushi] submitReport called before the SDK finished initializing. Ensure <MushiProvider> is mounted with a valid projectId and apiKey."
2101
+ );
2102
+ return { ok: false, failureKind: "permanent" };
2103
+ }
1742
2104
  const result = await client.submitReport(report);
1743
2105
  if (!result.ok) {
1744
- const isCredentialError = result.error?.code === "HTTP_401" || result.error?.code === "HTTP_403" || result.error?.code?.includes("UNAUTHORIZED") || result.error?.code?.includes("FORBIDDEN");
2106
+ const code = result.error?.code ?? "";
2107
+ const isCredentialError = code === "HTTP_401" || code === "HTTP_403" || code.includes("UNAUTHORIZED") || code.includes("FORBIDDEN") || code.includes("INVALID_API_KEY");
1745
2108
  if (isCredentialError) {
1746
- const where = apiEndpoint.replace(/\/$/, "") === import_core6.DEFAULT_API_ENDPOINT ? "Get the correct values at: https://kensaur.us/mushi-mushi/admin/projects" : "Get the correct values from your Mushi console's Projects page.";
2109
+ const where = apiEndpoint.replace(/\/$/, "") === import_core8.DEFAULT_API_ENDPOINT ? "Get the correct values at: https://kensaur.us/mushi-mushi/admin/projects" : "Get the correct values from your Mushi console's Projects page.";
1747
2110
  console.error(
1748
2111
  '[Mushi] Credentials rejected. Check your projectId and apiKey (must have "report:write" scope). ' + where
1749
2112
  );
1750
- return;
2113
+ return { ok: false, failureKind: "credentials" };
1751
2114
  }
2115
+ const isRateLimited = code === "HTTP_429" || code.includes("RATE_LIMIT");
1752
2116
  await queueRef.current?.enqueue(report);
1753
2117
  config.callbacks?.onReportQueued?.(report.id);
2118
+ return {
2119
+ ok: false,
2120
+ queued: true,
2121
+ failureKind: isRateLimited ? "rate_limited" : "retrying"
2122
+ };
1754
2123
  } else {
1755
2124
  config.callbacks?.onReportSubmitted?.(report.id);
2125
+ return { ok: true };
1756
2126
  }
1757
2127
  },
1758
2128
  [config.projectId, config.callbacks, apiEndpoint]
@@ -1782,6 +2152,63 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1782
2152
  if (rewardsFlushRef.current) clearInterval(rewardsFlushRef.current);
1783
2153
  };
1784
2154
  }, [config.rewards?.enabled, config.rewards?.trackActivity, config.rewards?.flushIntervalMs]);
2155
+ const eventTrackerRef = (0, import_react4.useRef)(null);
2156
+ const sessionTrackerRef = (0, import_react4.useRef)(null);
2157
+ (0, import_react4.useEffect)(() => {
2158
+ const getRoute = () => screenRef.current?.route ?? screenRef.current?.name ?? null;
2159
+ const tracker = createRNEventTracker({
2160
+ projectId: config.projectId,
2161
+ client: () => apiClientRef.current,
2162
+ getAnonId: async () => {
2163
+ await reporterTokenReadyRef.current;
2164
+ return reporterTokenRef.current;
2165
+ },
2166
+ getUserId: () => userRef.current?.id ?? null,
2167
+ getRoute,
2168
+ sdkVersion: MUSHI_SDK_VERSION,
2169
+ config: config.analytics,
2170
+ scrub: import_core8.scrubPii
2171
+ });
2172
+ eventTrackerRef.current = tracker;
2173
+ const sessions = config.trackSessions === false ? null : createRNSessionTracker({
2174
+ client: () => apiClientRef.current,
2175
+ getReporterToken: async () => {
2176
+ await reporterTokenReadyRef.current;
2177
+ return reporterTokenRef.current;
2178
+ },
2179
+ getRoute,
2180
+ consent: tracker,
2181
+ sessionId: sessionIdRef.current,
2182
+ sdkVersion: MUSHI_SDK_VERSION,
2183
+ userAgent: nativeUserAgent()
2184
+ });
2185
+ sessionTrackerRef.current = sessions;
2186
+ let sub;
2187
+ try {
2188
+ if (typeof import_react_native5.AppState?.addEventListener === "function") {
2189
+ sub = import_react_native5.AppState.addEventListener("change", (state) => {
2190
+ sessions?.onAppStateChange(state);
2191
+ if (state !== "active") tracker.flush().catch(() => {
2192
+ });
2193
+ });
2194
+ }
2195
+ } catch {
2196
+ }
2197
+ return () => {
2198
+ sub?.remove();
2199
+ sessions?.destroy();
2200
+ sessionTrackerRef.current = null;
2201
+ eventTrackerRef.current = null;
2202
+ tracker.destroy().catch(() => {
2203
+ });
2204
+ };
2205
+ }, [
2206
+ config.projectId,
2207
+ config.analytics?.enabled,
2208
+ config.analytics?.consent,
2209
+ config.analytics?.flushIntervalMs,
2210
+ config.trackSessions
2211
+ ]);
1785
2212
  const instance = (0, import_react4.useMemo)(
1786
2213
  () => ({
1787
2214
  open,
@@ -1799,6 +2226,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1799
2226
  name: traits?.name,
1800
2227
  provider: traits?.provider
1801
2228
  };
2229
+ eventTrackerRef.current?.setIdentity(userId, traits ?? null);
1802
2230
  },
1803
2231
  setUser(user) {
1804
2232
  userRef.current = {
@@ -1807,18 +2235,22 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1807
2235
  name: user.name,
1808
2236
  provider: user.provider
1809
2237
  };
2238
+ eventTrackerRef.current?.setIdentity(user.id, { email: user.email, name: user.name, provider: user.provider });
1810
2239
  },
1811
2240
  identifyWithToken(token) {
1812
2241
  userTokenRef.current = token && typeof token === "string" ? token : null;
1813
2242
  if (userTokenRef.current) {
1814
- const claims = (0, import_core6.parseIdentityToken)(userTokenRef.current);
2243
+ const claims = (0, import_core8.parseIdentityToken)(userTokenRef.current);
1815
2244
  if (claims?.sub) {
1816
2245
  userRef.current = {
1817
2246
  id: claims.sub,
1818
2247
  ...claims.email ? { email: claims.email } : {},
1819
2248
  ...claims.name ? { name: claims.name } : {}
1820
2249
  };
2250
+ eventTrackerRef.current?.setIdentity(claims.sub, null);
1821
2251
  }
2252
+ } else {
2253
+ eventTrackerRef.current?.setIdentity(null, null);
1822
2254
  }
1823
2255
  },
1824
2256
  publishPageContext(context) {
@@ -1831,6 +2263,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1831
2263
  const prev = screenRef.current?.name;
1832
2264
  screenRef.current = screen;
1833
2265
  if (screen.name && screen.name !== prev) {
2266
+ sessionTrackerRef.current?.pageView(screen.route ?? screen.name);
1834
2267
  breadcrumbsRef.current.add({
1835
2268
  category: "navigation",
1836
2269
  level: "info",
@@ -1892,6 +2325,13 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1892
2325
  if (!config.rewards?.enabled) return;
1893
2326
  activityQueueRef.current.push({ action, metadata });
1894
2327
  },
2328
+ // Product analytics (Users & Funnels)
2329
+ track(event, properties) {
2330
+ return eventTrackerRef.current?.track(event, properties) ?? false;
2331
+ },
2332
+ setConsent(state) {
2333
+ eventTrackerRef.current?.setConsent(state);
2334
+ },
1895
2335
  async getTier() {
1896
2336
  const userId = userRef.current?.id;
1897
2337
  const client = apiClientRef.current;