@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.js CHANGED
@@ -15,11 +15,12 @@ import {
15
15
  useCallback as useCallback4,
16
16
  useMemo
17
17
  } from "react";
18
+ import { AppState, Platform as Platform5 } from "react-native";
18
19
  import {
19
20
  createApiClient,
20
21
  createBreadcrumbBuffer,
21
22
  DEFAULT_API_ENDPOINT,
22
- newUuid,
23
+ newUuid as newUuid2,
23
24
  parseIdentityToken,
24
25
  scrubPii as scrubPii3
25
26
  } from "@mushi-mushi/core";
@@ -474,7 +475,17 @@ import {
474
475
  ScrollView,
475
476
  useColorScheme
476
477
  } from "react-native";
477
- import { mushiPalette, MUSHI_BANNER_NEON, MUSHI_COPY } from "@mushi-mushi/core";
478
+ import {
479
+ mushiPalette,
480
+ MUSHI_BANNER_NEON,
481
+ MUSHI_COPY,
482
+ MUSHI_CONTROL_DISABLED,
483
+ MUSHI_INVERSE,
484
+ MUSHI_RADIUS,
485
+ MUSHI_SHADOW_INK,
486
+ MUSHI_SPACING,
487
+ MUSHI_TYPE
488
+ } from "@mushi-mushi/core";
478
489
  import { getLocale } from "@mushi-mushi/web/i18n";
479
490
 
480
491
  // src/reporter-status.ts
@@ -540,6 +551,7 @@ var MushiBottomSheet = ({
540
551
  const [category, setCategory] = useState(null);
541
552
  const [description, setDescription] = useState("");
542
553
  const [phase, setPhase] = useState("form");
554
+ const [submitError, setSubmitError] = useState(null);
543
555
  const [sheetTab, setSheetTab] = useState("report");
544
556
  const [inboxReports, setInboxReports] = useState([]);
545
557
  const [inboxLoading, setInboxLoading] = useState(false);
@@ -613,6 +625,8 @@ var MushiBottomSheet = ({
613
625
  ...prev,
614
626
  { role: "bot", text: botText, options: reply.options }
615
627
  ]);
628
+ } catch {
629
+ setAssistantError("Could not reach the assistant. Try again.");
616
630
  } finally {
617
631
  setAssistantSending(false);
618
632
  }
@@ -686,24 +700,46 @@ var MushiBottomSheet = ({
686
700
  ).current;
687
701
  const handleSubmit = async () => {
688
702
  if (!category || !description.trim() || !mushi) return;
703
+ const trimmed = description.trim();
704
+ if (trimmed.length < 20) {
705
+ setSubmitError("Please write at least 20 characters so we can understand the issue.");
706
+ return;
707
+ }
708
+ setSubmitError(null);
689
709
  setPhase("sending");
690
710
  try {
691
- await mushi.submitReport({
711
+ const outcome = await mushi.submitReport({
692
712
  category,
693
- description: description.trim(),
713
+ description: trimmed,
694
714
  screenshotDataUrl: screenshotDataUrl && screenshotAttached ? screenshotDataUrl : void 0
695
715
  });
716
+ if (!outcome.ok) {
717
+ if (outcome.failureKind === "credentials" || outcome.failureKind === "quota") {
718
+ setSubmitError("Could not send \u2014 check the project API key or plan quota.");
719
+ setPhase("error");
720
+ return;
721
+ }
722
+ if (outcome.failureKind === "rate_limited") {
723
+ setSubmitError("Sending too fast \u2014 we queued this and will retry shortly.");
724
+ setPhase("error");
725
+ return;
726
+ }
727
+ setSubmitError("Queued for retry \u2014 we will send it when the connection is back.");
728
+ setPhase("error");
729
+ return;
730
+ }
696
731
  setPhase("sent");
697
732
  setTimeout(handleClose, 1400);
698
733
  } catch {
699
- setPhase("form");
734
+ setSubmitError("Something went wrong. Please try again.");
735
+ setPhase("error");
700
736
  }
701
737
  };
702
738
  const activeScreenshot = screenshotDataUrl && screenshotAttached ? screenshotDataUrl : null;
703
739
  const pal = mushiPalette(dark ? "dark" : "light");
704
- const colors = dark ? { bg: pal.paper, text: pal.ink, sub: pal.inkMuted, card: pal.paperRaised, accent: MUSHI_BANNER_NEON.bg, accentInk: 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: MUSHI_BANNER_NEON.bg, accentInk: MUSHI_BANNER_NEON.fg, border: pal.ruleStrong, backdrop: "rgba(0,0,0,0.35)", disabled: "#d1d1d6", disabledText: pal.inkFaint };
740
+ const colors = dark ? { bg: pal.paper, text: pal.ink, sub: pal.inkMuted, card: pal.paperRaised, accent: MUSHI_BANNER_NEON.bg, accentInk: MUSHI_BANNER_NEON.fg, border: pal.ruleStrong, backdrop: "rgba(0,0,0,0.6)", disabled: MUSHI_CONTROL_DISABLED.dark, disabledText: pal.inkFaint } : { bg: pal.paperRaised, text: pal.ink, sub: pal.inkMuted, card: pal.paper, accent: MUSHI_BANNER_NEON.bg, accentInk: MUSHI_BANNER_NEON.fg, border: pal.ruleStrong, backdrop: "rgba(0,0,0,0.35)", disabled: MUSHI_CONTROL_DISABLED.light, disabledText: pal.inkFaint };
705
741
  const sheetTabs = ["report", "inbox", ...assistantEnabled ? ["assistant"] : []];
706
- const canSubmit = !!category && description.trim().length > 0 && phase === "form";
742
+ const canSubmit = !!category && description.trim().length >= 20 && (phase === "form" || phase === "error");
707
743
  return /* @__PURE__ */ jsx(Modal, { visible, transparent: true, animationType: "none", statusBarTranslucent: true, children: /* @__PURE__ */ jsxs(
708
744
  KeyboardAvoidingView,
709
745
  {
@@ -731,7 +767,7 @@ var MushiBottomSheet = ({
731
767
  /* @__PURE__ */ jsx(Text, { style: [s.brandEyebrow, { color: MUSHI_BANNER_NEON.fg }], children: "MUSHI \xB7 BETA" }),
732
768
  /* @__PURE__ */ jsx(Text, { style: [s.brandTitle, { color: MUSHI_BANNER_NEON.fg }], children: sheetTab === "assistant" ? assistantLabel : sheetTab === "inbox" ? t.flows.reports.title : t.widget.title })
733
769
  ] }),
734
- /* @__PURE__ */ jsx(View, { style: s.tabRow, children: sheetTabs.map((tab) => /* @__PURE__ */ jsx(
770
+ /* @__PURE__ */ jsx(View, { style: [s.tabRow, { borderBottomColor: colors.border }], children: sheetTabs.map((tab) => /* @__PURE__ */ jsx(
735
771
  TouchableOpacity,
736
772
  {
737
773
  onPress: () => {
@@ -884,16 +920,22 @@ var MushiBottomSheet = ({
884
920
  multiline: true,
885
921
  textAlignVertical: "top",
886
922
  value: description,
887
- onChangeText: setDescription,
888
- editable: phase === "form"
923
+ onChangeText: (text) => {
924
+ setDescription(text.slice(0, 4e3));
925
+ if (submitError) setSubmitError(null);
926
+ if (phase === "error") setPhase("form");
927
+ },
928
+ editable: phase === "form" || phase === "error",
929
+ maxLength: 4e3
889
930
  }
890
931
  ),
932
+ submitError ? /* @__PURE__ */ jsx(Text, { style: { color: colors.accent, marginTop: 6, fontSize: 12 }, children: submitError }) : null,
891
933
  activeScreenshot ? /* @__PURE__ */ jsxs(View, { style: s.screenshotRow, children: [
892
934
  /* @__PURE__ */ jsx(
893
935
  Image,
894
936
  {
895
937
  source: { uri: activeScreenshot },
896
- style: s.screenshotThumb,
938
+ style: [s.screenshotThumb, { backgroundColor: colors.text }],
897
939
  accessibilityLabel: t.step3.screenshotPreviewAlt
898
940
  }
899
941
  ),
@@ -954,10 +996,10 @@ var s = StyleSheet.create({
954
996
  left: 0,
955
997
  right: 0,
956
998
  minHeight: SHEET_HEIGHT,
957
- borderTopLeftRadius: 20,
958
- borderTopRightRadius: 20,
959
- paddingBottom: Platform2.OS === "ios" ? 34 : 16,
960
- shadowColor: "#000",
999
+ borderTopLeftRadius: MUSHI_RADIUS.sheet,
1000
+ borderTopRightRadius: MUSHI_RADIUS.sheet,
1001
+ paddingBottom: Platform2.OS === "ios" ? 34 : MUSHI_SPACING.roomy,
1002
+ shadowColor: MUSHI_SHADOW_INK,
961
1003
  shadowOffset: { width: 0, height: -3 },
962
1004
  shadowOpacity: 0.12,
963
1005
  shadowRadius: 8,
@@ -965,22 +1007,22 @@ var s = StyleSheet.create({
965
1007
  },
966
1008
  handleArea: {
967
1009
  alignItems: "center",
968
- paddingVertical: 10
1010
+ paddingVertical: MUSHI_SPACING.comfy
969
1011
  },
970
1012
  handle: {
971
1013
  width: 40,
972
1014
  height: 5,
973
- borderRadius: 3,
1015
+ borderRadius: MUSHI_RADIUS.control,
974
1016
  opacity: 0.5
975
1017
  },
976
1018
  brandHeader: {
977
- paddingHorizontal: 20,
978
- paddingTop: 10,
979
- paddingBottom: 12,
1019
+ paddingHorizontal: MUSHI_SPACING.lounge,
1020
+ paddingTop: MUSHI_SPACING.comfy,
1021
+ paddingBottom: MUSHI_SPACING.comfy,
980
1022
  borderBottomWidth: 1.5
981
1023
  },
982
1024
  brandEyebrow: {
983
- fontSize: 10,
1025
+ fontSize: MUSHI_TYPE.sizeLabel,
984
1026
  fontWeight: "700",
985
1027
  letterSpacing: 1.2,
986
1028
  marginBottom: 2,
@@ -992,59 +1034,61 @@ var s = StyleSheet.create({
992
1034
  letterSpacing: -0.2
993
1035
  },
994
1036
  body: {
995
- paddingHorizontal: 20,
996
- paddingTop: 4
1037
+ paddingHorizontal: MUSHI_SPACING.lounge,
1038
+ paddingTop: MUSHI_SPACING.tight
997
1039
  },
998
1040
  title: {
999
1041
  fontSize: 20,
1000
1042
  fontWeight: "700",
1001
- marginBottom: 16
1043
+ marginBottom: MUSHI_SPACING.roomy
1002
1044
  },
1003
1045
  stepLabel: {
1004
- fontSize: 12,
1046
+ fontSize: MUSHI_TYPE.sizeLabel,
1005
1047
  fontWeight: "600",
1006
1048
  letterSpacing: 0.2,
1007
- marginTop: 8,
1008
- marginBottom: 10,
1049
+ marginTop: MUSHI_SPACING.snug,
1050
+ marginBottom: MUSHI_SPACING.comfy,
1009
1051
  textTransform: "uppercase"
1010
1052
  },
1011
1053
  catRow: {
1012
1054
  flexDirection: "row",
1013
1055
  justifyContent: "space-between",
1014
- marginBottom: 16
1056
+ marginBottom: MUSHI_SPACING.roomy
1015
1057
  },
1016
1058
  catBtn: {
1017
1059
  alignItems: "center",
1018
- paddingVertical: 10,
1060
+ paddingVertical: MUSHI_SPACING.comfy,
1019
1061
  paddingHorizontal: 6,
1020
- borderRadius: 12,
1062
+ borderRadius: MUSHI_RADIUS.card,
1021
1063
  borderWidth: 1,
1022
1064
  flex: 1,
1023
- marginHorizontal: 3
1065
+ marginHorizontal: 3,
1066
+ minHeight: 44
1024
1067
  },
1025
1068
  catEmoji: {
1026
1069
  fontSize: 22,
1027
- marginBottom: 4
1070
+ marginBottom: MUSHI_SPACING.tight
1028
1071
  },
1029
1072
  catLabel: {
1030
- fontSize: 11,
1073
+ fontSize: MUSHI_TYPE.sizeLabel,
1031
1074
  fontWeight: "600"
1032
1075
  },
1033
1076
  input: {
1034
1077
  borderWidth: 1,
1035
- borderRadius: 12,
1078
+ borderRadius: MUSHI_RADIUS.card,
1036
1079
  padding: 14,
1037
1080
  fontSize: 15,
1038
1081
  minHeight: 100,
1039
- marginBottom: 16
1082
+ marginBottom: MUSHI_SPACING.roomy
1040
1083
  },
1041
1084
  submitBtn: {
1042
- borderRadius: 12,
1085
+ borderRadius: MUSHI_RADIUS.card,
1043
1086
  paddingVertical: 14,
1044
- alignItems: "center"
1087
+ alignItems: "center",
1088
+ minHeight: 44
1045
1089
  },
1046
1090
  submitText: {
1047
- color: "#fff",
1091
+ color: MUSHI_INVERSE,
1048
1092
  fontSize: 16,
1049
1093
  fontWeight: "700"
1050
1094
  },
@@ -1075,7 +1119,6 @@ var s = StyleSheet.create({
1075
1119
  width: 48,
1076
1120
  height: 36,
1077
1121
  borderRadius: 4,
1078
- backgroundColor: "#000",
1079
1122
  flexShrink: 0
1080
1123
  },
1081
1124
  screenshotMeta: {
@@ -1097,7 +1140,6 @@ var s = StyleSheet.create({
1097
1140
  tabRow: {
1098
1141
  flexDirection: "row",
1099
1142
  borderBottomWidth: StyleSheet.hairlineWidth,
1100
- borderBottomColor: "#ccc",
1101
1143
  marginBottom: 8
1102
1144
  },
1103
1145
  tabBtn: {
@@ -1216,7 +1258,8 @@ var MushiFloatingButton = ({
1216
1258
  const currentX = useRef2(baseX);
1217
1259
  const currentY = useRef2(0);
1218
1260
  const isDragging = useRef2(false);
1219
- const accentColor = accent ?? mushiPalette2(dark ? "dark" : "light").accent;
1261
+ const pal = mushiPalette2(dark ? "dark" : "light");
1262
+ const accentColor = accent ?? pal.accent;
1220
1263
  useEffect2(() => {
1221
1264
  if (!persist) return;
1222
1265
  void loadStoredPos().then((pos) => {
@@ -1319,7 +1362,7 @@ var MushiFloatingButton = ({
1319
1362
  left: posX,
1320
1363
  bottom: Animated2.add(new Animated2.Value(baseBottom), Animated2.multiply(posY, -1)),
1321
1364
  backgroundColor: accentColor,
1322
- shadowColor: dark ? accentColor : "#000",
1365
+ shadowColor: dark ? accentColor : pal.ink,
1323
1366
  transform: [{ scale }]
1324
1367
  }
1325
1368
  ],
@@ -1358,7 +1401,7 @@ import {
1358
1401
  StyleSheet as StyleSheet3,
1359
1402
  Platform as Platform4
1360
1403
  } from "react-native";
1361
- import { MUSHI_BANNER_NEON as MUSHI_BANNER_NEON2, MUSHI_COPY as MUSHI_COPY3, MUSHI_GEOMETRY as MUSHI_GEOMETRY2 } from "@mushi-mushi/core";
1404
+ import { MUSHI_BANNER_NEON as MUSHI_BANNER_NEON2, MUSHI_COPY as MUSHI_COPY3, MUSHI_GEOMETRY as MUSHI_GEOMETRY2, MUSHI_SPACING as MUSHI_SPACING2, MUSHI_TYPE as MUSHI_TYPE2, MUSHI_Z } from "@mushi-mushi/core";
1362
1405
  import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
1363
1406
  var MUSHI_BANNER_DEFAULT_HEIGHT = MUSHI_GEOMETRY2.bannerHeight;
1364
1407
  var MushiBanner = ({
@@ -1414,7 +1457,7 @@ var s3 = StyleSheet3.create({
1414
1457
  backgroundColor: MUSHI_BANNER_NEON2.bg,
1415
1458
  borderTopWidth: 1,
1416
1459
  borderTopColor: MUSHI_BANNER_NEON2.border,
1417
- zIndex: 99998,
1460
+ zIndex: MUSHI_Z.banner,
1418
1461
  elevation: 8,
1419
1462
  paddingBottom: Platform4.OS === "ios" ? 0 : 0
1420
1463
  },
@@ -1422,12 +1465,13 @@ var s3 = StyleSheet3.create({
1422
1465
  flex: 1,
1423
1466
  flexDirection: "row",
1424
1467
  alignItems: "center",
1425
- gap: 8,
1426
- paddingHorizontal: 12,
1427
- paddingVertical: 8
1468
+ gap: MUSHI_SPACING2.snug,
1469
+ paddingHorizontal: MUSHI_SPACING2.comfy,
1470
+ paddingVertical: MUSHI_SPACING2.snug,
1471
+ minHeight: 44
1428
1472
  },
1429
1473
  eyebrow: {
1430
- fontSize: 10,
1474
+ fontSize: MUSHI_TYPE2.sizeLabel,
1431
1475
  fontWeight: "700",
1432
1476
  letterSpacing: 1.2,
1433
1477
  color: MUSHI_BANNER_NEON2.fg
@@ -1456,13 +1500,341 @@ var s3 = StyleSheet3.create({
1456
1500
  }
1457
1501
  });
1458
1502
 
1503
+ // src/analytics/event-tracker.ts
1504
+ import {
1505
+ EVENT_PROPERTY_LIMITS,
1506
+ isValidEventName,
1507
+ propertiesWithinByteLimit,
1508
+ sanitizeEventProperties
1509
+ } from "@mushi-mushi/core";
1510
+ var CONSENT_BUFFER_MAX = 50;
1511
+ var SPILL_MAX = 200;
1512
+ var SPILL_TTL_MS = 24 * 60 * 60 * 1e3;
1513
+ var SURFACE = "mobile";
1514
+ async function defaultStorage() {
1515
+ try {
1516
+ const mod = await import("@react-native-async-storage/async-storage");
1517
+ return mod.default;
1518
+ } catch {
1519
+ return null;
1520
+ }
1521
+ }
1522
+ function createRNEventTracker(opts) {
1523
+ const cfg = opts.config ?? {};
1524
+ const enabled = cfg.enabled !== false;
1525
+ const consentMode = cfg.consent ?? "implied";
1526
+ const allowlist = cfg.propertyAllowlist ?? [];
1527
+ const spillKey = `@mushi:events_spill_${opts.projectId}`;
1528
+ const consentKey = `@mushi:analytics_consent_${opts.projectId}`;
1529
+ let buffer = [];
1530
+ let consentBuffer = [];
1531
+ let consent = consentMode === "required" ? "pending" : "granted";
1532
+ let userId = null;
1533
+ let userTraits = null;
1534
+ let pendingIdentify = false;
1535
+ let flushing = false;
1536
+ let destroyed = false;
1537
+ let timer = null;
1538
+ let storage = null;
1539
+ const consentListeners = /* @__PURE__ */ new Set();
1540
+ const loadStorage = opts.storage ?? defaultStorage;
1541
+ const resolveClient = () => typeof opts.client === "function" ? opts.client() : opts.client;
1542
+ async function readSpill() {
1543
+ if (!storage) return [];
1544
+ try {
1545
+ const raw = await storage.getItem(spillKey);
1546
+ if (!raw) return [];
1547
+ const parsed = JSON.parse(raw);
1548
+ if (!parsed || Date.now() - parsed.at > SPILL_TTL_MS) {
1549
+ await storage.removeItem(spillKey);
1550
+ return [];
1551
+ }
1552
+ return Array.isArray(parsed.events) ? parsed.events.slice(0, SPILL_MAX) : [];
1553
+ } catch {
1554
+ return [];
1555
+ }
1556
+ }
1557
+ async function writeSpill(events) {
1558
+ if (!storage) return;
1559
+ try {
1560
+ if (events.length === 0) await storage.removeItem(spillKey);
1561
+ else await storage.setItem(spillKey, JSON.stringify({ at: Date.now(), events: events.slice(-SPILL_MAX) }));
1562
+ } catch {
1563
+ }
1564
+ }
1565
+ async function flushNow() {
1566
+ if (destroyed && buffer.length === 0) return;
1567
+ await ready;
1568
+ if (flushing || consent !== "granted") return;
1569
+ if (buffer.length === 0 && !pendingIdentify) return;
1570
+ const client = resolveClient();
1571
+ if (!client) return;
1572
+ flushing = true;
1573
+ const batch = buffer.splice(0, EVENT_PROPERTY_LIMITS.maxServerBatch);
1574
+ if (pendingIdentify) {
1575
+ batch.unshift({ name: "identify", ts: (/* @__PURE__ */ new Date()).toISOString(), properties: {} });
1576
+ pendingIdentify = false;
1577
+ }
1578
+ try {
1579
+ const anonId = await Promise.resolve(opts.getAnonId());
1580
+ const payload = {
1581
+ anon_id: anonId,
1582
+ user_id: userId ?? opts.getUserId?.() ?? null,
1583
+ user_traits: userTraits ?? opts.getUserTraits?.() ?? null,
1584
+ session_id: null,
1585
+ sdk_version: opts.sdkVersion ?? null,
1586
+ surface: SURFACE,
1587
+ events: batch
1588
+ };
1589
+ const res = await client.postProductEvents(payload);
1590
+ if (!res.ok) await writeSpill([...await readSpill(), ...batch.filter((e) => e.name !== "identify")]);
1591
+ else await writeSpill([]);
1592
+ } catch {
1593
+ await writeSpill([...await readSpill(), ...batch.filter((e) => e.name !== "identify")]);
1594
+ } finally {
1595
+ flushing = false;
1596
+ }
1597
+ if (buffer.length >= EVENT_PROPERTY_LIMITS.maxClientBatch) void flushNow();
1598
+ }
1599
+ function enqueue(ev) {
1600
+ if (consent === "denied") return;
1601
+ if (consent === "pending") {
1602
+ if (consentBuffer.length < CONSENT_BUFFER_MAX) consentBuffer.push(ev);
1603
+ return;
1604
+ }
1605
+ buffer.push(ev);
1606
+ if (buffer.length >= EVENT_PROPERTY_LIMITS.maxClientBatch) void flushNow();
1607
+ }
1608
+ const ready = enabled ? (async () => {
1609
+ storage = await loadStorage();
1610
+ if (destroyed) return;
1611
+ let stored = null;
1612
+ try {
1613
+ stored = storage ? await storage.getItem(consentKey) : null;
1614
+ } catch {
1615
+ stored = null;
1616
+ }
1617
+ if (stored === "denied") {
1618
+ consent = "denied";
1619
+ buffer = [];
1620
+ consentBuffer = [];
1621
+ pendingIdentify = false;
1622
+ await writeSpill([]);
1623
+ return;
1624
+ }
1625
+ if (stored === "granted" && consent === "pending") {
1626
+ consent = "granted";
1627
+ buffer.push(...consentBuffer);
1628
+ consentBuffer = [];
1629
+ }
1630
+ const spill = await readSpill();
1631
+ if (spill.length > 0) {
1632
+ buffer.unshift(...spill);
1633
+ await writeSpill([]);
1634
+ }
1635
+ })().catch(() => void 0) : Promise.resolve();
1636
+ if (enabled) {
1637
+ const interval = Math.max(1e3, cfg.flushIntervalMs ?? 5e3);
1638
+ timer = setInterval(() => {
1639
+ void flushNow();
1640
+ }, interval);
1641
+ }
1642
+ return {
1643
+ ready,
1644
+ track(name, properties, trackOpts) {
1645
+ if (!enabled || destroyed) return false;
1646
+ if (!isValidEventName(name)) return false;
1647
+ const { properties: props } = sanitizeEventProperties(properties, { allowlist, scrub: opts.scrub });
1648
+ let merged = props;
1649
+ if (trackOpts?.reserved) {
1650
+ const { properties: res } = sanitizeEventProperties(trackOpts.reserved, { allowReserved: true, scrub: opts.scrub });
1651
+ merged = { ...props, ...res };
1652
+ }
1653
+ merged.$surface = SURFACE;
1654
+ const route = opts.getRoute?.();
1655
+ if (route && merged.$route === void 0) merged.$route = route;
1656
+ if (!propertiesWithinByteLimit(merged)) return false;
1657
+ enqueue({
1658
+ name,
1659
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1660
+ properties: merged,
1661
+ ...trackOpts?.dedupKey ? { dedup_key: trackOpts.dedupKey } : {}
1662
+ });
1663
+ return true;
1664
+ },
1665
+ setConsent(state) {
1666
+ void ready.then(() => storage?.setItem(consentKey, state)).catch(() => void 0);
1667
+ if (state === "denied") {
1668
+ consent = "denied";
1669
+ buffer = [];
1670
+ consentBuffer = [];
1671
+ pendingIdentify = false;
1672
+ void writeSpill([]);
1673
+ } else {
1674
+ const wasPending = consent === "pending";
1675
+ consent = "granted";
1676
+ if (wasPending && consentBuffer.length > 0) {
1677
+ buffer.push(...consentBuffer);
1678
+ consentBuffer = [];
1679
+ }
1680
+ if (buffer.length > 0 || pendingIdentify) void flushNow();
1681
+ }
1682
+ for (const listener of [...consentListeners]) {
1683
+ try {
1684
+ listener(state);
1685
+ } catch {
1686
+ }
1687
+ }
1688
+ },
1689
+ setIdentity(id, traits) {
1690
+ userId = id;
1691
+ userTraits = traits ?? null;
1692
+ if (id && enabled && consent !== "denied") {
1693
+ pendingIdentify = true;
1694
+ void flushNow();
1695
+ }
1696
+ },
1697
+ consentState() {
1698
+ return enabled ? consent : "denied";
1699
+ },
1700
+ onConsentChange(listener) {
1701
+ consentListeners.add(listener);
1702
+ return () => {
1703
+ consentListeners.delete(listener);
1704
+ };
1705
+ },
1706
+ async flush() {
1707
+ await ready;
1708
+ await flushNow();
1709
+ },
1710
+ async destroy() {
1711
+ if (timer != null) {
1712
+ clearInterval(timer);
1713
+ timer = null;
1714
+ }
1715
+ await flushNow().catch(() => void 0);
1716
+ destroyed = true;
1717
+ buffer = [];
1718
+ consentBuffer = [];
1719
+ pendingIdentify = false;
1720
+ }
1721
+ };
1722
+ }
1723
+
1724
+ // src/analytics/session-tracker.ts
1725
+ import { newUuid } from "@mushi-mushi/core";
1726
+ var HEARTBEAT_INTERVAL_MS = 6e4;
1727
+ var RESUME_WINDOW_MS = 30 * 6e4;
1728
+ function createRNSessionTracker(opts) {
1729
+ const clock = opts.now ?? Date.now;
1730
+ const resolveClient = () => typeof opts.client === "function" ? opts.client() : opts.client;
1731
+ let sessionId = opts.sessionId ?? `sess_${newUuid()}`;
1732
+ let pageViewCount = 0;
1733
+ let started = false;
1734
+ let allowed = false;
1735
+ let foreground = true;
1736
+ let destroyed = false;
1737
+ let backgroundedAt = null;
1738
+ let heartbeat = null;
1739
+ function route() {
1740
+ return opts.getRoute?.() ?? null;
1741
+ }
1742
+ function send(kind, extra) {
1743
+ const client = resolveClient();
1744
+ if (!client || !allowed || destroyed) return;
1745
+ const base = {
1746
+ kind,
1747
+ session_id: sessionId,
1748
+ ts: new Date(clock()).toISOString(),
1749
+ page_view_count: pageViewCount,
1750
+ user_agent: opts.userAgent ?? null,
1751
+ sdk_version: opts.sdkVersion
1752
+ };
1753
+ void Promise.resolve(opts.getReporterToken()).then((token) => client.postSessionEvent({ ...base, reporter_token_hash: token, ...extra })).catch(() => void 0);
1754
+ }
1755
+ function stopHeartbeat() {
1756
+ if (heartbeat != null) {
1757
+ clearInterval(heartbeat);
1758
+ heartbeat = null;
1759
+ }
1760
+ }
1761
+ function startHeartbeat() {
1762
+ stopHeartbeat();
1763
+ heartbeat = setInterval(() => send("session_heartbeat", { route: route() }), HEARTBEAT_INTERVAL_MS);
1764
+ }
1765
+ function startSession() {
1766
+ started = true;
1767
+ pageViewCount = 1;
1768
+ send("session_start", { route: route() });
1769
+ startHeartbeat();
1770
+ }
1771
+ function activate() {
1772
+ if (allowed || destroyed) return;
1773
+ allowed = true;
1774
+ if (!foreground) return;
1775
+ if (!started) startSession();
1776
+ else startHeartbeat();
1777
+ }
1778
+ function deactivate() {
1779
+ allowed = false;
1780
+ stopHeartbeat();
1781
+ }
1782
+ const unsubscribe = opts.consent.onConsentChange(() => {
1783
+ if (opts.consent.consentState() === "granted") activate();
1784
+ else deactivate();
1785
+ });
1786
+ void opts.consent.ready.then(() => {
1787
+ if (opts.consent.consentState() === "granted") activate();
1788
+ }).catch(() => void 0);
1789
+ return {
1790
+ onAppStateChange(state) {
1791
+ if (destroyed) return;
1792
+ if (state === "active") {
1793
+ if (foreground) return;
1794
+ foreground = true;
1795
+ const away = backgroundedAt == null ? 0 : clock() - backgroundedAt;
1796
+ backgroundedAt = null;
1797
+ if (!allowed) return;
1798
+ if (!started || away >= RESUME_WINDOW_MS) {
1799
+ sessionId = `sess_${newUuid()}`;
1800
+ startSession();
1801
+ } else {
1802
+ send("session_heartbeat", { route: route() });
1803
+ startHeartbeat();
1804
+ }
1805
+ return;
1806
+ }
1807
+ if (state !== "background" || !foreground) return;
1808
+ foreground = false;
1809
+ backgroundedAt = clock();
1810
+ stopHeartbeat();
1811
+ if (started) send("session_end", { route: route() });
1812
+ },
1813
+ pageView(next) {
1814
+ if (!allowed || !started || !foreground) return;
1815
+ pageViewCount += 1;
1816
+ send("page_view", { route: next });
1817
+ },
1818
+ destroy() {
1819
+ destroyed = true;
1820
+ deactivate();
1821
+ unsubscribe();
1822
+ }
1823
+ };
1824
+ }
1825
+
1459
1826
  // src/version.ts
1460
1827
  var MUSHI_SDK_PACKAGE = "@mushi-mushi/react-native";
1461
- var MUSHI_SDK_VERSION = "0.20.1" ? "0.20.1" : "0.17.0";
1828
+ var MUSHI_SDK_VERSION = "0.22.0" ? "0.22.0" : "0.17.0";
1462
1829
 
1463
1830
  // src/provider.tsx
1464
1831
  import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
1465
1832
  var MushiContext = createContext(null);
1833
+ function nativeUserAgent() {
1834
+ const os = Platform5?.OS ?? "native";
1835
+ const version = Platform5?.Version;
1836
+ return `${MUSHI_SDK_PACKAGE}/${MUSHI_SDK_VERSION} (${version != null ? `${os} ${String(version)}` : os})`;
1837
+ }
1466
1838
  var DEFAULT_SCREENSHOT_HINT = "Check the preview \u2014 remove it if any private info (balances, personal details) is visible.";
1467
1839
  function resolveScreenshotHint(v) {
1468
1840
  if (v === false) return null;
@@ -1505,7 +1877,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1505
1877
  const networkRef = useRef3(null);
1506
1878
  const queueRef = useRef3(null);
1507
1879
  const apiClientRef = useRef3(null);
1508
- const sessionIdRef = useRef3(`sess_${newUuid()}`);
1880
+ const sessionIdRef = useRef3(`sess_${newUuid2()}`);
1509
1881
  const breadcrumbsRef = useRef3(
1510
1882
  createBreadcrumbBuffer({ max: config.capture?.maxBreadcrumbs ?? 50 })
1511
1883
  );
@@ -1577,14 +1949,14 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1577
1949
  if (existing && existing.startsWith("mushi_")) {
1578
1950
  reporterTokenRef.current = existing;
1579
1951
  } else {
1580
- const fresh = `mushi_${newUuid()}`;
1952
+ const fresh = `mushi_${newUuid2()}`;
1581
1953
  reporterTokenRef.current = fresh;
1582
1954
  await saveReporterToken(fresh, secureStorage);
1583
1955
  }
1584
1956
  resolveReporterTokenReady();
1585
1957
  } catch {
1586
1958
  if (reporterTokenRef.current === `rn-${config.projectId}-anon`) {
1587
- reporterTokenRef.current = `mushi_${newUuid()}`;
1959
+ reporterTokenRef.current = `mushi_${newUuid2()}`;
1588
1960
  }
1589
1961
  resolveReporterTokenReady();
1590
1962
  }
@@ -1713,7 +2085,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1713
2085
  }));
1714
2086
  const fingerprintHash = getDeviceFingerprintHash(deviceInfo);
1715
2087
  const report = {
1716
- id: newUuid(),
2088
+ id: newUuid2(),
1717
2089
  projectId: config.projectId,
1718
2090
  category: data.category,
1719
2091
  description: scrubPii3(data.description),
@@ -1748,21 +2120,34 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1748
2120
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1749
2121
  };
1750
2122
  const client = apiClientRef.current;
1751
- if (!client) return;
2123
+ if (!client) {
2124
+ console.error(
2125
+ "[Mushi] submitReport called before the SDK finished initializing. Ensure <MushiProvider> is mounted with a valid projectId and apiKey."
2126
+ );
2127
+ return { ok: false, failureKind: "permanent" };
2128
+ }
1752
2129
  const result = await client.submitReport(report);
1753
2130
  if (!result.ok) {
1754
- const isCredentialError = result.error?.code === "HTTP_401" || result.error?.code === "HTTP_403" || result.error?.code?.includes("UNAUTHORIZED") || result.error?.code?.includes("FORBIDDEN");
2131
+ const code = result.error?.code ?? "";
2132
+ const isCredentialError = code === "HTTP_401" || code === "HTTP_403" || code.includes("UNAUTHORIZED") || code.includes("FORBIDDEN") || code.includes("INVALID_API_KEY");
1755
2133
  if (isCredentialError) {
1756
2134
  const where = apiEndpoint.replace(/\/$/, "") === 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.";
1757
2135
  console.error(
1758
2136
  '[Mushi] Credentials rejected. Check your projectId and apiKey (must have "report:write" scope). ' + where
1759
2137
  );
1760
- return;
2138
+ return { ok: false, failureKind: "credentials" };
1761
2139
  }
2140
+ const isRateLimited = code === "HTTP_429" || code.includes("RATE_LIMIT");
1762
2141
  await queueRef.current?.enqueue(report);
1763
2142
  config.callbacks?.onReportQueued?.(report.id);
2143
+ return {
2144
+ ok: false,
2145
+ queued: true,
2146
+ failureKind: isRateLimited ? "rate_limited" : "retrying"
2147
+ };
1764
2148
  } else {
1765
2149
  config.callbacks?.onReportSubmitted?.(report.id);
2150
+ return { ok: true };
1766
2151
  }
1767
2152
  },
1768
2153
  [config.projectId, config.callbacks, apiEndpoint]
@@ -1792,6 +2177,63 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1792
2177
  if (rewardsFlushRef.current) clearInterval(rewardsFlushRef.current);
1793
2178
  };
1794
2179
  }, [config.rewards?.enabled, config.rewards?.trackActivity, config.rewards?.flushIntervalMs]);
2180
+ const eventTrackerRef = useRef3(null);
2181
+ const sessionTrackerRef = useRef3(null);
2182
+ useEffect3(() => {
2183
+ const getRoute = () => screenRef.current?.route ?? screenRef.current?.name ?? null;
2184
+ const tracker = createRNEventTracker({
2185
+ projectId: config.projectId,
2186
+ client: () => apiClientRef.current,
2187
+ getAnonId: async () => {
2188
+ await reporterTokenReadyRef.current;
2189
+ return reporterTokenRef.current;
2190
+ },
2191
+ getUserId: () => userRef.current?.id ?? null,
2192
+ getRoute,
2193
+ sdkVersion: MUSHI_SDK_VERSION,
2194
+ config: config.analytics,
2195
+ scrub: scrubPii3
2196
+ });
2197
+ eventTrackerRef.current = tracker;
2198
+ const sessions = config.trackSessions === false ? null : createRNSessionTracker({
2199
+ client: () => apiClientRef.current,
2200
+ getReporterToken: async () => {
2201
+ await reporterTokenReadyRef.current;
2202
+ return reporterTokenRef.current;
2203
+ },
2204
+ getRoute,
2205
+ consent: tracker,
2206
+ sessionId: sessionIdRef.current,
2207
+ sdkVersion: MUSHI_SDK_VERSION,
2208
+ userAgent: nativeUserAgent()
2209
+ });
2210
+ sessionTrackerRef.current = sessions;
2211
+ let sub;
2212
+ try {
2213
+ if (typeof AppState?.addEventListener === "function") {
2214
+ sub = AppState.addEventListener("change", (state) => {
2215
+ sessions?.onAppStateChange(state);
2216
+ if (state !== "active") tracker.flush().catch(() => {
2217
+ });
2218
+ });
2219
+ }
2220
+ } catch {
2221
+ }
2222
+ return () => {
2223
+ sub?.remove();
2224
+ sessions?.destroy();
2225
+ sessionTrackerRef.current = null;
2226
+ eventTrackerRef.current = null;
2227
+ tracker.destroy().catch(() => {
2228
+ });
2229
+ };
2230
+ }, [
2231
+ config.projectId,
2232
+ config.analytics?.enabled,
2233
+ config.analytics?.consent,
2234
+ config.analytics?.flushIntervalMs,
2235
+ config.trackSessions
2236
+ ]);
1795
2237
  const instance = useMemo(
1796
2238
  () => ({
1797
2239
  open,
@@ -1809,6 +2251,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1809
2251
  name: traits?.name,
1810
2252
  provider: traits?.provider
1811
2253
  };
2254
+ eventTrackerRef.current?.setIdentity(userId, traits ?? null);
1812
2255
  },
1813
2256
  setUser(user) {
1814
2257
  userRef.current = {
@@ -1817,6 +2260,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1817
2260
  name: user.name,
1818
2261
  provider: user.provider
1819
2262
  };
2263
+ eventTrackerRef.current?.setIdentity(user.id, { email: user.email, name: user.name, provider: user.provider });
1820
2264
  },
1821
2265
  identifyWithToken(token) {
1822
2266
  userTokenRef.current = token && typeof token === "string" ? token : null;
@@ -1828,7 +2272,10 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1828
2272
  ...claims.email ? { email: claims.email } : {},
1829
2273
  ...claims.name ? { name: claims.name } : {}
1830
2274
  };
2275
+ eventTrackerRef.current?.setIdentity(claims.sub, null);
1831
2276
  }
2277
+ } else {
2278
+ eventTrackerRef.current?.setIdentity(null, null);
1832
2279
  }
1833
2280
  },
1834
2281
  publishPageContext(context) {
@@ -1841,6 +2288,7 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1841
2288
  const prev = screenRef.current?.name;
1842
2289
  screenRef.current = screen;
1843
2290
  if (screen.name && screen.name !== prev) {
2291
+ sessionTrackerRef.current?.pageView(screen.route ?? screen.name);
1844
2292
  breadcrumbsRef.current.add({
1845
2293
  category: "navigation",
1846
2294
  level: "info",
@@ -1902,6 +2350,13 @@ function MushiProvider({ children, config: configProp, ...barePropConfig }) {
1902
2350
  if (!config.rewards?.enabled) return;
1903
2351
  activityQueueRef.current.push({ action, metadata });
1904
2352
  },
2353
+ // Product analytics (Users & Funnels)
2354
+ track(event, properties) {
2355
+ return eventTrackerRef.current?.track(event, properties) ?? false;
2356
+ },
2357
+ setConsent(state) {
2358
+ eventTrackerRef.current?.setConsent(state);
2359
+ },
1905
2360
  async getTier() {
1906
2361
  const userId = userRef.current?.id;
1907
2362
  const client = apiClientRef.current;