@mushi-mushi/react-native 0.15.1 → 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
@@ -328,6 +328,7 @@ var CATEGORIES = [
328
328
  { key: "confusing", emoji: "\u{1F615}", label: "Confusing" },
329
329
  { key: "other", emoji: "\u{1F4AC}", label: "Other" }
330
330
  ];
331
+ var VERIFIABLE_STATUSES = /* @__PURE__ */ new Set(["fixed", "resolved", "verified"]);
331
332
  var MushiBottomSheet = ({
332
333
  visible,
333
334
  onClose,
@@ -349,12 +350,6 @@ var MushiBottomSheet = ({
349
350
  const [threadComments, setThreadComments] = (0, import_react.useState)([]);
350
351
  const [replyText, setReplyText] = (0, import_react.useState)("");
351
352
  const [screenshotAttached, setScreenshotAttached] = (0, import_react.useState)(true);
352
- (0, import_react.useEffect)(() => {
353
- if (visible) {
354
- setScreenshotAttached(true);
355
- if (sheetTab === "inbox") void loadInbox();
356
- }
357
- }, [visible, sheetTab]);
358
353
  const loadInbox = (0, import_react.useCallback)(async () => {
359
354
  if (!mushi?.listMyReports) return;
360
355
  setInboxLoading(true);
@@ -365,6 +360,12 @@ var MushiBottomSheet = ({
365
360
  setInboxLoading(false);
366
361
  }
367
362
  }, [mushi]);
363
+ (0, import_react.useEffect)(() => {
364
+ if (visible) {
365
+ setScreenshotAttached(true);
366
+ if (sheetTab === "inbox") void loadInbox();
367
+ }
368
+ }, [visible, sheetTab, loadInbox]);
368
369
  const openThread = (0, import_react.useCallback)(async (reportId) => {
369
370
  setSelectedReportId(reportId);
370
371
  if (!mushi?.listMyComments) return;
@@ -466,13 +467,14 @@ var MushiBottomSheet = ({
466
467
  }
467
468
  };
468
469
  const activeScreenshot = screenshotDataUrl && screenshotAttached ? screenshotDataUrl : null;
469
- 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" };
470
471
  const canSubmit = !!category && description.trim().length > 0 && phase === "form";
471
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)(
472
473
  import_react_native2.KeyboardAvoidingView,
473
474
  {
474
475
  style: s.flex,
475
- 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,
476
478
  children: [
477
479
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
478
480
  import_react_native2.Animated.View,
@@ -490,7 +492,7 @@ var MushiBottomSheet = ({
490
492
  ],
491
493
  children: [
492
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 }] }) }),
493
- /* @__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)(
494
496
  import_react_native2.TouchableOpacity,
495
497
  {
496
498
  onPress: () => {
@@ -499,17 +501,19 @@ var MushiBottomSheet = ({
499
501
  else setSelectedReportId(null);
500
502
  },
501
503
  style: [s.tabBtn, sheetTab === tab && { borderBottomColor: colors.accent }],
502
- 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}" })
503
505
  },
504
506
  tab
505
507
  )) }),
506
- 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: [
507
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" }) }),
508
510
  threadComments.map((c) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.View, { style: [s.threadBubble, { backgroundColor: colors.card }], children: [
509
511
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: { color: colors.sub, fontSize: 11 }, children: c.author_kind }),
510
512
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: { color: colors.text }, children: c.body })
511
513
  ] }, c.id)),
512
- inboxReports.find((r) => r.id === selectedReportId)?.status === "fixed" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.View, { style: s.verifyRow, children: [
514
+ VERIFIABLE_STATUSES.has(
515
+ inboxReports.find((r) => r.id === selectedReportId)?.status ?? ""
516
+ ) && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.View, { style: s.verifyRow, children: [
513
517
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.TouchableOpacity, { style: [s.verifyBtn, { backgroundColor: colors.accent }], onPress: () => submitFeedback("confirms"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: s.submitText, children: "Yes, fixed" }) }),
514
518
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.TouchableOpacity, { style: [s.verifyBtn, { backgroundColor: colors.border }], onPress: () => submitFeedback("not_fixed"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.submitText, { color: colors.text }], children: "Not fixed" }) })
515
519
  ] }),
@@ -527,102 +531,127 @@ var MushiBottomSheet = ({
527
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: [
528
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) }),
529
533
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: { color: colors.sub, fontSize: 11 }, children: r.status })
530
- ] }, 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: [
531
552
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.sentEmoji], children: "\u2705" }),
532
553
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.sentText, { color: colors.text }], children: "Report sent!" })
533
- ] }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.View, { style: s.body, children: [
534
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.title, { color: colors.text }], children: "Report an issue" }),
535
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.View, { style: s.catRow, children: CATEGORIES.map((c) => {
536
- const active = category === c.key;
537
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
538
- import_react_native2.TouchableOpacity,
539
- {
540
- onPress: () => setCategory(c.key),
541
- activeOpacity: 0.7,
542
- style: [
543
- 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,
544
566
  {
545
- backgroundColor: active ? colors.accent : colors.card,
546
- borderColor: active ? colors.accent : colors.border
547
- }
548
- ],
549
- children: [
550
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: s.catEmoji, children: c.emoji }),
551
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
552
- 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,
553
598
  {
554
- style: [
555
- s.catLabel,
556
- { color: active ? "#fff" : colors.text }
557
- ],
558
- children: c.label
599
+ backgroundColor: colors.card,
600
+ color: colors.text,
601
+ borderColor: colors.border
559
602
  }
560
- )
561
- ]
562
- },
563
- c.key
564
- );
565
- }) }),
566
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
567
- import_react_native2.TextInput,
568
- {
569
- style: [
570
- 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,
571
641
  {
572
- backgroundColor: colors.card,
573
- color: colors.text,
574
- 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" })
575
650
  }
576
- ],
577
- placeholder: "What happened?",
578
- placeholderTextColor: colors.sub,
579
- multiline: true,
580
- textAlignVertical: "top",
581
- value: description,
582
- onChangeText: setDescription,
583
- editable: phase === "form"
584
- }
585
- ),
586
- activeScreenshot ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.View, { style: s.screenshotRow, children: [
587
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
588
- import_react_native2.Image,
589
- {
590
- source: { uri: activeScreenshot },
591
- style: s.screenshotThumb,
592
- accessibilityLabel: "Attached screenshot"
593
- }
594
- ),
595
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_react_native2.View, { style: s.screenshotMeta, children: [
596
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.screenshotLabel, { color: colors.text }], children: "Screenshot attached" }),
597
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.screenshotSub, { color: colors.sub }], children: "Helps the team see the issue" })
598
- ] }),
599
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
600
- import_react_native2.TouchableOpacity,
601
- {
602
- onPress: () => {
603
- setScreenshotAttached(false);
604
- onClearScreenshot?.();
605
- },
606
- hitSlop: { top: 8, bottom: 8, left: 8, right: 8 },
607
- accessibilityLabel: "Remove screenshot",
608
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: [s.screenshotRemove, { color: colors.sub }], children: "\u2715" })
609
- }
610
- )
611
- ] }) : null,
612
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
613
- import_react_native2.TouchableOpacity,
614
- {
615
- onPress: handleSubmit,
616
- disabled: !canSubmit,
617
- activeOpacity: 0.8,
618
- style: [
619
- s.submitBtn,
620
- { backgroundColor: canSubmit ? colors.accent : colors.border }
621
- ],
622
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native2.Text, { style: s.submitText, children: phase === "sending" ? "Sending\u2026" : "Submit" })
623
- }
624
- )
625
- ] })
651
+ )
652
+ ]
653
+ }
654
+ )
626
655
  ]
627
656
  }
628
657
  )
@@ -798,15 +827,133 @@ var s = import_react_native2.StyleSheet.create({
798
827
  var import_react2 = require("react");
799
828
  var import_react_native3 = require("react-native");
800
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
+ }
801
857
  var MushiFloatingButton = ({
802
858
  onPress,
803
859
  position = "bottom-right",
804
- inset
860
+ inset,
861
+ config,
862
+ accent
805
863
  }) => {
806
864
  const scheme = (0, import_react_native3.useColorScheme)();
807
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
+ })();
808
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;
809
955
  const handlePressIn = (0, import_react2.useCallback)(() => {
956
+ if (isDragging.current) return;
810
957
  import_react_native3.Animated.spring(scale, {
811
958
  toValue: 0.85,
812
959
  useNativeDriver: true,
@@ -821,39 +968,35 @@ var MushiFloatingButton = ({
821
968
  damping: 10,
822
969
  stiffness: 250
823
970
  }).start();
824
- }, [scale]);
825
- const positionStyle = position === "bottom-left" ? { left: inset?.left ?? 20 } : { right: inset?.right ?? 20 };
971
+ if (!isDragging.current) onPress();
972
+ }, [scale, onPress]);
826
973
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
827
- import_react_native3.TouchableWithoutFeedback,
974
+ import_react_native3.Animated.View,
828
975
  {
829
- onPress,
830
- onPressIn: handlePressIn,
831
- onPressOut: handlePressOut,
832
- children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
833
- import_react_native3.Animated.View,
976
+ ...draggable ? panResponder.panHandlers : {},
977
+ style: [
978
+ s2.fab,
834
979
  {
835
- style: [
836
- s2.fab,
837
- positionStyle,
838
- { bottom: inset?.bottom ?? (import_react_native3.Platform.OS === "ios" ? 50 : 28) },
839
- {
840
- backgroundColor: dark ? "#0a84ff" : "#007aff",
841
- transform: [{ scale }],
842
- shadowColor: dark ? "#0a84ff" : "#000"
843
- }
844
- ],
845
- 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 }]
846
985
  }
847
- )
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}" })
848
991
  }
849
992
  );
850
993
  };
851
994
  var s2 = import_react_native3.StyleSheet.create({
852
995
  fab: {
853
996
  position: "absolute",
854
- width: 56,
855
- height: 56,
856
- borderRadius: 28,
997
+ width: FAB_SIZE,
998
+ height: FAB_SIZE,
999
+ borderRadius: FAB_SIZE / 2,
857
1000
  alignItems: "center",
858
1001
  justifyContent: "center",
859
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";
@@ -319,6 +320,7 @@ var CATEGORIES = [
319
320
  { key: "confusing", emoji: "\u{1F615}", label: "Confusing" },
320
321
  { key: "other", emoji: "\u{1F4AC}", label: "Other" }
321
322
  ];
323
+ var VERIFIABLE_STATUSES = /* @__PURE__ */ new Set(["fixed", "resolved", "verified"]);
322
324
  var MushiBottomSheet = ({
323
325
  visible,
324
326
  onClose,
@@ -340,12 +342,6 @@ var MushiBottomSheet = ({
340
342
  const [threadComments, setThreadComments] = useState([]);
341
343
  const [replyText, setReplyText] = useState("");
342
344
  const [screenshotAttached, setScreenshotAttached] = useState(true);
343
- useEffect(() => {
344
- if (visible) {
345
- setScreenshotAttached(true);
346
- if (sheetTab === "inbox") void loadInbox();
347
- }
348
- }, [visible, sheetTab]);
349
345
  const loadInbox = useCallback(async () => {
350
346
  if (!mushi?.listMyReports) return;
351
347
  setInboxLoading(true);
@@ -356,6 +352,12 @@ var MushiBottomSheet = ({
356
352
  setInboxLoading(false);
357
353
  }
358
354
  }, [mushi]);
355
+ useEffect(() => {
356
+ if (visible) {
357
+ setScreenshotAttached(true);
358
+ if (sheetTab === "inbox") void loadInbox();
359
+ }
360
+ }, [visible, sheetTab, loadInbox]);
359
361
  const openThread = useCallback(async (reportId) => {
360
362
  setSelectedReportId(reportId);
361
363
  if (!mushi?.listMyComments) return;
@@ -457,13 +459,14 @@ var MushiBottomSheet = ({
457
459
  }
458
460
  };
459
461
  const activeScreenshot = screenshotDataUrl && screenshotAttached ? screenshotDataUrl : null;
460
- 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" };
461
463
  const canSubmit = !!category && description.trim().length > 0 && phase === "form";
462
464
  return /* @__PURE__ */ jsx(Modal, { visible, transparent: true, animationType: "none", statusBarTranslucent: true, children: /* @__PURE__ */ jsxs(
463
465
  KeyboardAvoidingView,
464
466
  {
465
467
  style: s.flex,
466
- behavior: Platform2.OS === "ios" ? "padding" : void 0,
468
+ behavior: Platform2.OS === "ios" ? "padding" : "height",
469
+ keyboardVerticalOffset: Platform2.OS === "ios" ? 0 : 20,
467
470
  children: [
468
471
  /* @__PURE__ */ jsx(
469
472
  Animated.View,
@@ -481,7 +484,7 @@ var MushiBottomSheet = ({
481
484
  ],
482
485
  children: [
483
486
  /* @__PURE__ */ jsx(View, { ...panResponder.panHandlers, style: s.handleArea, children: /* @__PURE__ */ jsx(View, { style: [s.handle, { backgroundColor: colors.sub }] }) }),
484
- /* @__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(
485
488
  TouchableOpacity,
486
489
  {
487
490
  onPress: () => {
@@ -490,17 +493,19 @@ var MushiBottomSheet = ({
490
493
  else setSelectedReportId(null);
491
494
  },
492
495
  style: [s.tabBtn, sheetTab === tab && { borderBottomColor: colors.accent }],
493
- 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}" })
494
497
  },
495
498
  tab
496
499
  )) }),
497
- 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: [
498
501
  /* @__PURE__ */ jsx(TouchableOpacity, { onPress: () => setSelectedReportId(null), children: /* @__PURE__ */ jsx(Text, { style: { color: colors.accent, marginBottom: 8 }, children: "\u2190 Back" }) }),
499
502
  threadComments.map((c) => /* @__PURE__ */ jsxs(View, { style: [s.threadBubble, { backgroundColor: colors.card }], children: [
500
503
  /* @__PURE__ */ jsx(Text, { style: { color: colors.sub, fontSize: 11 }, children: c.author_kind }),
501
504
  /* @__PURE__ */ jsx(Text, { style: { color: colors.text }, children: c.body })
502
505
  ] }, c.id)),
503
- inboxReports.find((r) => r.id === selectedReportId)?.status === "fixed" && /* @__PURE__ */ jsxs(View, { style: s.verifyRow, children: [
506
+ VERIFIABLE_STATUSES.has(
507
+ inboxReports.find((r) => r.id === selectedReportId)?.status ?? ""
508
+ ) && /* @__PURE__ */ jsxs(View, { style: s.verifyRow, children: [
504
509
  /* @__PURE__ */ jsx(TouchableOpacity, { style: [s.verifyBtn, { backgroundColor: colors.accent }], onPress: () => submitFeedback("confirms"), children: /* @__PURE__ */ jsx(Text, { style: s.submitText, children: "Yes, fixed" }) }),
505
510
  /* @__PURE__ */ jsx(TouchableOpacity, { style: [s.verifyBtn, { backgroundColor: colors.border }], onPress: () => submitFeedback("not_fixed"), children: /* @__PURE__ */ jsx(Text, { style: [s.submitText, { color: colors.text }], children: "Not fixed" }) })
506
511
  ] }),
@@ -518,102 +523,127 @@ var MushiBottomSheet = ({
518
523
  ] }) : inboxReports.map((r) => /* @__PURE__ */ jsxs(TouchableOpacity, { style: [s.inboxRow, { borderColor: colors.border }], onPress: () => openThread(r.id), children: [
519
524
  /* @__PURE__ */ jsx(Text, { style: { color: colors.text, fontWeight: "600" }, numberOfLines: 1, children: (r.summary ?? r.description ?? "Report").slice(0, 60) }),
520
525
  /* @__PURE__ */ jsx(Text, { style: { color: colors.sub, fontSize: 11 }, children: r.status })
521
- ] }, 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: [
522
544
  /* @__PURE__ */ jsx(Text, { style: [s.sentEmoji], children: "\u2705" }),
523
545
  /* @__PURE__ */ jsx(Text, { style: [s.sentText, { color: colors.text }], children: "Report sent!" })
524
- ] }) : /* @__PURE__ */ jsxs(View, { style: s.body, children: [
525
- /* @__PURE__ */ jsx(Text, { style: [s.title, { color: colors.text }], children: "Report an issue" }),
526
- /* @__PURE__ */ jsx(View, { style: s.catRow, children: CATEGORIES.map((c) => {
527
- const active = category === c.key;
528
- return /* @__PURE__ */ jsxs(
529
- TouchableOpacity,
530
- {
531
- onPress: () => setCategory(c.key),
532
- activeOpacity: 0.7,
533
- style: [
534
- 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,
535
558
  {
536
- backgroundColor: active ? colors.accent : colors.card,
537
- borderColor: active ? colors.accent : colors.border
538
- }
539
- ],
540
- children: [
541
- /* @__PURE__ */ jsx(Text, { style: s.catEmoji, children: c.emoji }),
542
- /* @__PURE__ */ jsx(
543
- 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,
544
590
  {
545
- style: [
546
- s.catLabel,
547
- { color: active ? "#fff" : colors.text }
548
- ],
549
- children: c.label
591
+ backgroundColor: colors.card,
592
+ color: colors.text,
593
+ borderColor: colors.border
550
594
  }
551
- )
552
- ]
553
- },
554
- c.key
555
- );
556
- }) }),
557
- /* @__PURE__ */ jsx(
558
- TextInput,
559
- {
560
- style: [
561
- 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,
562
633
  {
563
- backgroundColor: colors.card,
564
- color: colors.text,
565
- 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" })
566
642
  }
567
- ],
568
- placeholder: "What happened?",
569
- placeholderTextColor: colors.sub,
570
- multiline: true,
571
- textAlignVertical: "top",
572
- value: description,
573
- onChangeText: setDescription,
574
- editable: phase === "form"
575
- }
576
- ),
577
- activeScreenshot ? /* @__PURE__ */ jsxs(View, { style: s.screenshotRow, children: [
578
- /* @__PURE__ */ jsx(
579
- Image,
580
- {
581
- source: { uri: activeScreenshot },
582
- style: s.screenshotThumb,
583
- accessibilityLabel: "Attached screenshot"
584
- }
585
- ),
586
- /* @__PURE__ */ jsxs(View, { style: s.screenshotMeta, children: [
587
- /* @__PURE__ */ jsx(Text, { style: [s.screenshotLabel, { color: colors.text }], children: "Screenshot attached" }),
588
- /* @__PURE__ */ jsx(Text, { style: [s.screenshotSub, { color: colors.sub }], children: "Helps the team see the issue" })
589
- ] }),
590
- /* @__PURE__ */ jsx(
591
- TouchableOpacity,
592
- {
593
- onPress: () => {
594
- setScreenshotAttached(false);
595
- onClearScreenshot?.();
596
- },
597
- hitSlop: { top: 8, bottom: 8, left: 8, right: 8 },
598
- accessibilityLabel: "Remove screenshot",
599
- children: /* @__PURE__ */ jsx(Text, { style: [s.screenshotRemove, { color: colors.sub }], children: "\u2715" })
600
- }
601
- )
602
- ] }) : null,
603
- /* @__PURE__ */ jsx(
604
- TouchableOpacity,
605
- {
606
- onPress: handleSubmit,
607
- disabled: !canSubmit,
608
- activeOpacity: 0.8,
609
- style: [
610
- s.submitBtn,
611
- { backgroundColor: canSubmit ? colors.accent : colors.border }
612
- ],
613
- children: /* @__PURE__ */ jsx(Text, { style: s.submitText, children: phase === "sending" ? "Sending\u2026" : "Submit" })
614
- }
615
- )
616
- ] })
643
+ )
644
+ ]
645
+ }
646
+ )
617
647
  ]
618
648
  }
619
649
  )
@@ -786,24 +816,143 @@ var s = StyleSheet.create({
786
816
  });
787
817
 
788
818
  // src/components/MushiFloatingButton.tsx
789
- import { useRef as useRef2, useCallback as useCallback2 } from "react";
819
+ import { useRef as useRef2, useCallback as useCallback2, useEffect as useEffect2 } from "react";
790
820
  import {
791
821
  Animated as Animated2,
792
- TouchableWithoutFeedback,
822
+ PanResponder as PanResponder2,
793
823
  StyleSheet as StyleSheet2,
794
824
  Platform as Platform3,
825
+ Dimensions as Dimensions3,
795
826
  useColorScheme as useColorScheme2
796
827
  } from "react-native";
797
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
+ }
798
856
  var MushiFloatingButton = ({
799
857
  onPress,
800
858
  position = "bottom-right",
801
- inset
859
+ inset,
860
+ config,
861
+ accent
802
862
  }) => {
803
863
  const scheme = useColorScheme2();
804
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
+ })();
805
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;
806
954
  const handlePressIn = useCallback2(() => {
955
+ if (isDragging.current) return;
807
956
  Animated2.spring(scale, {
808
957
  toValue: 0.85,
809
958
  useNativeDriver: true,
@@ -818,39 +967,35 @@ var MushiFloatingButton = ({
818
967
  damping: 10,
819
968
  stiffness: 250
820
969
  }).start();
821
- }, [scale]);
822
- const positionStyle = position === "bottom-left" ? { left: inset?.left ?? 20 } : { right: inset?.right ?? 20 };
970
+ if (!isDragging.current) onPress();
971
+ }, [scale, onPress]);
823
972
  return /* @__PURE__ */ jsx2(
824
- TouchableWithoutFeedback,
973
+ Animated2.View,
825
974
  {
826
- onPress,
827
- onPressIn: handlePressIn,
828
- onPressOut: handlePressOut,
829
- children: /* @__PURE__ */ jsx2(
830
- Animated2.View,
975
+ ...draggable ? panResponder.panHandlers : {},
976
+ style: [
977
+ s2.fab,
831
978
  {
832
- style: [
833
- s2.fab,
834
- positionStyle,
835
- { bottom: inset?.bottom ?? (Platform3.OS === "ios" ? 50 : 28) },
836
- {
837
- backgroundColor: dark ? "#0a84ff" : "#007aff",
838
- transform: [{ scale }],
839
- shadowColor: dark ? "#0a84ff" : "#000"
840
- }
841
- ],
842
- 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 }]
843
984
  }
844
- )
985
+ ],
986
+ onStartShouldSetResponder: () => !draggable,
987
+ onResponderGrant: handlePressIn,
988
+ onResponderRelease: handlePressOut,
989
+ children: /* @__PURE__ */ jsx2(Animated2.Text, { style: s2.emoji, children: "\u{1F41B}" })
845
990
  }
846
991
  );
847
992
  };
848
993
  var s2 = StyleSheet2.create({
849
994
  fab: {
850
995
  position: "absolute",
851
- width: 56,
852
- height: 56,
853
- borderRadius: 28,
996
+ width: FAB_SIZE,
997
+ height: FAB_SIZE,
998
+ borderRadius: FAB_SIZE / 2,
854
999
  alignItems: "center",
855
1000
  justifyContent: "center",
856
1001
  zIndex: 9999,
@@ -892,7 +1037,7 @@ function MushiProvider({ children, ...config }) {
892
1037
  console.warn("[MushiProvider] `endpoint` is deprecated \u2014 use `apiEndpoint` instead.");
893
1038
  }
894
1039
  const apiEndpoint = rawEndpoint ?? DEFAULT_API_ENDPOINT;
895
- useEffect2(() => {
1040
+ useEffect3(() => {
896
1041
  if (config.capture?.console !== false) {
897
1042
  consoleRef.current = setupConsoleCapture(config.capture?.maxConsoleEntries);
898
1043
  }
@@ -941,7 +1086,7 @@ function MushiProvider({ children, ...config }) {
941
1086
  networkRef.current?.restore();
942
1087
  };
943
1088
  }, []);
944
- useEffect2(() => {
1089
+ useEffect3(() => {
945
1090
  let unsubscribe;
946
1091
  try {
947
1092
  const NetInfo = __require("@react-native-community/netinfo").default;
@@ -976,7 +1121,7 @@ function MushiProvider({ children, ...config }) {
976
1121
  }, []);
977
1122
  const close = useCallback3(() => setSheetVisible(false), []);
978
1123
  const attachTo = useCallback3(() => ({ onPress: open }), [open]);
979
- useEffect2(() => {
1124
+ useEffect3(() => {
980
1125
  const trigger2 = config.widget?.trigger ?? "button";
981
1126
  if (trigger2 !== "shake" && trigger2 !== "both") return;
982
1127
  let disposed = false;
@@ -1057,7 +1202,7 @@ function MushiProvider({ children, ...config }) {
1057
1202
  const screenRef = useRef3(null);
1058
1203
  const activityQueueRef = useRef3([]);
1059
1204
  const rewardsFlushRef = useRef3(null);
1060
- useEffect2(() => {
1205
+ useEffect3(() => {
1061
1206
  const rewards = config.rewards;
1062
1207
  if (!rewards?.enabled || !rewards.trackActivity) return;
1063
1208
  const flushMs = Math.max(3e4, rewards.flushIntervalMs ?? 3e5);
@@ -1240,10 +1385,10 @@ function MushiTrigger({ children }) {
1240
1385
  }
1241
1386
 
1242
1387
  // src/capture/navigation-capture.ts
1243
- import { useEffect as useEffect3, useRef as useRef4 } from "react";
1388
+ import { useEffect as useEffect4, useRef as useRef4 } from "react";
1244
1389
  function useNavigationCapture(navigationRef) {
1245
1390
  const historyRef = useRef4([]);
1246
- useEffect3(() => {
1391
+ useEffect4(() => {
1247
1392
  if (!navigationRef?.addListener || !navigationRef?.getCurrentRoute) return;
1248
1393
  const unsubscribe = navigationRef.addListener("state", () => {
1249
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.1",
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",