@mushi-mushi/react-native 0.15.2 → 0.16.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/README.md CHANGED
@@ -120,4 +120,4 @@ MIT
120
120
  <!-- mushi-readme-stats-footer -->
121
121
  ---
122
122
 
123
- <sub>Monorepo scale (June 2026): 43 edge functions · 234 SQL migrations · 13 outbound plugins · 11 inbound adapters. Canonical counts: <a href="https://github.com/kensaurus/mushi-mushi/blob/master/docs/stats.md">docs/stats.md</a> · <code>pnpm docs-stats</code></sub>
123
+ <sub>Monorepo scale (June 2026): 47 edge functions · 256 SQL migrations · 13 outbound plugins · 11 inbound adapters · 18 pipeline agents. Canonical counts: <a href="https://github.com/kensaurus/mushi-mushi/blob/master/docs/stats.md">docs/stats.md</a> · <code>pnpm docs-stats</code></sub>
package/dist/index.cjs CHANGED
@@ -467,13 +467,14 @@ var MushiBottomSheet = ({
467
467
  }
468
468
  };
469
469
  const activeScreenshot = screenshotDataUrl && screenshotAttached ? screenshotDataUrl : null;
470
- const colors = dark ? { bg: "#1c1c1e", text: "#f2f2f7", sub: "#8e8e93", card: "#2c2c2e", accent: "#0a84ff", border: "#38383a", backdrop: "rgba(0,0,0,0.6)" } : { bg: "#ffffff", text: "#1c1c1e", sub: "#8e8e93", card: "#f2f2f7", accent: "#007aff", border: "#e5e5ea", backdrop: "rgba(0,0,0,0.35)" };
470
+ const colors = dark ? { bg: "#1c1c1e", text: "#f2f2f7", sub: "#8e8e93", card: "#2c2c2e", accent: "#0a84ff", border: "#38383a", backdrop: "rgba(0,0,0,0.6)", disabled: "#3a3a3c", disabledText: "#636366" } : { bg: "#ffffff", text: "#1c1c1e", sub: "#8e8e93", card: "#f2f2f7", accent: "#007aff", border: "#e5e5ea", backdrop: "rgba(0,0,0,0.35)", disabled: "#d1d1d6", disabledText: "#8e8e93" };
471
471
  const canSubmit = !!category && description.trim().length > 0 && phase === "form";
472
472
  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)(
473
473
  import_react_native2.KeyboardAvoidingView,
474
474
  {
475
475
  style: s.flex,
476
- behavior: import_react_native2.Platform.OS === "ios" ? "padding" : void 0,
476
+ behavior: import_react_native2.Platform.OS === "ios" ? "padding" : "height",
477
+ keyboardVerticalOffset: import_react_native2.Platform.OS === "ios" ? 0 : 20,
477
478
  children: [
478
479
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
479
480
  import_react_native2.Animated.View,
@@ -491,7 +492,7 @@ var MushiBottomSheet = ({
491
492
  ],
492
493
  children: [
493
494
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.View, { ...panResponder.panHandlers, style: s.handleArea, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.View, { style: [s.handle, { backgroundColor: colors.sub }] }) }),
494
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.View, { style: s.tabRow, children: ["report", "inbox"].map((tab) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
495
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.View, { style: s.tabRow, children: ["report", "inbox", "community"].map((tab) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
495
496
  import_react_native2.TouchableOpacity,
496
497
  {
497
498
  onPress: () => {
@@ -500,11 +501,11 @@ var MushiBottomSheet = ({
500
501
  else setSelectedReportId(null);
501
502
  },
502
503
  style: [s.tabBtn, sheetTab === tab && { borderBottomColor: colors.accent }],
503
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.tabLabel, { color: sheetTab === tab ? colors.accent : colors.sub }], children: tab === "report" ? "Report" : "Inbox" })
504
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.tabLabel, { color: sheetTab === tab ? colors.accent : colors.sub }], children: tab === "report" ? "Report" : tab === "inbox" ? "Inbox" : "\u{1F310}" })
504
505
  },
505
506
  tab
506
507
  )) }),
507
- sheetTab === "inbox" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.View, { style: s.body, children: inboxLoading ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: { color: colors.sub }, children: "Loading\u2026" }) : selectedReportId ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
508
+ sheetTab === "inbox" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.ScrollView, { style: s.body, keyboardShouldPersistTaps: "handled", children: inboxLoading ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: { color: colors.sub }, children: "Loading\u2026" }) : selectedReportId ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
508
509
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.TouchableOpacity, { onPress: () => setSelectedReportId(null), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: { color: colors.accent, marginBottom: 8 }, children: "\u2190 Back" }) }),
509
510
  threadComments.map((c) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.View, { style: [s.threadBubble, { backgroundColor: colors.card }], children: [
510
511
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: { color: colors.sub, fontSize: 11 }, children: c.author_kind }),
@@ -530,102 +531,127 @@ var MushiBottomSheet = ({
530
531
  ] }) : inboxReports.map((r) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.TouchableOpacity, { style: [s.inboxRow, { borderColor: colors.border }], onPress: () => openThread(r.id), children: [
531
532
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: { color: colors.text, fontWeight: "600" }, numberOfLines: 1, children: (r.summary ?? r.description ?? "Report").slice(0, 60) }),
532
533
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: { color: colors.sub, fontSize: 11 }, children: r.status })
533
- ] }, r.id)) }) : phase === "sent" ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.View, { style: s.sentWrap, children: [
534
+ ] }, r.id)) }) : sheetTab === "community" ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.ScrollView, { style: s.body, keyboardShouldPersistTaps: "handled", children: [
535
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.title, { color: colors.text }], children: "\u{1F310} Mushi Community" }),
536
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: { color: colors.sub, fontSize: 13, marginBottom: 16, lineHeight: 18 }, children: "Sign in to track your reports across apps and see the global leaderboard. No password needed." }),
537
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
538
+ import_react_native2.TextInput,
539
+ {
540
+ style: [s.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border, minHeight: 48 }],
541
+ placeholder: "you@example.com",
542
+ placeholderTextColor: colors.sub,
543
+ keyboardType: "email-address",
544
+ autoCapitalize: "none",
545
+ autoCorrect: false,
546
+ textContentType: "emailAddress"
547
+ }
548
+ ),
549
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.TouchableOpacity, { style: [s.submitBtn, { backgroundColor: colors.accent }], children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: s.submitText, children: "Send sign-in link \u2192" }) }),
550
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: { color: colors.sub, fontSize: 11, textAlign: "center", marginTop: 12 }, children: "We'll email you a one-tap sign-in link. No password, ever." })
551
+ ] }) : phase === "sent" ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.View, { style: s.sentWrap, children: [
534
552
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.sentEmoji], children: "\u2705" }),
535
553
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.sentText, { color: colors.text }], children: "Report sent!" })
536
- ] }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.View, { style: s.body, children: [
537
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.title, { color: colors.text }], children: "Report an issue" }),
538
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.View, { style: s.catRow, children: CATEGORIES.map((c) => {
539
- const active = category === c.key;
540
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
541
- import_react_native2.TouchableOpacity,
542
- {
543
- onPress: () => setCategory(c.key),
544
- activeOpacity: 0.7,
545
- style: [
546
- s.catBtn,
554
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
555
+ import_react_native2.ScrollView,
556
+ {
557
+ style: s.body,
558
+ keyboardShouldPersistTaps: "handled",
559
+ contentContainerStyle: { paddingBottom: 24 },
560
+ children: [
561
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.title, { color: colors.text }], children: "Report an issue" }),
562
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.View, { style: s.catRow, children: CATEGORIES.map((c) => {
563
+ const active = category === c.key;
564
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
565
+ import_react_native2.TouchableOpacity,
547
566
  {
548
- backgroundColor: active ? colors.accent : colors.card,
549
- borderColor: active ? colors.accent : colors.border
550
- }
551
- ],
552
- children: [
553
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: s.catEmoji, children: c.emoji }),
554
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
555
- import_react_native2.Text,
567
+ onPress: () => setCategory(c.key),
568
+ activeOpacity: 0.7,
569
+ style: [
570
+ s.catBtn,
571
+ {
572
+ backgroundColor: active ? colors.accent : colors.card,
573
+ borderColor: active ? colors.accent : colors.border
574
+ }
575
+ ],
576
+ children: [
577
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: s.catEmoji, children: c.emoji }),
578
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
579
+ import_react_native2.Text,
580
+ {
581
+ style: [
582
+ s.catLabel,
583
+ { color: active ? "#fff" : colors.text }
584
+ ],
585
+ children: c.label
586
+ }
587
+ )
588
+ ]
589
+ },
590
+ c.key
591
+ );
592
+ }) }),
593
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
594
+ import_react_native2.TextInput,
595
+ {
596
+ style: [
597
+ s.input,
556
598
  {
557
- style: [
558
- s.catLabel,
559
- { color: active ? "#fff" : colors.text }
560
- ],
561
- children: c.label
599
+ backgroundColor: colors.card,
600
+ color: colors.text,
601
+ borderColor: colors.border
562
602
  }
563
- )
564
- ]
565
- },
566
- c.key
567
- );
568
- }) }),
569
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
570
- import_react_native2.TextInput,
571
- {
572
- style: [
573
- s.input,
603
+ ],
604
+ placeholder: "What happened?",
605
+ placeholderTextColor: colors.sub,
606
+ multiline: true,
607
+ textAlignVertical: "top",
608
+ value: description,
609
+ onChangeText: setDescription,
610
+ editable: phase === "form"
611
+ }
612
+ ),
613
+ activeScreenshot ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.View, { style: s.screenshotRow, children: [
614
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
615
+ import_react_native2.Image,
616
+ {
617
+ source: { uri: activeScreenshot },
618
+ style: s.screenshotThumb,
619
+ accessibilityLabel: "Attached screenshot"
620
+ }
621
+ ),
622
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.View, { style: s.screenshotMeta, children: [
623
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.screenshotLabel, { color: colors.text }], children: "Screenshot attached" }),
624
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.screenshotSub, { color: colors.sub }], children: "Helps the team see the issue" })
625
+ ] }),
626
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
627
+ import_react_native2.TouchableOpacity,
628
+ {
629
+ onPress: () => {
630
+ setScreenshotAttached(false);
631
+ onClearScreenshot?.();
632
+ },
633
+ hitSlop: { top: 8, bottom: 8, left: 8, right: 8 },
634
+ accessibilityLabel: "Remove screenshot",
635
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.screenshotRemove, { color: colors.sub }], children: "\u2715" })
636
+ }
637
+ )
638
+ ] }) : null,
639
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
640
+ import_react_native2.TouchableOpacity,
574
641
  {
575
- backgroundColor: colors.card,
576
- color: colors.text,
577
- borderColor: colors.border
642
+ onPress: handleSubmit,
643
+ disabled: !canSubmit,
644
+ activeOpacity: 0.8,
645
+ style: [
646
+ s.submitBtn,
647
+ { backgroundColor: canSubmit ? colors.accent : colors.disabled }
648
+ ],
649
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.submitText, !canSubmit && { color: colors.disabledText }], children: phase === "sending" ? "Sending\u2026" : "Submit" })
578
650
  }
579
- ],
580
- placeholder: "What happened?",
581
- placeholderTextColor: colors.sub,
582
- multiline: true,
583
- textAlignVertical: "top",
584
- value: description,
585
- onChangeText: setDescription,
586
- editable: phase === "form"
587
- }
588
- ),
589
- activeScreenshot ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.View, { style: s.screenshotRow, children: [
590
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
591
- import_react_native2.Image,
592
- {
593
- source: { uri: activeScreenshot },
594
- style: s.screenshotThumb,
595
- accessibilityLabel: "Attached screenshot"
596
- }
597
- ),
598
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.View, { style: s.screenshotMeta, children: [
599
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.screenshotLabel, { color: colors.text }], children: "Screenshot attached" }),
600
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.screenshotSub, { color: colors.sub }], children: "Helps the team see the issue" })
601
- ] }),
602
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
603
- import_react_native2.TouchableOpacity,
604
- {
605
- onPress: () => {
606
- setScreenshotAttached(false);
607
- onClearScreenshot?.();
608
- },
609
- hitSlop: { top: 8, bottom: 8, left: 8, right: 8 },
610
- accessibilityLabel: "Remove screenshot",
611
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.screenshotRemove, { color: colors.sub }], children: "\u2715" })
612
- }
613
- )
614
- ] }) : null,
615
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
616
- import_react_native2.TouchableOpacity,
617
- {
618
- onPress: handleSubmit,
619
- disabled: !canSubmit,
620
- activeOpacity: 0.8,
621
- style: [
622
- s.submitBtn,
623
- { backgroundColor: canSubmit ? colors.accent : colors.border }
624
- ],
625
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: s.submitText, children: phase === "sending" ? "Sending\u2026" : "Submit" })
626
- }
627
- )
628
- ] })
651
+ )
652
+ ]
653
+ }
654
+ )
629
655
  ]
630
656
  }
631
657
  )
@@ -801,15 +827,133 @@ var s = import_react_native2.StyleSheet.create({
801
827
  var import_react2 = require("react");
802
828
  var import_react_native3 = require("react-native");
803
829
  var import_jsx_runtime2 = require("react/jsx-runtime");
830
+ var FAB_SIZE = 56;
831
+ var EDGE_PADDING = 12;
832
+ function getScreenBounds() {
833
+ const { width, height } = import_react_native3.Dimensions.get("window");
834
+ return { width, height };
835
+ }
836
+ function clamp(value, min, max) {
837
+ return Math.min(Math.max(value, min), max);
838
+ }
839
+ var STORAGE_KEY = "mushi:fab-pos";
840
+ async function loadStoredPos() {
841
+ try {
842
+ const AsyncStorage = (await import("@react-native-async-storage/async-storage")).default;
843
+ const raw = await AsyncStorage.getItem(STORAGE_KEY);
844
+ if (!raw) return null;
845
+ return JSON.parse(raw);
846
+ } catch {
847
+ return null;
848
+ }
849
+ }
850
+ async function storePos(pos) {
851
+ try {
852
+ const AsyncStorage = (await import("@react-native-async-storage/async-storage")).default;
853
+ await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(pos));
854
+ } catch {
855
+ }
856
+ }
804
857
  var MushiFloatingButton = ({
805
858
  onPress,
806
859
  position = "bottom-right",
807
- inset
860
+ inset,
861
+ config,
862
+ accent
808
863
  }) => {
809
864
  const scheme = (0, import_react_native3.useColorScheme)();
810
865
  const dark = scheme === "dark";
866
+ const draggable = config?.draggable ?? false;
867
+ const snapToEdge = config?.snapToEdge ?? true;
868
+ const persist = config?.persist ?? false;
869
+ const baseBottom = inset?.bottom ?? (import_react_native3.Platform.OS === "ios" ? 50 : 28);
870
+ const baseX = position === "bottom-left" ? inset?.left ?? EDGE_PADDING : (() => {
871
+ const { width } = getScreenBounds();
872
+ return width - FAB_SIZE - (inset?.right ?? EDGE_PADDING);
873
+ })();
811
874
  const scale = (0, import_react2.useRef)(new import_react_native3.Animated.Value(1)).current;
875
+ const posX = (0, import_react2.useRef)(new import_react_native3.Animated.Value(baseX)).current;
876
+ const posY = (0, import_react2.useRef)(new import_react_native3.Animated.Value(0)).current;
877
+ const currentX = (0, import_react2.useRef)(baseX);
878
+ const currentY = (0, import_react2.useRef)(0);
879
+ const isDragging = (0, import_react2.useRef)(false);
880
+ const accentColor = accent ?? (dark ? "#0a84ff" : "#007aff");
881
+ (0, import_react2.useEffect)(() => {
882
+ if (!persist) return;
883
+ void loadStoredPos().then((pos) => {
884
+ if (!pos) return;
885
+ currentX.current = pos.x;
886
+ currentY.current = pos.y;
887
+ posX.setValue(pos.x);
888
+ posY.setValue(pos.y);
889
+ });
890
+ }, [persist, posX, posY]);
891
+ const snapAndPersist = (0, import_react2.useCallback)(
892
+ (x, y) => {
893
+ const { width, height } = getScreenBounds();
894
+ let snappedX = x;
895
+ if (snapToEdge) {
896
+ const midX = width / 2;
897
+ snappedX = x + FAB_SIZE / 2 < midX ? EDGE_PADDING : width - FAB_SIZE - EDGE_PADDING;
898
+ }
899
+ const clampedX = clamp(snappedX, EDGE_PADDING, width - FAB_SIZE - EDGE_PADDING);
900
+ const clampedY = clamp(y, -(height - FAB_SIZE - baseBottom - 60), 0);
901
+ currentX.current = clampedX;
902
+ currentY.current = clampedY;
903
+ import_react_native3.Animated.spring(posX, {
904
+ toValue: clampedX,
905
+ useNativeDriver: false,
906
+ damping: 18,
907
+ stiffness: 220
908
+ }).start();
909
+ import_react_native3.Animated.spring(posY, {
910
+ toValue: clampedY,
911
+ useNativeDriver: false,
912
+ damping: 18,
913
+ stiffness: 220
914
+ }).start();
915
+ if (persist) void storePos({ x: clampedX, y: clampedY });
916
+ },
917
+ [snapToEdge, persist, posX, posY, baseBottom]
918
+ );
919
+ const panResponder = (0, import_react2.useRef)(
920
+ draggable ? import_react_native3.PanResponder.create({
921
+ onStartShouldSetPanResponder: () => true,
922
+ onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 6 || Math.abs(g.dy) > 6,
923
+ onPanResponderGrant: () => {
924
+ isDragging.current = false;
925
+ posX.stopAnimation();
926
+ posY.stopAnimation();
927
+ },
928
+ onPanResponderMove: (_, g) => {
929
+ if (Math.abs(g.dx) > 6 || Math.abs(g.dy) > 6) {
930
+ isDragging.current = true;
931
+ scale.setValue(1);
932
+ }
933
+ const { width, height } = getScreenBounds();
934
+ const newX = clamp(
935
+ currentX.current + g.dx,
936
+ EDGE_PADDING,
937
+ width - FAB_SIZE - EDGE_PADDING
938
+ );
939
+ const newY = clamp(
940
+ currentY.current + g.dy,
941
+ -(height - FAB_SIZE - baseBottom - 60),
942
+ 0
943
+ );
944
+ posX.setValue(newX);
945
+ posY.setValue(newY);
946
+ },
947
+ onPanResponderRelease: (_, g) => {
948
+ if (isDragging.current) {
949
+ snapAndPersist(currentX.current + g.dx, currentY.current + g.dy);
950
+ }
951
+ isDragging.current = false;
952
+ }
953
+ }) : import_react_native3.PanResponder.create({})
954
+ ).current;
812
955
  const handlePressIn = (0, import_react2.useCallback)(() => {
956
+ if (isDragging.current) return;
813
957
  import_react_native3.Animated.spring(scale, {
814
958
  toValue: 0.85,
815
959
  useNativeDriver: true,
@@ -824,39 +968,35 @@ var MushiFloatingButton = ({
824
968
  damping: 10,
825
969
  stiffness: 250
826
970
  }).start();
827
- }, [scale]);
828
- const positionStyle = position === "bottom-left" ? { left: inset?.left ?? 20 } : { right: inset?.right ?? 20 };
971
+ if (!isDragging.current) onPress();
972
+ }, [scale, onPress]);
829
973
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
830
- import_react_native3.TouchableWithoutFeedback,
974
+ import_react_native3.Animated.View,
831
975
  {
832
- onPress,
833
- onPressIn: handlePressIn,
834
- onPressOut: handlePressOut,
835
- children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
836
- import_react_native3.Animated.View,
976
+ ...draggable ? panResponder.panHandlers : {},
977
+ style: [
978
+ s2.fab,
837
979
  {
838
- style: [
839
- s2.fab,
840
- positionStyle,
841
- { bottom: inset?.bottom ?? (import_react_native3.Platform.OS === "ios" ? 50 : 28) },
842
- {
843
- backgroundColor: dark ? "#0a84ff" : "#007aff",
844
- transform: [{ scale }],
845
- shadowColor: dark ? "#0a84ff" : "#000"
846
- }
847
- ],
848
- children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native3.Animated.Text, { style: s2.emoji, children: "\u{1F41B}" })
980
+ left: posX,
981
+ bottom: import_react_native3.Animated.add(new import_react_native3.Animated.Value(baseBottom), import_react_native3.Animated.multiply(posY, -1)),
982
+ backgroundColor: accentColor,
983
+ shadowColor: dark ? accentColor : "#000",
984
+ transform: [{ scale }]
849
985
  }
850
- )
986
+ ],
987
+ onStartShouldSetResponder: () => !draggable,
988
+ onResponderGrant: handlePressIn,
989
+ onResponderRelease: handlePressOut,
990
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native3.Animated.Text, { style: s2.emoji, children: "\u{1F41B}" })
851
991
  }
852
992
  );
853
993
  };
854
994
  var s2 = import_react_native3.StyleSheet.create({
855
995
  fab: {
856
996
  position: "absolute",
857
- width: 56,
858
- height: 56,
859
- borderRadius: 28,
997
+ width: FAB_SIZE,
998
+ height: FAB_SIZE,
999
+ borderRadius: FAB_SIZE / 2,
860
1000
  alignItems: "center",
861
1001
  justifyContent: "center",
862
1002
  zIndex: 9999,
package/dist/index.d.cts CHANGED
@@ -188,29 +188,25 @@ interface MushiBottomSheetProps {
188
188
  declare const MushiBottomSheet: FC<MushiBottomSheetProps>;
189
189
 
190
190
  /**
191
- * FILE: MushiFloatingButton.tsx
192
- * PURPOSE: Floating action button (FAB) that triggers the Mushi bug-report bottom sheet
191
+ * MushiFloatingButton — draggable, repositionable FAB for the Mushi SDK.
193
192
  *
194
- * OVERVIEW:
195
- * - Renders a persistent 🐛 button at bottom-right or bottom-left
196
- * - Animated press-scale effect via Animated.spring
197
- * - Position configurable through `buttonPosition` prop
198
- *
199
- * DEPENDENCIES:
200
- * - React Native built-in APIs only
201
- *
202
- * USAGE:
203
- * - Rendered automatically by MushiProvider when widget.trigger is 'button' or 'both'
204
- * - Can be used standalone: <MushiFloatingButton onPress={…} />
205
- *
206
- * TECHNICAL DETAILS:
207
- * - Uses Animated.Value for scale, driven by onPressIn / onPressOut
208
- * - Safe-area aware: extra bottom padding on iOS
209
- *
210
- * NOTES:
211
- * - Keeps zIndex high (9999) so it floats above app content
193
+ * Supports:
194
+ * - PanResponder drag with tap-vs-drag threshold (6 px)
195
+ * - Clamp to screen bounds with safe-area respect
196
+ * - Optional edge-snap on release (`snapToEdge`)
197
+ * - Persist position to AsyncStorage (opt-in via `persist` config)
198
+ * - Press-in / press-out spring scale animation
199
+ * - Dark/light adaptive colours
212
200
  */
213
201
 
202
+ interface MushiFloatingButtonConfig {
203
+ /** Allow the FAB to be dragged. Default: false. */
204
+ draggable?: boolean;
205
+ /** Snap FAB to the nearest vertical edge on release. Default: true. */
206
+ snapToEdge?: boolean;
207
+ /** Persist FAB position across app restarts via AsyncStorage. Default: false. */
208
+ persist?: boolean;
209
+ }
214
210
  interface MushiFloatingButtonProps {
215
211
  onPress: () => void;
216
212
  position?: 'bottom-right' | 'bottom-left';
@@ -219,6 +215,10 @@ interface MushiFloatingButtonProps {
219
215
  left?: number;
220
216
  right?: number;
221
217
  };
218
+ /** Draggable + persist config */
219
+ config?: MushiFloatingButtonConfig;
220
+ /** Accent color (hex) — falls back to platform blue */
221
+ accent?: string;
222
222
  }
223
223
  declare const MushiFloatingButton: FC<MushiFloatingButtonProps>;
224
224
 
package/dist/index.d.ts CHANGED
@@ -188,29 +188,25 @@ interface MushiBottomSheetProps {
188
188
  declare const MushiBottomSheet: FC<MushiBottomSheetProps>;
189
189
 
190
190
  /**
191
- * FILE: MushiFloatingButton.tsx
192
- * PURPOSE: Floating action button (FAB) that triggers the Mushi bug-report bottom sheet
191
+ * MushiFloatingButton — draggable, repositionable FAB for the Mushi SDK.
193
192
  *
194
- * OVERVIEW:
195
- * - Renders a persistent 🐛 button at bottom-right or bottom-left
196
- * - Animated press-scale effect via Animated.spring
197
- * - Position configurable through `buttonPosition` prop
198
- *
199
- * DEPENDENCIES:
200
- * - React Native built-in APIs only
201
- *
202
- * USAGE:
203
- * - Rendered automatically by MushiProvider when widget.trigger is 'button' or 'both'
204
- * - Can be used standalone: <MushiFloatingButton onPress={…} />
205
- *
206
- * TECHNICAL DETAILS:
207
- * - Uses Animated.Value for scale, driven by onPressIn / onPressOut
208
- * - Safe-area aware: extra bottom padding on iOS
209
- *
210
- * NOTES:
211
- * - Keeps zIndex high (9999) so it floats above app content
193
+ * Supports:
194
+ * - PanResponder drag with tap-vs-drag threshold (6 px)
195
+ * - Clamp to screen bounds with safe-area respect
196
+ * - Optional edge-snap on release (`snapToEdge`)
197
+ * - Persist position to AsyncStorage (opt-in via `persist` config)
198
+ * - Press-in / press-out spring scale animation
199
+ * - Dark/light adaptive colours
212
200
  */
213
201
 
202
+ interface MushiFloatingButtonConfig {
203
+ /** Allow the FAB to be dragged. Default: false. */
204
+ draggable?: boolean;
205
+ /** Snap FAB to the nearest vertical edge on release. Default: true. */
206
+ snapToEdge?: boolean;
207
+ /** Persist FAB position across app restarts via AsyncStorage. Default: false. */
208
+ persist?: boolean;
209
+ }
214
210
  interface MushiFloatingButtonProps {
215
211
  onPress: () => void;
216
212
  position?: 'bottom-right' | 'bottom-left';
@@ -219,6 +215,10 @@ interface MushiFloatingButtonProps {
219
215
  left?: number;
220
216
  right?: number;
221
217
  };
218
+ /** Draggable + persist config */
219
+ config?: MushiFloatingButtonConfig;
220
+ /** Accent color (hex) — falls back to platform blue */
221
+ accent?: string;
222
222
  }
223
223
  declare const MushiFloatingButton: FC<MushiFloatingButtonProps>;
224
224
 
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
9
9
  import {
10
10
  createContext,
11
11
  useContext,
12
- useEffect as useEffect2,
12
+ useEffect as useEffect3,
13
13
  useRef as useRef3,
14
14
  useState as useState2,
15
15
  useCallback as useCallback3,
@@ -306,6 +306,7 @@ import {
306
306
  Dimensions as Dimensions2,
307
307
  Platform as Platform2,
308
308
  KeyboardAvoidingView,
309
+ ScrollView,
309
310
  useColorScheme
310
311
  } from "react-native";
311
312
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
@@ -458,13 +459,14 @@ var MushiBottomSheet = ({
458
459
  }
459
460
  };
460
461
  const activeScreenshot = screenshotDataUrl && screenshotAttached ? screenshotDataUrl : null;
461
- const colors = dark ? { bg: "#1c1c1e", text: "#f2f2f7", sub: "#8e8e93", card: "#2c2c2e", accent: "#0a84ff", border: "#38383a", backdrop: "rgba(0,0,0,0.6)" } : { bg: "#ffffff", text: "#1c1c1e", sub: "#8e8e93", card: "#f2f2f7", accent: "#007aff", border: "#e5e5ea", backdrop: "rgba(0,0,0,0.35)" };
462
+ const colors = dark ? { bg: "#1c1c1e", text: "#f2f2f7", sub: "#8e8e93", card: "#2c2c2e", accent: "#0a84ff", border: "#38383a", backdrop: "rgba(0,0,0,0.6)", disabled: "#3a3a3c", disabledText: "#636366" } : { bg: "#ffffff", text: "#1c1c1e", sub: "#8e8e93", card: "#f2f2f7", accent: "#007aff", border: "#e5e5ea", backdrop: "rgba(0,0,0,0.35)", disabled: "#d1d1d6", disabledText: "#8e8e93" };
462
463
  const canSubmit = !!category && description.trim().length > 0 && phase === "form";
463
464
  return /* @__PURE__ */ jsx(Modal, { visible, transparent: true, animationType: "none", statusBarTranslucent: true, children: /* @__PURE__ */ jsxs(
464
465
  KeyboardAvoidingView,
465
466
  {
466
467
  style: s.flex,
467
- behavior: Platform2.OS === "ios" ? "padding" : void 0,
468
+ behavior: Platform2.OS === "ios" ? "padding" : "height",
469
+ keyboardVerticalOffset: Platform2.OS === "ios" ? 0 : 20,
468
470
  children: [
469
471
  /* @__PURE__ */ jsx(
470
472
  Animated.View,
@@ -482,7 +484,7 @@ var MushiBottomSheet = ({
482
484
  ],
483
485
  children: [
484
486
  /* @__PURE__ */ jsx(View, { ...panResponder.panHandlers, style: s.handleArea, children: /* @__PURE__ */ jsx(View, { style: [s.handle, { backgroundColor: colors.sub }] }) }),
485
- /* @__PURE__ */ jsx(View, { style: s.tabRow, children: ["report", "inbox"].map((tab) => /* @__PURE__ */ jsx(
487
+ /* @__PURE__ */ jsx(View, { style: s.tabRow, children: ["report", "inbox", "community"].map((tab) => /* @__PURE__ */ jsx(
486
488
  TouchableOpacity,
487
489
  {
488
490
  onPress: () => {
@@ -491,11 +493,11 @@ var MushiBottomSheet = ({
491
493
  else setSelectedReportId(null);
492
494
  },
493
495
  style: [s.tabBtn, sheetTab === tab && { borderBottomColor: colors.accent }],
494
- children: /* @__PURE__ */ jsx(Text, { style: [s.tabLabel, { color: sheetTab === tab ? colors.accent : colors.sub }], children: tab === "report" ? "Report" : "Inbox" })
496
+ children: /* @__PURE__ */ jsx(Text, { style: [s.tabLabel, { color: sheetTab === tab ? colors.accent : colors.sub }], children: tab === "report" ? "Report" : tab === "inbox" ? "Inbox" : "\u{1F310}" })
495
497
  },
496
498
  tab
497
499
  )) }),
498
- sheetTab === "inbox" ? /* @__PURE__ */ jsx(View, { style: s.body, children: inboxLoading ? /* @__PURE__ */ jsx(Text, { style: { color: colors.sub }, children: "Loading\u2026" }) : selectedReportId ? /* @__PURE__ */ jsxs(Fragment, { children: [
500
+ sheetTab === "inbox" ? /* @__PURE__ */ jsx(ScrollView, { style: s.body, keyboardShouldPersistTaps: "handled", children: inboxLoading ? /* @__PURE__ */ jsx(Text, { style: { color: colors.sub }, children: "Loading\u2026" }) : selectedReportId ? /* @__PURE__ */ jsxs(Fragment, { children: [
499
501
  /* @__PURE__ */ jsx(TouchableOpacity, { onPress: () => setSelectedReportId(null), children: /* @__PURE__ */ jsx(Text, { style: { color: colors.accent, marginBottom: 8 }, children: "\u2190 Back" }) }),
500
502
  threadComments.map((c) => /* @__PURE__ */ jsxs(View, { style: [s.threadBubble, { backgroundColor: colors.card }], children: [
501
503
  /* @__PURE__ */ jsx(Text, { style: { color: colors.sub, fontSize: 11 }, children: c.author_kind }),
@@ -521,102 +523,127 @@ var MushiBottomSheet = ({
521
523
  ] }) : inboxReports.map((r) => /* @__PURE__ */ jsxs(TouchableOpacity, { style: [s.inboxRow, { borderColor: colors.border }], onPress: () => openThread(r.id), children: [
522
524
  /* @__PURE__ */ jsx(Text, { style: { color: colors.text, fontWeight: "600" }, numberOfLines: 1, children: (r.summary ?? r.description ?? "Report").slice(0, 60) }),
523
525
  /* @__PURE__ */ jsx(Text, { style: { color: colors.sub, fontSize: 11 }, children: r.status })
524
- ] }, r.id)) }) : phase === "sent" ? /* @__PURE__ */ jsxs(View, { style: s.sentWrap, children: [
526
+ ] }, r.id)) }) : sheetTab === "community" ? /* @__PURE__ */ jsxs(ScrollView, { style: s.body, keyboardShouldPersistTaps: "handled", children: [
527
+ /* @__PURE__ */ jsx(Text, { style: [s.title, { color: colors.text }], children: "\u{1F310} Mushi Community" }),
528
+ /* @__PURE__ */ jsx(Text, { style: { color: colors.sub, fontSize: 13, marginBottom: 16, lineHeight: 18 }, children: "Sign in to track your reports across apps and see the global leaderboard. No password needed." }),
529
+ /* @__PURE__ */ jsx(
530
+ TextInput,
531
+ {
532
+ style: [s.input, { backgroundColor: colors.card, color: colors.text, borderColor: colors.border, minHeight: 48 }],
533
+ placeholder: "you@example.com",
534
+ placeholderTextColor: colors.sub,
535
+ keyboardType: "email-address",
536
+ autoCapitalize: "none",
537
+ autoCorrect: false,
538
+ textContentType: "emailAddress"
539
+ }
540
+ ),
541
+ /* @__PURE__ */ jsx(TouchableOpacity, { style: [s.submitBtn, { backgroundColor: colors.accent }], children: /* @__PURE__ */ jsx(Text, { style: s.submitText, children: "Send sign-in link \u2192" }) }),
542
+ /* @__PURE__ */ jsx(Text, { style: { color: colors.sub, fontSize: 11, textAlign: "center", marginTop: 12 }, children: "We'll email you a one-tap sign-in link. No password, ever." })
543
+ ] }) : phase === "sent" ? /* @__PURE__ */ jsxs(View, { style: s.sentWrap, children: [
525
544
  /* @__PURE__ */ jsx(Text, { style: [s.sentEmoji], children: "\u2705" }),
526
545
  /* @__PURE__ */ jsx(Text, { style: [s.sentText, { color: colors.text }], children: "Report sent!" })
527
- ] }) : /* @__PURE__ */ jsxs(View, { style: s.body, children: [
528
- /* @__PURE__ */ jsx(Text, { style: [s.title, { color: colors.text }], children: "Report an issue" }),
529
- /* @__PURE__ */ jsx(View, { style: s.catRow, children: CATEGORIES.map((c) => {
530
- const active = category === c.key;
531
- return /* @__PURE__ */ jsxs(
532
- TouchableOpacity,
533
- {
534
- onPress: () => setCategory(c.key),
535
- activeOpacity: 0.7,
536
- style: [
537
- s.catBtn,
546
+ ] }) : /* @__PURE__ */ jsxs(
547
+ ScrollView,
548
+ {
549
+ style: s.body,
550
+ keyboardShouldPersistTaps: "handled",
551
+ contentContainerStyle: { paddingBottom: 24 },
552
+ children: [
553
+ /* @__PURE__ */ jsx(Text, { style: [s.title, { color: colors.text }], children: "Report an issue" }),
554
+ /* @__PURE__ */ jsx(View, { style: s.catRow, children: CATEGORIES.map((c) => {
555
+ const active = category === c.key;
556
+ return /* @__PURE__ */ jsxs(
557
+ TouchableOpacity,
538
558
  {
539
- backgroundColor: active ? colors.accent : colors.card,
540
- borderColor: active ? colors.accent : colors.border
541
- }
542
- ],
543
- children: [
544
- /* @__PURE__ */ jsx(Text, { style: s.catEmoji, children: c.emoji }),
545
- /* @__PURE__ */ jsx(
546
- Text,
559
+ onPress: () => setCategory(c.key),
560
+ activeOpacity: 0.7,
561
+ style: [
562
+ s.catBtn,
563
+ {
564
+ backgroundColor: active ? colors.accent : colors.card,
565
+ borderColor: active ? colors.accent : colors.border
566
+ }
567
+ ],
568
+ children: [
569
+ /* @__PURE__ */ jsx(Text, { style: s.catEmoji, children: c.emoji }),
570
+ /* @__PURE__ */ jsx(
571
+ Text,
572
+ {
573
+ style: [
574
+ s.catLabel,
575
+ { color: active ? "#fff" : colors.text }
576
+ ],
577
+ children: c.label
578
+ }
579
+ )
580
+ ]
581
+ },
582
+ c.key
583
+ );
584
+ }) }),
585
+ /* @__PURE__ */ jsx(
586
+ TextInput,
587
+ {
588
+ style: [
589
+ s.input,
547
590
  {
548
- style: [
549
- s.catLabel,
550
- { color: active ? "#fff" : colors.text }
551
- ],
552
- children: c.label
591
+ backgroundColor: colors.card,
592
+ color: colors.text,
593
+ borderColor: colors.border
553
594
  }
554
- )
555
- ]
556
- },
557
- c.key
558
- );
559
- }) }),
560
- /* @__PURE__ */ jsx(
561
- TextInput,
562
- {
563
- style: [
564
- s.input,
595
+ ],
596
+ placeholder: "What happened?",
597
+ placeholderTextColor: colors.sub,
598
+ multiline: true,
599
+ textAlignVertical: "top",
600
+ value: description,
601
+ onChangeText: setDescription,
602
+ editable: phase === "form"
603
+ }
604
+ ),
605
+ activeScreenshot ? /* @__PURE__ */ jsxs(View, { style: s.screenshotRow, children: [
606
+ /* @__PURE__ */ jsx(
607
+ Image,
608
+ {
609
+ source: { uri: activeScreenshot },
610
+ style: s.screenshotThumb,
611
+ accessibilityLabel: "Attached screenshot"
612
+ }
613
+ ),
614
+ /* @__PURE__ */ jsxs(View, { style: s.screenshotMeta, children: [
615
+ /* @__PURE__ */ jsx(Text, { style: [s.screenshotLabel, { color: colors.text }], children: "Screenshot attached" }),
616
+ /* @__PURE__ */ jsx(Text, { style: [s.screenshotSub, { color: colors.sub }], children: "Helps the team see the issue" })
617
+ ] }),
618
+ /* @__PURE__ */ jsx(
619
+ TouchableOpacity,
620
+ {
621
+ onPress: () => {
622
+ setScreenshotAttached(false);
623
+ onClearScreenshot?.();
624
+ },
625
+ hitSlop: { top: 8, bottom: 8, left: 8, right: 8 },
626
+ accessibilityLabel: "Remove screenshot",
627
+ children: /* @__PURE__ */ jsx(Text, { style: [s.screenshotRemove, { color: colors.sub }], children: "\u2715" })
628
+ }
629
+ )
630
+ ] }) : null,
631
+ /* @__PURE__ */ jsx(
632
+ TouchableOpacity,
565
633
  {
566
- backgroundColor: colors.card,
567
- color: colors.text,
568
- borderColor: colors.border
634
+ onPress: handleSubmit,
635
+ disabled: !canSubmit,
636
+ activeOpacity: 0.8,
637
+ style: [
638
+ s.submitBtn,
639
+ { backgroundColor: canSubmit ? colors.accent : colors.disabled }
640
+ ],
641
+ children: /* @__PURE__ */ jsx(Text, { style: [s.submitText, !canSubmit && { color: colors.disabledText }], children: phase === "sending" ? "Sending\u2026" : "Submit" })
569
642
  }
570
- ],
571
- placeholder: "What happened?",
572
- placeholderTextColor: colors.sub,
573
- multiline: true,
574
- textAlignVertical: "top",
575
- value: description,
576
- onChangeText: setDescription,
577
- editable: phase === "form"
578
- }
579
- ),
580
- activeScreenshot ? /* @__PURE__ */ jsxs(View, { style: s.screenshotRow, children: [
581
- /* @__PURE__ */ jsx(
582
- Image,
583
- {
584
- source: { uri: activeScreenshot },
585
- style: s.screenshotThumb,
586
- accessibilityLabel: "Attached screenshot"
587
- }
588
- ),
589
- /* @__PURE__ */ jsxs(View, { style: s.screenshotMeta, children: [
590
- /* @__PURE__ */ jsx(Text, { style: [s.screenshotLabel, { color: colors.text }], children: "Screenshot attached" }),
591
- /* @__PURE__ */ jsx(Text, { style: [s.screenshotSub, { color: colors.sub }], children: "Helps the team see the issue" })
592
- ] }),
593
- /* @__PURE__ */ jsx(
594
- TouchableOpacity,
595
- {
596
- onPress: () => {
597
- setScreenshotAttached(false);
598
- onClearScreenshot?.();
599
- },
600
- hitSlop: { top: 8, bottom: 8, left: 8, right: 8 },
601
- accessibilityLabel: "Remove screenshot",
602
- children: /* @__PURE__ */ jsx(Text, { style: [s.screenshotRemove, { color: colors.sub }], children: "\u2715" })
603
- }
604
- )
605
- ] }) : null,
606
- /* @__PURE__ */ jsx(
607
- TouchableOpacity,
608
- {
609
- onPress: handleSubmit,
610
- disabled: !canSubmit,
611
- activeOpacity: 0.8,
612
- style: [
613
- s.submitBtn,
614
- { backgroundColor: canSubmit ? colors.accent : colors.border }
615
- ],
616
- children: /* @__PURE__ */ jsx(Text, { style: s.submitText, children: phase === "sending" ? "Sending\u2026" : "Submit" })
617
- }
618
- )
619
- ] })
643
+ )
644
+ ]
645
+ }
646
+ )
620
647
  ]
621
648
  }
622
649
  )
@@ -789,24 +816,143 @@ var s = StyleSheet.create({
789
816
  });
790
817
 
791
818
  // src/components/MushiFloatingButton.tsx
792
- import { useRef as useRef2, useCallback as useCallback2 } from "react";
819
+ import { useRef as useRef2, useCallback as useCallback2, useEffect as useEffect2 } from "react";
793
820
  import {
794
821
  Animated as Animated2,
795
- TouchableWithoutFeedback,
822
+ PanResponder as PanResponder2,
796
823
  StyleSheet as StyleSheet2,
797
824
  Platform as Platform3,
825
+ Dimensions as Dimensions3,
798
826
  useColorScheme as useColorScheme2
799
827
  } from "react-native";
800
828
  import { jsx as jsx2 } from "react/jsx-runtime";
829
+ var FAB_SIZE = 56;
830
+ var EDGE_PADDING = 12;
831
+ function getScreenBounds() {
832
+ const { width, height } = Dimensions3.get("window");
833
+ return { width, height };
834
+ }
835
+ function clamp(value, min, max) {
836
+ return Math.min(Math.max(value, min), max);
837
+ }
838
+ var STORAGE_KEY = "mushi:fab-pos";
839
+ async function loadStoredPos() {
840
+ try {
841
+ const AsyncStorage = (await import("@react-native-async-storage/async-storage")).default;
842
+ const raw = await AsyncStorage.getItem(STORAGE_KEY);
843
+ if (!raw) return null;
844
+ return JSON.parse(raw);
845
+ } catch {
846
+ return null;
847
+ }
848
+ }
849
+ async function storePos(pos) {
850
+ try {
851
+ const AsyncStorage = (await import("@react-native-async-storage/async-storage")).default;
852
+ await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(pos));
853
+ } catch {
854
+ }
855
+ }
801
856
  var MushiFloatingButton = ({
802
857
  onPress,
803
858
  position = "bottom-right",
804
- inset
859
+ inset,
860
+ config,
861
+ accent
805
862
  }) => {
806
863
  const scheme = useColorScheme2();
807
864
  const dark = scheme === "dark";
865
+ const draggable = config?.draggable ?? false;
866
+ const snapToEdge = config?.snapToEdge ?? true;
867
+ const persist = config?.persist ?? false;
868
+ const baseBottom = inset?.bottom ?? (Platform3.OS === "ios" ? 50 : 28);
869
+ const baseX = position === "bottom-left" ? inset?.left ?? EDGE_PADDING : (() => {
870
+ const { width } = getScreenBounds();
871
+ return width - FAB_SIZE - (inset?.right ?? EDGE_PADDING);
872
+ })();
808
873
  const scale = useRef2(new Animated2.Value(1)).current;
874
+ const posX = useRef2(new Animated2.Value(baseX)).current;
875
+ const posY = useRef2(new Animated2.Value(0)).current;
876
+ const currentX = useRef2(baseX);
877
+ const currentY = useRef2(0);
878
+ const isDragging = useRef2(false);
879
+ const accentColor = accent ?? (dark ? "#0a84ff" : "#007aff");
880
+ useEffect2(() => {
881
+ if (!persist) return;
882
+ void loadStoredPos().then((pos) => {
883
+ if (!pos) return;
884
+ currentX.current = pos.x;
885
+ currentY.current = pos.y;
886
+ posX.setValue(pos.x);
887
+ posY.setValue(pos.y);
888
+ });
889
+ }, [persist, posX, posY]);
890
+ const snapAndPersist = useCallback2(
891
+ (x, y) => {
892
+ const { width, height } = getScreenBounds();
893
+ let snappedX = x;
894
+ if (snapToEdge) {
895
+ const midX = width / 2;
896
+ snappedX = x + FAB_SIZE / 2 < midX ? EDGE_PADDING : width - FAB_SIZE - EDGE_PADDING;
897
+ }
898
+ const clampedX = clamp(snappedX, EDGE_PADDING, width - FAB_SIZE - EDGE_PADDING);
899
+ const clampedY = clamp(y, -(height - FAB_SIZE - baseBottom - 60), 0);
900
+ currentX.current = clampedX;
901
+ currentY.current = clampedY;
902
+ Animated2.spring(posX, {
903
+ toValue: clampedX,
904
+ useNativeDriver: false,
905
+ damping: 18,
906
+ stiffness: 220
907
+ }).start();
908
+ Animated2.spring(posY, {
909
+ toValue: clampedY,
910
+ useNativeDriver: false,
911
+ damping: 18,
912
+ stiffness: 220
913
+ }).start();
914
+ if (persist) void storePos({ x: clampedX, y: clampedY });
915
+ },
916
+ [snapToEdge, persist, posX, posY, baseBottom]
917
+ );
918
+ const panResponder = useRef2(
919
+ draggable ? PanResponder2.create({
920
+ onStartShouldSetPanResponder: () => true,
921
+ onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 6 || Math.abs(g.dy) > 6,
922
+ onPanResponderGrant: () => {
923
+ isDragging.current = false;
924
+ posX.stopAnimation();
925
+ posY.stopAnimation();
926
+ },
927
+ onPanResponderMove: (_, g) => {
928
+ if (Math.abs(g.dx) > 6 || Math.abs(g.dy) > 6) {
929
+ isDragging.current = true;
930
+ scale.setValue(1);
931
+ }
932
+ const { width, height } = getScreenBounds();
933
+ const newX = clamp(
934
+ currentX.current + g.dx,
935
+ EDGE_PADDING,
936
+ width - FAB_SIZE - EDGE_PADDING
937
+ );
938
+ const newY = clamp(
939
+ currentY.current + g.dy,
940
+ -(height - FAB_SIZE - baseBottom - 60),
941
+ 0
942
+ );
943
+ posX.setValue(newX);
944
+ posY.setValue(newY);
945
+ },
946
+ onPanResponderRelease: (_, g) => {
947
+ if (isDragging.current) {
948
+ snapAndPersist(currentX.current + g.dx, currentY.current + g.dy);
949
+ }
950
+ isDragging.current = false;
951
+ }
952
+ }) : PanResponder2.create({})
953
+ ).current;
809
954
  const handlePressIn = useCallback2(() => {
955
+ if (isDragging.current) return;
810
956
  Animated2.spring(scale, {
811
957
  toValue: 0.85,
812
958
  useNativeDriver: true,
@@ -821,39 +967,35 @@ var MushiFloatingButton = ({
821
967
  damping: 10,
822
968
  stiffness: 250
823
969
  }).start();
824
- }, [scale]);
825
- const positionStyle = position === "bottom-left" ? { left: inset?.left ?? 20 } : { right: inset?.right ?? 20 };
970
+ if (!isDragging.current) onPress();
971
+ }, [scale, onPress]);
826
972
  return /* @__PURE__ */ jsx2(
827
- TouchableWithoutFeedback,
973
+ Animated2.View,
828
974
  {
829
- onPress,
830
- onPressIn: handlePressIn,
831
- onPressOut: handlePressOut,
832
- children: /* @__PURE__ */ jsx2(
833
- Animated2.View,
975
+ ...draggable ? panResponder.panHandlers : {},
976
+ style: [
977
+ s2.fab,
834
978
  {
835
- style: [
836
- s2.fab,
837
- positionStyle,
838
- { bottom: inset?.bottom ?? (Platform3.OS === "ios" ? 50 : 28) },
839
- {
840
- backgroundColor: dark ? "#0a84ff" : "#007aff",
841
- transform: [{ scale }],
842
- shadowColor: dark ? "#0a84ff" : "#000"
843
- }
844
- ],
845
- children: /* @__PURE__ */ jsx2(Animated2.Text, { style: s2.emoji, children: "\u{1F41B}" })
979
+ left: posX,
980
+ bottom: Animated2.add(new Animated2.Value(baseBottom), Animated2.multiply(posY, -1)),
981
+ backgroundColor: accentColor,
982
+ shadowColor: dark ? accentColor : "#000",
983
+ transform: [{ scale }]
846
984
  }
847
- )
985
+ ],
986
+ onStartShouldSetResponder: () => !draggable,
987
+ onResponderGrant: handlePressIn,
988
+ onResponderRelease: handlePressOut,
989
+ children: /* @__PURE__ */ jsx2(Animated2.Text, { style: s2.emoji, children: "\u{1F41B}" })
848
990
  }
849
991
  );
850
992
  };
851
993
  var s2 = StyleSheet2.create({
852
994
  fab: {
853
995
  position: "absolute",
854
- width: 56,
855
- height: 56,
856
- borderRadius: 28,
996
+ width: FAB_SIZE,
997
+ height: FAB_SIZE,
998
+ borderRadius: FAB_SIZE / 2,
857
999
  alignItems: "center",
858
1000
  justifyContent: "center",
859
1001
  zIndex: 9999,
@@ -895,7 +1037,7 @@ function MushiProvider({ children, ...config }) {
895
1037
  console.warn("[MushiProvider] `endpoint` is deprecated \u2014 use `apiEndpoint` instead.");
896
1038
  }
897
1039
  const apiEndpoint = rawEndpoint ?? DEFAULT_API_ENDPOINT;
898
- useEffect2(() => {
1040
+ useEffect3(() => {
899
1041
  if (config.capture?.console !== false) {
900
1042
  consoleRef.current = setupConsoleCapture(config.capture?.maxConsoleEntries);
901
1043
  }
@@ -944,7 +1086,7 @@ function MushiProvider({ children, ...config }) {
944
1086
  networkRef.current?.restore();
945
1087
  };
946
1088
  }, []);
947
- useEffect2(() => {
1089
+ useEffect3(() => {
948
1090
  let unsubscribe;
949
1091
  try {
950
1092
  const NetInfo = __require("@react-native-community/netinfo").default;
@@ -979,7 +1121,7 @@ function MushiProvider({ children, ...config }) {
979
1121
  }, []);
980
1122
  const close = useCallback3(() => setSheetVisible(false), []);
981
1123
  const attachTo = useCallback3(() => ({ onPress: open }), [open]);
982
- useEffect2(() => {
1124
+ useEffect3(() => {
983
1125
  const trigger2 = config.widget?.trigger ?? "button";
984
1126
  if (trigger2 !== "shake" && trigger2 !== "both") return;
985
1127
  let disposed = false;
@@ -1060,7 +1202,7 @@ function MushiProvider({ children, ...config }) {
1060
1202
  const screenRef = useRef3(null);
1061
1203
  const activityQueueRef = useRef3([]);
1062
1204
  const rewardsFlushRef = useRef3(null);
1063
- useEffect2(() => {
1205
+ useEffect3(() => {
1064
1206
  const rewards = config.rewards;
1065
1207
  if (!rewards?.enabled || !rewards.trackActivity) return;
1066
1208
  const flushMs = Math.max(3e4, rewards.flushIntervalMs ?? 3e5);
@@ -1243,10 +1385,10 @@ function MushiTrigger({ children }) {
1243
1385
  }
1244
1386
 
1245
1387
  // src/capture/navigation-capture.ts
1246
- import { useEffect as useEffect3, useRef as useRef4 } from "react";
1388
+ import { useEffect as useEffect4, useRef as useRef4 } from "react";
1247
1389
  function useNavigationCapture(navigationRef) {
1248
1390
  const historyRef = useRef4([]);
1249
- useEffect3(() => {
1391
+ useEffect4(() => {
1250
1392
  if (!navigationRef?.addListener || !navigationRef?.getCurrentRoute) return;
1251
1393
  const unsubscribe = navigationRef.addListener("state", () => {
1252
1394
  const route = navigationRef.getCurrentRoute?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mushi-mushi/react-native",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
4
4
  "license": "MIT",
5
5
  "description": "React Native SDK for Mushi Mushi — shake-to-report, bottom sheet widget, full context capture",
6
6
  "main": "./dist/index.js",
@@ -33,7 +33,7 @@
33
33
  }
34
34
  },
35
35
  "dependencies": {
36
- "@mushi-mushi/core": "^1.12.0"
36
+ "@mushi-mushi/core": "^1.14.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/react": "^19.2.14",