yaver-feedback-react-native 0.7.17 → 0.8.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.
@@ -40,6 +40,7 @@ const YaverFeedback_1 = require("./YaverFeedback");
40
40
  const capture_1 = require("./capture");
41
41
  const upload_1 = require("./upload");
42
42
  const AuthOverlay_1 = require("./AuthOverlay");
43
+ const QuickActionIcon_1 = require("./QuickActionIcon");
43
44
  const FeedbackModal = () => {
44
45
  const [visible, setVisible] = (0, react_1.useState)(false);
45
46
  const [action, setAction] = (0, react_1.useState)('idle');
@@ -52,6 +53,11 @@ const FeedbackModal = () => {
52
53
  // hidden button instead of a runtime error.
53
54
  const voiceSupported = (0, react_1.useRef)((0, capture_1.isVoiceCaptureSupported)()).current;
54
55
  const [lastVideo, setLastVideo] = (0, react_1.useState)(null);
56
+ // Tracks whether the user has hidden the QuickActionIcon via its
57
+ // long-press menu. Shake is always available, so the feedback modal
58
+ // is our guaranteed UI for bringing the icon back — we surface a
59
+ // small "Show quick icon" row when this is true.
60
+ const [quickIconHidden, setQuickIconHidden] = (0, react_1.useState)(false);
55
61
  // Vibing-input mode: same expand-on-tap pattern as email login.
56
62
  // Tap "Vibing" once → the button reveals an input + Send; that lets
57
63
  // the user say WHAT they want to vibe on instead of firing a canned
@@ -69,6 +75,15 @@ const FeedbackModal = () => {
69
75
  setError(null);
70
76
  setToast(null);
71
77
  setAction('idle');
78
+ // Re-read the "user hid the quick icon" flag on every open so
79
+ // the re-enable row reflects the latest preference (the user
80
+ // might have hidden or shown it between opens).
81
+ YaverFeedback_1.YaverFeedback.isQuickIconHidden()
82
+ .then((v) => {
83
+ if (mountedRef.current)
84
+ setQuickIconHidden(v);
85
+ })
86
+ .catch(() => { });
72
87
  }
73
88
  });
74
89
  // Agent streams build / compile progress through the BlackBox
@@ -496,6 +511,7 @@ const FeedbackModal = () => {
496
511
  const busy = action !== 'idle';
497
512
  return (<>
498
513
  <AuthOverlay_1.AuthOverlay />
514
+ <QuickActionIcon_1.QuickActionIcon />
499
515
  {visible && (<react_native_1.Modal visible={visible} animationType="slide" transparent onRequestClose={handleClose}>
500
516
  <react_native_1.Pressable style={styles.overlay} onPress={handleClose}>
501
517
  <react_native_1.Pressable style={styles.modal} onPress={(e) => e.stopPropagation()}>
@@ -566,6 +582,27 @@ const FeedbackModal = () => {
566
582
  </react_native_1.View>)}
567
583
  {toast && <react_native_1.Text style={styles.toast}>{toast}</react_native_1.Text>}
568
584
  {error && <react_native_1.Text style={styles.error}>{error}</react_native_1.Text>}
585
+
586
+ {/* Quick-icon toggle. The user's three ways to control
587
+ the floating icon are: (1) long-press the icon →
588
+ Hide, (2) tap this row to toggle it on/off, (3) shake
589
+ → this modal → tap this row. Shake is the unkillable
590
+ back-door when the icon is hidden and the dev hasn't
591
+ exposed their own settings UI. */}
592
+ <react_native_1.Pressable onPress={async () => {
593
+ const next = !quickIconHidden;
594
+ setQuickIconHidden(next);
595
+ await YaverFeedback_1.YaverFeedback.setQuickIconVisible(!next);
596
+ }} style={({ pressed }) => [
597
+ styles.quickIconToggle,
598
+ pressed && { opacity: 0.7 },
599
+ ]} accessibilityRole="button" accessibilityLabel={quickIconHidden ? 'Show quick icon' : 'Hide quick icon'}>
600
+ <react_native_1.Text style={styles.quickIconToggleText}>
601
+ {quickIconHidden
602
+ ? '◯ Show quick-access icon'
603
+ : '● Hide quick-access icon'}
604
+ </react_native_1.Text>
605
+ </react_native_1.Pressable>
569
606
  </react_native_1.Pressable>
570
607
  </react_native_1.Pressable>
571
608
  </react_native_1.Modal>)}
@@ -710,4 +747,15 @@ const styles = react_native_1.StyleSheet.create({
710
747
  textAlign: 'center',
711
748
  marginTop: 4,
712
749
  },
750
+ quickIconToggle: {
751
+ marginTop: 4,
752
+ alignSelf: 'center',
753
+ paddingVertical: 6,
754
+ paddingHorizontal: 12,
755
+ },
756
+ quickIconToggleText: {
757
+ color: '#9ca3af',
758
+ fontSize: 12,
759
+ fontWeight: '500',
760
+ },
713
761
  });
@@ -0,0 +1,35 @@
1
+ import React from 'react';
2
+ export interface QuickActionIconProps {
3
+ /** Override the color from FeedbackConfig.quickIconColor. */
4
+ color?: string;
5
+ /** Override the initial position from FeedbackConfig.quickIconInitialPosition. */
6
+ initialPosition?: {
7
+ x: number;
8
+ y: number;
9
+ };
10
+ /** Override the icon diameter. Default 44. */
11
+ size?: number;
12
+ }
13
+ /**
14
+ * Small tap-to-open icon for the Yaver Feedback SDK.
15
+ *
16
+ * Default UX:
17
+ * - **Tap** opens the feedback modal (same as shake).
18
+ * - **Long-press** (~550ms) opens a menu with "Open feedback" and
19
+ * "Hide icon". Hiding is persisted to AsyncStorage so the user's
20
+ * decision survives app relaunches.
21
+ * - **Drag** repositions the icon.
22
+ *
23
+ * Shake always keeps working independently — even when the icon is
24
+ * hidden the user can still shake to open feedback.
25
+ *
26
+ * Visibility is controlled by `FeedbackConfig.quickIcon`:
27
+ * - `'auto'` (default) → `'always'` on iOS/Android, `'off'` on web.
28
+ * - `'always'` → visible from first render.
29
+ * - `'after-shake'` → hidden until `yaverFeedback:firstShake` fires.
30
+ * - `'off'` → never rendered.
31
+ *
32
+ * Suppressed entirely when the SDK is loaded inside Yaver's super-host
33
+ * (the Yaver mobile app owns the shake gesture + overlay in that case).
34
+ */
35
+ export declare const QuickActionIcon: React.FC<QuickActionIconProps>;
@@ -0,0 +1,299 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.QuickActionIcon = void 0;
37
+ const react_1 = __importStar(require("react"));
38
+ const react_native_1 = require("react-native");
39
+ const YaverFeedback_1 = require("./YaverFeedback");
40
+ const preferences_1 = require("./preferences");
41
+ // Mirror the suppression rule used by YaverFeedback + ShakeDetector:
42
+ // when loaded through Yaver's super-host Hermes bundle, the host owns
43
+ // shake + reload UX. We must not render a second action surface.
44
+ function isRunningInsideYaverHost() {
45
+ try {
46
+ return !!react_native_1.NativeModules?.YaverInfo;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ const DEFAULT_SIZE = 44;
53
+ const DEFAULT_COLOR = '#6366f1';
54
+ const LONG_PRESS_MS = 550;
55
+ /**
56
+ * Small tap-to-open icon for the Yaver Feedback SDK.
57
+ *
58
+ * Default UX:
59
+ * - **Tap** opens the feedback modal (same as shake).
60
+ * - **Long-press** (~550ms) opens a menu with "Open feedback" and
61
+ * "Hide icon". Hiding is persisted to AsyncStorage so the user's
62
+ * decision survives app relaunches.
63
+ * - **Drag** repositions the icon.
64
+ *
65
+ * Shake always keeps working independently — even when the icon is
66
+ * hidden the user can still shake to open feedback.
67
+ *
68
+ * Visibility is controlled by `FeedbackConfig.quickIcon`:
69
+ * - `'auto'` (default) → `'always'` on iOS/Android, `'off'` on web.
70
+ * - `'always'` → visible from first render.
71
+ * - `'after-shake'` → hidden until `yaverFeedback:firstShake` fires.
72
+ * - `'off'` → never rendered.
73
+ *
74
+ * Suppressed entirely when the SDK is loaded inside Yaver's super-host
75
+ * (the Yaver mobile app owns the shake gesture + overlay in that case).
76
+ */
77
+ const QuickActionIcon = ({ color: colorProp, initialPosition: initialPositionProp, size = DEFAULT_SIZE, }) => {
78
+ const config = YaverFeedback_1.YaverFeedback.getConfig();
79
+ const mode = (() => {
80
+ const raw = config?.quickIcon ?? 'auto';
81
+ if (raw === 'auto') {
82
+ return react_native_1.Platform.OS === 'web' ? 'off' : 'always';
83
+ }
84
+ return raw;
85
+ })();
86
+ const color = colorProp ?? config?.quickIconColor ?? DEFAULT_COLOR;
87
+ const { width, height } = react_native_1.Dimensions.get('window');
88
+ const defaultStart = initialPositionProp ??
89
+ config?.quickIconInitialPosition ?? {
90
+ x: Math.max(width - size - 14, 0),
91
+ y: Math.max(Math.floor(height * 0.35), 80),
92
+ };
93
+ const pan = (0, react_1.useRef)(new react_native_1.Animated.ValueXY(defaultStart)).current;
94
+ const lastPos = (0, react_1.useRef)(defaultStart);
95
+ const dragStart = (0, react_1.useRef)(null);
96
+ const didDrag = (0, react_1.useRef)(false);
97
+ const [userDisabled, setUserDisabled] = (0, react_1.useState)(null);
98
+ const [shakenThisSession, setShakenThisSession] = (0, react_1.useState)(false);
99
+ const [menuOpen, setMenuOpen] = (0, react_1.useState)(false);
100
+ const [hostSuppressed] = (0, react_1.useState)(() => isRunningInsideYaverHost());
101
+ // Load the persisted disable flag once on mount. Until it resolves we
102
+ // render nothing — a one-frame flash of the icon before hiding would
103
+ // be worse than a tiny delayed appearance.
104
+ (0, react_1.useEffect)(() => {
105
+ let alive = true;
106
+ (0, preferences_1.getQuickIconDisabled)().then((v) => {
107
+ if (alive)
108
+ setUserDisabled(v);
109
+ });
110
+ return () => {
111
+ alive = false;
112
+ };
113
+ }, []);
114
+ // `after-shake` mode waits for the first shake before revealing
115
+ // itself. YaverFeedback emits this event from its shake callback.
116
+ (0, react_1.useEffect)(() => {
117
+ const sub = react_native_1.DeviceEventEmitter.addListener('yaverFeedback:firstShake', () => setShakenThisSession(true));
118
+ return () => sub.remove();
119
+ }, []);
120
+ // Programmatic control: host apps can call
121
+ // `YaverFeedback.setQuickIconVisible(true)` to re-surface the icon
122
+ // after the user hid it (e.g. from a settings screen).
123
+ (0, react_1.useEffect)(() => {
124
+ const showSub = react_native_1.DeviceEventEmitter.addListener('yaverFeedback:quickIconShow', () => {
125
+ setUserDisabled(false);
126
+ void (0, preferences_1.setQuickIconDisabled)(false);
127
+ });
128
+ const hideSub = react_native_1.DeviceEventEmitter.addListener('yaverFeedback:quickIconHide', () => {
129
+ setUserDisabled(true);
130
+ void (0, preferences_1.setQuickIconDisabled)(true);
131
+ setMenuOpen(false);
132
+ });
133
+ return () => {
134
+ showSub.remove();
135
+ hideSub.remove();
136
+ };
137
+ }, []);
138
+ const panResponder = (0, react_1.useRef)(react_native_1.PanResponder.create({
139
+ onStartShouldSetPanResponder: () => true,
140
+ onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 3 || Math.abs(g.dy) > 3,
141
+ onPanResponderGrant: () => {
142
+ didDrag.current = false;
143
+ dragStart.current = { ...lastPos.current };
144
+ pan.setOffset({ x: lastPos.current.x, y: lastPos.current.y });
145
+ pan.setValue({ x: 0, y: 0 });
146
+ },
147
+ onPanResponderMove: (_, g) => {
148
+ if (Math.abs(g.dx) > 3 || Math.abs(g.dy) > 3) {
149
+ didDrag.current = true;
150
+ }
151
+ react_native_1.Animated.event([null, { dx: pan.x, dy: pan.y }], {
152
+ useNativeDriver: false,
153
+ })(_, g);
154
+ },
155
+ onPanResponderRelease: (_, g) => {
156
+ pan.flattenOffset();
157
+ const start = dragStart.current ?? lastPos.current;
158
+ const maxX = Math.max(width - size, 0);
159
+ const maxY = Math.max(height - size, 0);
160
+ const nextX = Math.max(0, Math.min(maxX, start.x + g.dx));
161
+ const nextY = Math.max(0, Math.min(maxY, start.y + g.dy));
162
+ lastPos.current = { x: nextX, y: nextY };
163
+ react_native_1.Animated.spring(pan, {
164
+ toValue: { x: nextX, y: nextY },
165
+ useNativeDriver: false,
166
+ friction: 7,
167
+ }).start();
168
+ },
169
+ })).current;
170
+ const openFeedback = (0, react_1.useCallback)(() => {
171
+ setMenuOpen(false);
172
+ void YaverFeedback_1.YaverFeedback.startReport();
173
+ }, []);
174
+ const hideForever = (0, react_1.useCallback)(() => {
175
+ setMenuOpen(false);
176
+ setUserDisabled(true);
177
+ void (0, preferences_1.setQuickIconDisabled)(true);
178
+ }, []);
179
+ if (hostSuppressed)
180
+ return null;
181
+ if (mode === 'off')
182
+ return null;
183
+ if (userDisabled === null)
184
+ return null;
185
+ if (userDisabled)
186
+ return null;
187
+ if (mode === 'after-shake' && !shakenThisSession)
188
+ return null;
189
+ if (!YaverFeedback_1.YaverFeedback.isEnabled())
190
+ return null;
191
+ const visualSize = size;
192
+ const radius = visualSize / 2;
193
+ return (<react_native_1.Animated.View pointerEvents="box-none" style={[
194
+ react_native_1.StyleSheet.absoluteFill,
195
+ { zIndex: 9998 },
196
+ ]}>
197
+ <react_native_1.Animated.View {...panResponder.panHandlers} style={[
198
+ styles.container,
199
+ {
200
+ transform: [{ translateX: pan.x }, { translateY: pan.y }],
201
+ },
202
+ ]}>
203
+ <react_native_1.Pressable onPress={() => {
204
+ if (didDrag.current) {
205
+ didDrag.current = false;
206
+ return;
207
+ }
208
+ openFeedback();
209
+ }} onLongPress={() => {
210
+ if (didDrag.current)
211
+ return;
212
+ setMenuOpen((m) => !m);
213
+ }} delayLongPress={LONG_PRESS_MS} hitSlop={6} accessibilityRole="button" accessibilityLabel="Open Yaver feedback" style={({ pressed }) => [
214
+ styles.icon,
215
+ {
216
+ width: visualSize,
217
+ height: visualSize,
218
+ borderRadius: radius,
219
+ backgroundColor: color,
220
+ opacity: pressed ? 0.85 : 1,
221
+ },
222
+ ]}>
223
+ <react_native_1.Text style={[styles.iconLabel, { fontSize: Math.round(visualSize * 0.5) }]}>y</react_native_1.Text>
224
+ </react_native_1.Pressable>
225
+ {menuOpen ? (<react_native_1.View style={styles.menu}>
226
+ <react_native_1.Pressable onPress={openFeedback} style={({ pressed }) => [
227
+ styles.menuItem,
228
+ pressed && styles.menuItemPressed,
229
+ ]}>
230
+ <react_native_1.Text style={styles.menuItemText}>Open feedback</react_native_1.Text>
231
+ </react_native_1.Pressable>
232
+ <react_native_1.View style={styles.menuDivider}/>
233
+ <react_native_1.Pressable onPress={hideForever} style={({ pressed }) => [
234
+ styles.menuItem,
235
+ pressed && styles.menuItemPressed,
236
+ ]}>
237
+ <react_native_1.Text style={[styles.menuItemText, styles.menuItemDanger]}>
238
+ Hide icon
239
+ </react_native_1.Text>
240
+ </react_native_1.Pressable>
241
+ </react_native_1.View>) : null}
242
+ </react_native_1.Animated.View>
243
+ </react_native_1.Animated.View>);
244
+ };
245
+ exports.QuickActionIcon = QuickActionIcon;
246
+ const styles = react_native_1.StyleSheet.create({
247
+ container: {
248
+ position: 'absolute',
249
+ top: 0,
250
+ left: 0,
251
+ alignItems: 'flex-start',
252
+ },
253
+ icon: {
254
+ alignItems: 'center',
255
+ justifyContent: 'center',
256
+ shadowColor: '#000',
257
+ shadowOffset: { width: 0, height: 2 },
258
+ shadowOpacity: 0.25,
259
+ shadowRadius: 4,
260
+ elevation: 4,
261
+ },
262
+ iconLabel: {
263
+ color: '#ffffff',
264
+ fontWeight: '700',
265
+ includeFontPadding: false,
266
+ },
267
+ menu: {
268
+ marginTop: 6,
269
+ minWidth: 150,
270
+ backgroundColor: '#1f1f23',
271
+ borderRadius: 10,
272
+ paddingVertical: 4,
273
+ shadowColor: '#000',
274
+ shadowOffset: { width: 0, height: 2 },
275
+ shadowOpacity: 0.3,
276
+ shadowRadius: 6,
277
+ elevation: 6,
278
+ },
279
+ menuItem: {
280
+ paddingHorizontal: 14,
281
+ paddingVertical: 10,
282
+ },
283
+ menuItemPressed: {
284
+ backgroundColor: '#2a2a30',
285
+ },
286
+ menuItemText: {
287
+ color: '#f4f4f5',
288
+ fontSize: 14,
289
+ fontWeight: '500',
290
+ },
291
+ menuItemDanger: {
292
+ color: '#f97316',
293
+ },
294
+ menuDivider: {
295
+ height: react_native_1.StyleSheet.hairlineWidth,
296
+ backgroundColor: '#3f3f46',
297
+ marginHorizontal: 8,
298
+ },
299
+ });
@@ -199,6 +199,32 @@ export declare class YaverFeedback {
199
199
  * 3. DevSettings.reload() (Metro dev server fallback)
200
200
  */
201
201
  private static loadBundleAndReload;
202
+ /**
203
+ * Internal: fired from every shake path (dev-menu + accelerometer)
204
+ * before the feedback modal opens. Emits `yaverFeedback:firstShake`
205
+ * exactly once per process so QuickActionIcon's `'after-shake'` mode
206
+ * can surface itself on first shake and stay visible for the rest of
207
+ * the session.
208
+ */
209
+ static notifyShake(): void;
210
+ /**
211
+ * Show / hide the QuickActionIcon programmatically and persist the
212
+ * choice across launches. Host apps can call this from a settings
213
+ * screen so the user has a second way to re-enable the icon after
214
+ * hiding it via the icon's own long-press menu — shake is always the
215
+ * third back-door because it never depends on a visible control.
216
+ */
217
+ static setQuickIconVisible(visible: boolean): Promise<void>;
218
+ /**
219
+ * Returns `true` when the user has chosen to hide the QuickActionIcon
220
+ * (via its long-press menu or `setQuickIconVisible(false)`).
221
+ * FeedbackModal uses this to surface a one-tap "Show quick icon"
222
+ * control so the user can bring the icon back without having to know
223
+ * about the programmatic API.
224
+ */
225
+ static isQuickIconHidden(): Promise<boolean>;
226
+ /** Clear the persisted "user hid the icon" flag. */
227
+ static resetQuickIconPreference(): Promise<void>;
202
228
  /** Tear down the SDK (stop shake detector, clear state). */
203
229
  static destroy(): void;
204
230
  }
@@ -7,6 +7,7 @@ const BlackBox_1 = require("./BlackBox");
7
7
  const P2PClient_1 = require("./P2PClient");
8
8
  const ShakeDetector_1 = require("./ShakeDetector");
9
9
  const auth_1 = require("./auth");
10
+ const preferences_1 = require("./preferences");
10
11
  // Is this JS runtime the Yaver mobile app's super-host bridge? The
11
12
  // YaverInfo native module is only registered inside Yaver's container
12
13
  // (mobile/ios/Yaver/YaverInfo.{swift,m} + Android counterpart); a
@@ -38,6 +39,13 @@ let errorBuffer = [];
38
39
  let maxErrors = 5;
39
40
  /** Track whether BlackBox was running before disable (to restart on enable). */
40
41
  let blackBoxWasStreaming = false;
42
+ /**
43
+ * Tracks whether the user has already shaken once in this process.
44
+ * Consumed by QuickActionIcon's `'after-shake'` mode so the icon
45
+ * appears the first time a user discovers shake and stays around
46
+ * thereafter.
47
+ */
48
+ let firstShakeFired = false;
41
49
  /**
42
50
  * Flag evaluation cache — 30s TTL per `userId|key`. Prevents a
43
51
  * tight render loop from hammering /flags/eval when the dev calls
@@ -118,6 +126,7 @@ class YaverFeedback {
118
126
  if (enabled && config.trigger === 'shake') {
119
127
  shakeDetector = new ShakeDetector_1.ShakeDetector();
120
128
  shakeDetector.start(() => {
129
+ YaverFeedback.notifyShake();
121
130
  if (config?.reportingOnly) {
122
131
  YaverFeedback.sendAutoReport();
123
132
  }
@@ -450,6 +459,7 @@ class YaverFeedback {
450
459
  if (config?.trigger === 'shake' && !shakeDetector) {
451
460
  shakeDetector = new ShakeDetector_1.ShakeDetector();
452
461
  shakeDetector.start(() => {
462
+ YaverFeedback.notifyShake();
453
463
  if (config?.reportingOnly) {
454
464
  YaverFeedback.sendAutoReport();
455
465
  }
@@ -752,6 +762,56 @@ class YaverFeedback {
752
762
  // Not in dev mode
753
763
  }
754
764
  }
765
+ /**
766
+ * Internal: fired from every shake path (dev-menu + accelerometer)
767
+ * before the feedback modal opens. Emits `yaverFeedback:firstShake`
768
+ * exactly once per process so QuickActionIcon's `'after-shake'` mode
769
+ * can surface itself on first shake and stay visible for the rest of
770
+ * the session.
771
+ */
772
+ static notifyShake() {
773
+ if (firstShakeFired)
774
+ return;
775
+ firstShakeFired = true;
776
+ try {
777
+ const { DeviceEventEmitter } = require('react-native');
778
+ DeviceEventEmitter.emit('yaverFeedback:firstShake');
779
+ }
780
+ catch {
781
+ // emitter unavailable (e.g. jsdom unit test) — safe to ignore
782
+ }
783
+ }
784
+ /**
785
+ * Show / hide the QuickActionIcon programmatically and persist the
786
+ * choice across launches. Host apps can call this from a settings
787
+ * screen so the user has a second way to re-enable the icon after
788
+ * hiding it via the icon's own long-press menu — shake is always the
789
+ * third back-door because it never depends on a visible control.
790
+ */
791
+ static async setQuickIconVisible(visible) {
792
+ await (0, preferences_1.setQuickIconDisabled)(!visible);
793
+ try {
794
+ const { DeviceEventEmitter } = require('react-native');
795
+ DeviceEventEmitter.emit(visible ? 'yaverFeedback:quickIconShow' : 'yaverFeedback:quickIconHide');
796
+ }
797
+ catch {
798
+ // emitter unavailable — preference is still persisted
799
+ }
800
+ }
801
+ /**
802
+ * Returns `true` when the user has chosen to hide the QuickActionIcon
803
+ * (via its long-press menu or `setQuickIconVisible(false)`).
804
+ * FeedbackModal uses this to surface a one-tap "Show quick icon"
805
+ * control so the user can bring the icon back without having to know
806
+ * about the programmatic API.
807
+ */
808
+ static async isQuickIconHidden() {
809
+ return (0, preferences_1.getQuickIconDisabled)();
810
+ }
811
+ /** Clear the persisted "user hid the icon" flag. */
812
+ static async resetQuickIconPreference() {
813
+ await YaverFeedback.setQuickIconVisible(true);
814
+ }
755
815
  /** Tear down the SDK (stop shake detector, clear state). */
756
816
  static destroy() {
757
817
  if (shakeDetector) {
package/dist/index.d.ts CHANGED
@@ -44,7 +44,10 @@ export { AuthOverlay } from './AuthOverlay';
44
44
  export { ShakeDetector } from './ShakeDetector';
45
45
  export { FloatingButton } from './FloatingButton';
46
46
  export { FeedbackModal } from './FeedbackModal';
47
+ export { QuickActionIcon } from './QuickActionIcon';
48
+ export type { QuickActionIconProps } from './QuickActionIcon';
47
49
  export { FixReport } from './FixReport';
50
+ export { getQuickIconDisabled, setQuickIconDisabled, clearQuickIconDisabled, } from './preferences';
48
51
  export { configureAuthEndpoints, getConvexSiteUrl, getWebBaseUrl, getToken, saveToken, clearToken, getUser, saveUser, getSelectedDeviceId, saveSelectedDeviceId, clearSelectedDeviceId, validateToken, signInWithApple, signInWithOAuth, signupWithEmail, loginWithEmail, listReachableDevices, DEFAULT_CONVEX_SITE_URL, DEFAULT_WEB_BASE_URL, DEFAULT_OAUTH_REDIRECT, } from './auth';
49
52
  export type { OAuthProvider, User, RemoteDevice, DeviceList, } from './auth';
50
53
  export { captureScreenshot, startVideoRecording, stopVideoRecording, isVideoRecording, } from './capture';
package/dist/index.js CHANGED
@@ -28,7 +28,7 @@
28
28
  * ```
29
29
  */
30
30
  Object.defineProperty(exports, "__esModule", { value: true });
31
- exports.uploadFeedback = exports.isVideoRecording = exports.stopVideoRecording = exports.startVideoRecording = exports.captureScreenshot = exports.DEFAULT_OAUTH_REDIRECT = exports.DEFAULT_WEB_BASE_URL = exports.DEFAULT_CONVEX_SITE_URL = exports.listReachableDevices = exports.loginWithEmail = exports.signupWithEmail = exports.signInWithOAuth = exports.signInWithApple = exports.validateToken = exports.clearSelectedDeviceId = exports.saveSelectedDeviceId = exports.getSelectedDeviceId = exports.saveUser = exports.getUser = exports.clearToken = exports.saveToken = exports.getToken = exports.getWebBaseUrl = exports.getConvexSiteUrl = exports.configureAuthEndpoints = exports.FixReport = exports.FeedbackModal = exports.FloatingButton = exports.ShakeDetector = exports.AuthOverlay = exports.PairDeviceModal = exports.YaverMachinePickerScreen = exports.YaverLoginScreen = exports.YaverConnectionScreen = exports.P2PClient = exports.YaverDiscovery = exports.initExpo = exports.YaverUpdates = exports.BlackBox = exports.YaverFeedback = void 0;
31
+ exports.uploadFeedback = exports.isVideoRecording = exports.stopVideoRecording = exports.startVideoRecording = exports.captureScreenshot = exports.DEFAULT_OAUTH_REDIRECT = exports.DEFAULT_WEB_BASE_URL = exports.DEFAULT_CONVEX_SITE_URL = exports.listReachableDevices = exports.loginWithEmail = exports.signupWithEmail = exports.signInWithOAuth = exports.signInWithApple = exports.validateToken = exports.clearSelectedDeviceId = exports.saveSelectedDeviceId = exports.getSelectedDeviceId = exports.saveUser = exports.getUser = exports.clearToken = exports.saveToken = exports.getToken = exports.getWebBaseUrl = exports.getConvexSiteUrl = exports.configureAuthEndpoints = exports.clearQuickIconDisabled = exports.setQuickIconDisabled = exports.getQuickIconDisabled = exports.FixReport = exports.QuickActionIcon = exports.FeedbackModal = exports.FloatingButton = exports.ShakeDetector = exports.AuthOverlay = exports.PairDeviceModal = exports.YaverMachinePickerScreen = exports.YaverLoginScreen = exports.YaverConnectionScreen = exports.P2PClient = exports.YaverDiscovery = exports.initExpo = exports.YaverUpdates = exports.BlackBox = exports.YaverFeedback = void 0;
32
32
  var YaverFeedback_1 = require("./YaverFeedback");
33
33
  Object.defineProperty(exports, "YaverFeedback", { enumerable: true, get: function () { return YaverFeedback_1.YaverFeedback; } });
34
34
  var BlackBox_1 = require("./BlackBox");
@@ -57,8 +57,14 @@ var FloatingButton_1 = require("./FloatingButton");
57
57
  Object.defineProperty(exports, "FloatingButton", { enumerable: true, get: function () { return FloatingButton_1.FloatingButton; } });
58
58
  var FeedbackModal_1 = require("./FeedbackModal");
59
59
  Object.defineProperty(exports, "FeedbackModal", { enumerable: true, get: function () { return FeedbackModal_1.FeedbackModal; } });
60
+ var QuickActionIcon_1 = require("./QuickActionIcon");
61
+ Object.defineProperty(exports, "QuickActionIcon", { enumerable: true, get: function () { return QuickActionIcon_1.QuickActionIcon; } });
60
62
  var FixReport_1 = require("./FixReport");
61
63
  Object.defineProperty(exports, "FixReport", { enumerable: true, get: function () { return FixReport_1.FixReport; } });
64
+ var preferences_1 = require("./preferences");
65
+ Object.defineProperty(exports, "getQuickIconDisabled", { enumerable: true, get: function () { return preferences_1.getQuickIconDisabled; } });
66
+ Object.defineProperty(exports, "setQuickIconDisabled", { enumerable: true, get: function () { return preferences_1.setQuickIconDisabled; } });
67
+ Object.defineProperty(exports, "clearQuickIconDisabled", { enumerable: true, get: function () { return preferences_1.clearQuickIconDisabled; } });
62
68
  var auth_1 = require("./auth");
63
69
  Object.defineProperty(exports, "configureAuthEndpoints", { enumerable: true, get: function () { return auth_1.configureAuthEndpoints; } });
64
70
  Object.defineProperty(exports, "getConvexSiteUrl", { enumerable: true, get: function () { return auth_1.getConvexSiteUrl; } });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * SDK user preferences persisted across launches.
3
+ *
4
+ * Currently only the quick-action icon's user-level dismiss flag
5
+ * lives here: the dev enables the icon via `FeedbackConfig.quickIcon`,
6
+ * but the *user* can long-press → Hide to opt out, and we remember
7
+ * that choice across launches so their next app session still
8
+ * respects it.
9
+ *
10
+ * AsyncStorage is an optional peer dep — if it's not installed the
11
+ * getters return `false` and the setters silently no-op, so the icon
12
+ * still works (it just can't remember the disable beyond the
13
+ * in-memory session).
14
+ */
15
+ /** True if the user has long-pressed the icon and chosen "Hide". */
16
+ export declare function getQuickIconDisabled(): Promise<boolean>;
17
+ export declare function setQuickIconDisabled(disabled: boolean): Promise<void>;
18
+ export declare function clearQuickIconDisabled(): Promise<void>;
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ /**
3
+ * SDK user preferences persisted across launches.
4
+ *
5
+ * Currently only the quick-action icon's user-level dismiss flag
6
+ * lives here: the dev enables the icon via `FeedbackConfig.quickIcon`,
7
+ * but the *user* can long-press → Hide to opt out, and we remember
8
+ * that choice across launches so their next app session still
9
+ * respects it.
10
+ *
11
+ * AsyncStorage is an optional peer dep — if it's not installed the
12
+ * getters return `false` and the setters silently no-op, so the icon
13
+ * still works (it just can't remember the disable beyond the
14
+ * in-memory session).
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.getQuickIconDisabled = getQuickIconDisabled;
18
+ exports.setQuickIconDisabled = setQuickIconDisabled;
19
+ exports.clearQuickIconDisabled = clearQuickIconDisabled;
20
+ let AsyncStorage = null;
21
+ try {
22
+ AsyncStorage = require('@react-native-async-storage/async-storage').default;
23
+ }
24
+ catch {
25
+ // not installed — degrade gracefully
26
+ }
27
+ const QUICK_ICON_DISABLED_KEY = 'yaver_feedback_quickicon_disabled';
28
+ /** True if the user has long-pressed the icon and chosen "Hide". */
29
+ async function getQuickIconDisabled() {
30
+ if (!AsyncStorage)
31
+ return false;
32
+ try {
33
+ const v = await AsyncStorage.getItem(QUICK_ICON_DISABLED_KEY);
34
+ return v === '1';
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ async function setQuickIconDisabled(disabled) {
41
+ if (!AsyncStorage)
42
+ return;
43
+ try {
44
+ if (disabled) {
45
+ await AsyncStorage.setItem(QUICK_ICON_DISABLED_KEY, '1');
46
+ }
47
+ else {
48
+ await AsyncStorage.removeItem(QUICK_ICON_DISABLED_KEY);
49
+ }
50
+ }
51
+ catch {
52
+ // best-effort
53
+ }
54
+ }
55
+ async function clearQuickIconDisabled() {
56
+ await setQuickIconDisabled(false);
57
+ }
package/dist/types.d.ts CHANGED
@@ -42,6 +42,37 @@ export interface FeedbackConfig {
42
42
  preferredDeviceId?: string;
43
43
  /** How feedback collection is triggered */
44
44
  trigger?: 'shake' | 'floating-button' | 'manual';
45
+ /**
46
+ * Small tap-to-open icon that floats above the app so the user
47
+ * doesn't have to shake every time they want to open feedback.
48
+ * Single tap → open the feedback modal; long-press → menu with
49
+ * "Hide icon" (persisted across launches via AsyncStorage).
50
+ *
51
+ * - `'auto'` (default) → `'always'` on iOS/Android, `'off'` on web.
52
+ * - `'always'` → visible from app launch.
53
+ * - `'after-shake'` → hidden until the first shake this session.
54
+ * - `'off'` → never rendered. Shake still works.
55
+ *
56
+ * The user can always override `'always'` / `'auto'` by long-pressing
57
+ * → Hide. Devs can clear that override via
58
+ * `YaverFeedback.resetQuickIconPreference()`.
59
+ */
60
+ quickIcon?: 'auto' | 'always' | 'after-shake' | 'off';
61
+ /**
62
+ * Background color for the quick-action icon. Default: '#6366f1'
63
+ * (indigo). Pick something distinctive so the icon never visually
64
+ * collides with your own FAB.
65
+ */
66
+ quickIconColor?: string;
67
+ /**
68
+ * Initial position of the quick-action icon, in pixels from the
69
+ * top-left. Default: near the top-right corner of the screen. The
70
+ * icon is draggable at runtime — this is only the first mount.
71
+ */
72
+ quickIconInitialPosition?: {
73
+ x: number;
74
+ y: number;
75
+ };
45
76
  /** Enable/disable the SDK. Defaults to __DEV__ */
46
77
  enabled?: boolean;
47
78
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yaver-feedback-react-native",
3
- "version": "0.7.17",
3
+ "version": "0.8.0",
4
4
  "description": "Visual feedback SDK for Yaver — bug reports, screen recording, voice annotations, and local-first developer workflows",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -22,6 +22,7 @@ import {
22
22
  import { uploadFeedback } from './upload';
23
23
  import { DeviceInfo, FeedbackBundle } from './types';
24
24
  import { AuthOverlay } from './AuthOverlay';
25
+ import { QuickActionIcon } from './QuickActionIcon';
25
26
 
26
27
  /**
27
28
  * Simplified feedback modal — 5 actions:
@@ -66,6 +67,11 @@ export const FeedbackModal: React.FC = () => {
66
67
  // hidden button instead of a runtime error.
67
68
  const voiceSupported = useRef<boolean>(isVoiceCaptureSupported()).current;
68
69
  const [lastVideo, setLastVideo] = useState<LastVideo | null>(null);
70
+ // Tracks whether the user has hidden the QuickActionIcon via its
71
+ // long-press menu. Shake is always available, so the feedback modal
72
+ // is our guaranteed UI for bringing the icon back — we surface a
73
+ // small "Show quick icon" row when this is true.
74
+ const [quickIconHidden, setQuickIconHidden] = useState(false);
69
75
  // Vibing-input mode: same expand-on-tap pattern as email login.
70
76
  // Tap "Vibing" once → the button reveals an input + Send; that lets
71
77
  // the user say WHAT they want to vibe on instead of firing a canned
@@ -84,6 +90,14 @@ export const FeedbackModal: React.FC = () => {
84
90
  setError(null);
85
91
  setToast(null);
86
92
  setAction('idle');
93
+ // Re-read the "user hid the quick icon" flag on every open so
94
+ // the re-enable row reflects the latest preference (the user
95
+ // might have hidden or shown it between opens).
96
+ YaverFeedback.isQuickIconHidden()
97
+ .then((v) => {
98
+ if (mountedRef.current) setQuickIconHidden(v);
99
+ })
100
+ .catch(() => {});
87
101
  }
88
102
  });
89
103
  // Agent streams build / compile progress through the BlackBox
@@ -517,6 +531,7 @@ export const FeedbackModal: React.FC = () => {
517
531
  return (
518
532
  <>
519
533
  <AuthOverlay />
534
+ <QuickActionIcon />
520
535
  {visible && (
521
536
  <Modal
522
537
  visible={visible}
@@ -676,6 +691,34 @@ export const FeedbackModal: React.FC = () => {
676
691
  )}
677
692
  {toast && <Text style={styles.toast}>{toast}</Text>}
678
693
  {error && <Text style={styles.error}>{error}</Text>}
694
+
695
+ {/* Quick-icon toggle. The user's three ways to control
696
+ the floating icon are: (1) long-press the icon →
697
+ Hide, (2) tap this row to toggle it on/off, (3) shake
698
+ → this modal → tap this row. Shake is the unkillable
699
+ back-door when the icon is hidden and the dev hasn't
700
+ exposed their own settings UI. */}
701
+ <Pressable
702
+ onPress={async () => {
703
+ const next = !quickIconHidden;
704
+ setQuickIconHidden(next);
705
+ await YaverFeedback.setQuickIconVisible(!next);
706
+ }}
707
+ style={({ pressed }) => [
708
+ styles.quickIconToggle,
709
+ pressed && { opacity: 0.7 },
710
+ ]}
711
+ accessibilityRole="button"
712
+ accessibilityLabel={
713
+ quickIconHidden ? 'Show quick icon' : 'Hide quick icon'
714
+ }
715
+ >
716
+ <Text style={styles.quickIconToggleText}>
717
+ {quickIconHidden
718
+ ? '◯ Show quick-access icon'
719
+ : '● Hide quick-access icon'}
720
+ </Text>
721
+ </Pressable>
679
722
  </Pressable>
680
723
  </Pressable>
681
724
  </Modal>
@@ -849,4 +892,15 @@ const styles = StyleSheet.create({
849
892
  textAlign: 'center',
850
893
  marginTop: 4,
851
894
  },
895
+ quickIconToggle: {
896
+ marginTop: 4,
897
+ alignSelf: 'center',
898
+ paddingVertical: 6,
899
+ paddingHorizontal: 12,
900
+ },
901
+ quickIconToggleText: {
902
+ color: '#9ca3af',
903
+ fontSize: 12,
904
+ fontWeight: '500',
905
+ },
852
906
  });
@@ -0,0 +1,332 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ Animated,
4
+ DeviceEventEmitter,
5
+ Dimensions,
6
+ NativeModules,
7
+ PanResponder,
8
+ Platform,
9
+ Pressable,
10
+ StyleSheet,
11
+ Text,
12
+ View,
13
+ } from 'react-native';
14
+ import { YaverFeedback } from './YaverFeedback';
15
+ import { getQuickIconDisabled, setQuickIconDisabled } from './preferences';
16
+
17
+ // Mirror the suppression rule used by YaverFeedback + ShakeDetector:
18
+ // when loaded through Yaver's super-host Hermes bundle, the host owns
19
+ // shake + reload UX. We must not render a second action surface.
20
+ function isRunningInsideYaverHost(): boolean {
21
+ try {
22
+ return !!(NativeModules as any)?.YaverInfo;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ const DEFAULT_SIZE = 44;
29
+ const DEFAULT_COLOR = '#6366f1';
30
+ const LONG_PRESS_MS = 550;
31
+
32
+ export interface QuickActionIconProps {
33
+ /** Override the color from FeedbackConfig.quickIconColor. */
34
+ color?: string;
35
+ /** Override the initial position from FeedbackConfig.quickIconInitialPosition. */
36
+ initialPosition?: { x: number; y: number };
37
+ /** Override the icon diameter. Default 44. */
38
+ size?: number;
39
+ }
40
+
41
+ /**
42
+ * Small tap-to-open icon for the Yaver Feedback SDK.
43
+ *
44
+ * Default UX:
45
+ * - **Tap** opens the feedback modal (same as shake).
46
+ * - **Long-press** (~550ms) opens a menu with "Open feedback" and
47
+ * "Hide icon". Hiding is persisted to AsyncStorage so the user's
48
+ * decision survives app relaunches.
49
+ * - **Drag** repositions the icon.
50
+ *
51
+ * Shake always keeps working independently — even when the icon is
52
+ * hidden the user can still shake to open feedback.
53
+ *
54
+ * Visibility is controlled by `FeedbackConfig.quickIcon`:
55
+ * - `'auto'` (default) → `'always'` on iOS/Android, `'off'` on web.
56
+ * - `'always'` → visible from first render.
57
+ * - `'after-shake'` → hidden until `yaverFeedback:firstShake` fires.
58
+ * - `'off'` → never rendered.
59
+ *
60
+ * Suppressed entirely when the SDK is loaded inside Yaver's super-host
61
+ * (the Yaver mobile app owns the shake gesture + overlay in that case).
62
+ */
63
+ export const QuickActionIcon: React.FC<QuickActionIconProps> = ({
64
+ color: colorProp,
65
+ initialPosition: initialPositionProp,
66
+ size = DEFAULT_SIZE,
67
+ }) => {
68
+ const config = YaverFeedback.getConfig();
69
+
70
+ const mode: 'always' | 'after-shake' | 'off' = (() => {
71
+ const raw = config?.quickIcon ?? 'auto';
72
+ if (raw === 'auto') {
73
+ return Platform.OS === 'web' ? 'off' : 'always';
74
+ }
75
+ return raw;
76
+ })();
77
+
78
+ const color = colorProp ?? config?.quickIconColor ?? DEFAULT_COLOR;
79
+
80
+ const { width, height } = Dimensions.get('window');
81
+ const defaultStart =
82
+ initialPositionProp ??
83
+ config?.quickIconInitialPosition ?? {
84
+ x: Math.max(width - size - 14, 0),
85
+ y: Math.max(Math.floor(height * 0.35), 80),
86
+ };
87
+
88
+ const pan = useRef(new Animated.ValueXY(defaultStart)).current;
89
+ const lastPos = useRef(defaultStart);
90
+ const dragStart = useRef<{ x: number; y: number } | null>(null);
91
+ const didDrag = useRef(false);
92
+
93
+ const [userDisabled, setUserDisabled] = useState<boolean | null>(null);
94
+ const [shakenThisSession, setShakenThisSession] = useState(false);
95
+ const [menuOpen, setMenuOpen] = useState(false);
96
+ const [hostSuppressed] = useState<boolean>(() => isRunningInsideYaverHost());
97
+
98
+ // Load the persisted disable flag once on mount. Until it resolves we
99
+ // render nothing — a one-frame flash of the icon before hiding would
100
+ // be worse than a tiny delayed appearance.
101
+ useEffect(() => {
102
+ let alive = true;
103
+ getQuickIconDisabled().then((v) => {
104
+ if (alive) setUserDisabled(v);
105
+ });
106
+ return () => {
107
+ alive = false;
108
+ };
109
+ }, []);
110
+
111
+ // `after-shake` mode waits for the first shake before revealing
112
+ // itself. YaverFeedback emits this event from its shake callback.
113
+ useEffect(() => {
114
+ const sub = DeviceEventEmitter.addListener(
115
+ 'yaverFeedback:firstShake',
116
+ () => setShakenThisSession(true),
117
+ );
118
+ return () => sub.remove();
119
+ }, []);
120
+
121
+ // Programmatic control: host apps can call
122
+ // `YaverFeedback.setQuickIconVisible(true)` to re-surface the icon
123
+ // after the user hid it (e.g. from a settings screen).
124
+ useEffect(() => {
125
+ const showSub = DeviceEventEmitter.addListener(
126
+ 'yaverFeedback:quickIconShow',
127
+ () => {
128
+ setUserDisabled(false);
129
+ void setQuickIconDisabled(false);
130
+ },
131
+ );
132
+ const hideSub = DeviceEventEmitter.addListener(
133
+ 'yaverFeedback:quickIconHide',
134
+ () => {
135
+ setUserDisabled(true);
136
+ void setQuickIconDisabled(true);
137
+ setMenuOpen(false);
138
+ },
139
+ );
140
+ return () => {
141
+ showSub.remove();
142
+ hideSub.remove();
143
+ };
144
+ }, []);
145
+
146
+ const panResponder = useRef(
147
+ PanResponder.create({
148
+ onStartShouldSetPanResponder: () => true,
149
+ onMoveShouldSetPanResponder: (_, g) =>
150
+ Math.abs(g.dx) > 3 || Math.abs(g.dy) > 3,
151
+ onPanResponderGrant: () => {
152
+ didDrag.current = false;
153
+ dragStart.current = { ...lastPos.current };
154
+ pan.setOffset({ x: lastPos.current.x, y: lastPos.current.y });
155
+ pan.setValue({ x: 0, y: 0 });
156
+ },
157
+ onPanResponderMove: (_, g) => {
158
+ if (Math.abs(g.dx) > 3 || Math.abs(g.dy) > 3) {
159
+ didDrag.current = true;
160
+ }
161
+ Animated.event([null, { dx: pan.x, dy: pan.y }], {
162
+ useNativeDriver: false,
163
+ })(_, g);
164
+ },
165
+ onPanResponderRelease: (_, g) => {
166
+ pan.flattenOffset();
167
+ const start = dragStart.current ?? lastPos.current;
168
+ const maxX = Math.max(width - size, 0);
169
+ const maxY = Math.max(height - size, 0);
170
+ const nextX = Math.max(0, Math.min(maxX, start.x + g.dx));
171
+ const nextY = Math.max(0, Math.min(maxY, start.y + g.dy));
172
+ lastPos.current = { x: nextX, y: nextY };
173
+ Animated.spring(pan, {
174
+ toValue: { x: nextX, y: nextY },
175
+ useNativeDriver: false,
176
+ friction: 7,
177
+ }).start();
178
+ },
179
+ }),
180
+ ).current;
181
+
182
+ const openFeedback = useCallback(() => {
183
+ setMenuOpen(false);
184
+ void YaverFeedback.startReport();
185
+ }, []);
186
+
187
+ const hideForever = useCallback(() => {
188
+ setMenuOpen(false);
189
+ setUserDisabled(true);
190
+ void setQuickIconDisabled(true);
191
+ }, []);
192
+
193
+ if (hostSuppressed) return null;
194
+ if (mode === 'off') return null;
195
+ if (userDisabled === null) return null;
196
+ if (userDisabled) return null;
197
+ if (mode === 'after-shake' && !shakenThisSession) return null;
198
+ if (!YaverFeedback.isEnabled()) return null;
199
+
200
+ const visualSize = size;
201
+ const radius = visualSize / 2;
202
+
203
+ return (
204
+ <Animated.View
205
+ pointerEvents="box-none"
206
+ style={[
207
+ StyleSheet.absoluteFill,
208
+ { zIndex: 9998 },
209
+ ]}
210
+ >
211
+ <Animated.View
212
+ {...panResponder.panHandlers}
213
+ style={[
214
+ styles.container,
215
+ {
216
+ transform: [{ translateX: pan.x }, { translateY: pan.y }],
217
+ },
218
+ ]}
219
+ >
220
+ <Pressable
221
+ onPress={() => {
222
+ if (didDrag.current) {
223
+ didDrag.current = false;
224
+ return;
225
+ }
226
+ openFeedback();
227
+ }}
228
+ onLongPress={() => {
229
+ if (didDrag.current) return;
230
+ setMenuOpen((m) => !m);
231
+ }}
232
+ delayLongPress={LONG_PRESS_MS}
233
+ hitSlop={6}
234
+ accessibilityRole="button"
235
+ accessibilityLabel="Open Yaver feedback"
236
+ style={({ pressed }) => [
237
+ styles.icon,
238
+ {
239
+ width: visualSize,
240
+ height: visualSize,
241
+ borderRadius: radius,
242
+ backgroundColor: color,
243
+ opacity: pressed ? 0.85 : 1,
244
+ },
245
+ ]}
246
+ >
247
+ <Text style={[styles.iconLabel, { fontSize: Math.round(visualSize * 0.5) }]}>y</Text>
248
+ </Pressable>
249
+ {menuOpen ? (
250
+ <View style={styles.menu}>
251
+ <Pressable
252
+ onPress={openFeedback}
253
+ style={({ pressed }) => [
254
+ styles.menuItem,
255
+ pressed && styles.menuItemPressed,
256
+ ]}
257
+ >
258
+ <Text style={styles.menuItemText}>Open feedback</Text>
259
+ </Pressable>
260
+ <View style={styles.menuDivider} />
261
+ <Pressable
262
+ onPress={hideForever}
263
+ style={({ pressed }) => [
264
+ styles.menuItem,
265
+ pressed && styles.menuItemPressed,
266
+ ]}
267
+ >
268
+ <Text style={[styles.menuItemText, styles.menuItemDanger]}>
269
+ Hide icon
270
+ </Text>
271
+ </Pressable>
272
+ </View>
273
+ ) : null}
274
+ </Animated.View>
275
+ </Animated.View>
276
+ );
277
+ };
278
+
279
+ const styles = StyleSheet.create({
280
+ container: {
281
+ position: 'absolute',
282
+ top: 0,
283
+ left: 0,
284
+ alignItems: 'flex-start',
285
+ },
286
+ icon: {
287
+ alignItems: 'center',
288
+ justifyContent: 'center',
289
+ shadowColor: '#000',
290
+ shadowOffset: { width: 0, height: 2 },
291
+ shadowOpacity: 0.25,
292
+ shadowRadius: 4,
293
+ elevation: 4,
294
+ },
295
+ iconLabel: {
296
+ color: '#ffffff',
297
+ fontWeight: '700',
298
+ includeFontPadding: false,
299
+ },
300
+ menu: {
301
+ marginTop: 6,
302
+ minWidth: 150,
303
+ backgroundColor: '#1f1f23',
304
+ borderRadius: 10,
305
+ paddingVertical: 4,
306
+ shadowColor: '#000',
307
+ shadowOffset: { width: 0, height: 2 },
308
+ shadowOpacity: 0.3,
309
+ shadowRadius: 6,
310
+ elevation: 6,
311
+ },
312
+ menuItem: {
313
+ paddingHorizontal: 14,
314
+ paddingVertical: 10,
315
+ },
316
+ menuItemPressed: {
317
+ backgroundColor: '#2a2a30',
318
+ },
319
+ menuItemText: {
320
+ color: '#f4f4f5',
321
+ fontSize: 14,
322
+ fontWeight: '500',
323
+ },
324
+ menuItemDanger: {
325
+ color: '#f97316',
326
+ },
327
+ menuDivider: {
328
+ height: StyleSheet.hairlineWidth,
329
+ backgroundColor: '#3f3f46',
330
+ marginHorizontal: 8,
331
+ },
332
+ });
@@ -13,6 +13,10 @@ import {
13
13
  clearSelectedDeviceId,
14
14
  DEFAULT_CONVEX_SITE_URL,
15
15
  } from './auth';
16
+ import {
17
+ getQuickIconDisabled,
18
+ setQuickIconDisabled,
19
+ } from './preferences';
16
20
 
17
21
  // Is this JS runtime the Yaver mobile app's super-host bridge? The
18
22
  // YaverInfo native module is only registered inside Yaver's container
@@ -49,6 +53,14 @@ let maxErrors = 5;
49
53
  /** Track whether BlackBox was running before disable (to restart on enable). */
50
54
  let blackBoxWasStreaming = false;
51
55
 
56
+ /**
57
+ * Tracks whether the user has already shaken once in this process.
58
+ * Consumed by QuickActionIcon's `'after-shake'` mode so the icon
59
+ * appears the first time a user discovers shake and stays around
60
+ * thereafter.
61
+ */
62
+ let firstShakeFired = false;
63
+
52
64
  /**
53
65
  * Flag evaluation cache — 30s TTL per `userId|key`. Prevents a
54
66
  * tight render loop from hammering /flags/eval when the dev calls
@@ -134,6 +146,7 @@ export class YaverFeedback {
134
146
  if (enabled && config.trigger === 'shake') {
135
147
  shakeDetector = new ShakeDetector();
136
148
  shakeDetector.start(() => {
149
+ YaverFeedback.notifyShake();
137
150
  if (config?.reportingOnly) {
138
151
  YaverFeedback.sendAutoReport();
139
152
  } else {
@@ -464,6 +477,7 @@ export class YaverFeedback {
464
477
  if (config?.trigger === 'shake' && !shakeDetector) {
465
478
  shakeDetector = new ShakeDetector();
466
479
  shakeDetector.start(() => {
480
+ YaverFeedback.notifyShake();
467
481
  if (config?.reportingOnly) {
468
482
  YaverFeedback.sendAutoReport();
469
483
  } else {
@@ -796,6 +810,59 @@ export class YaverFeedback {
796
810
  }
797
811
  }
798
812
 
813
+ /**
814
+ * Internal: fired from every shake path (dev-menu + accelerometer)
815
+ * before the feedback modal opens. Emits `yaverFeedback:firstShake`
816
+ * exactly once per process so QuickActionIcon's `'after-shake'` mode
817
+ * can surface itself on first shake and stay visible for the rest of
818
+ * the session.
819
+ */
820
+ static notifyShake(): void {
821
+ if (firstShakeFired) return;
822
+ firstShakeFired = true;
823
+ try {
824
+ const { DeviceEventEmitter } = require('react-native');
825
+ DeviceEventEmitter.emit('yaverFeedback:firstShake');
826
+ } catch {
827
+ // emitter unavailable (e.g. jsdom unit test) — safe to ignore
828
+ }
829
+ }
830
+
831
+ /**
832
+ * Show / hide the QuickActionIcon programmatically and persist the
833
+ * choice across launches. Host apps can call this from a settings
834
+ * screen so the user has a second way to re-enable the icon after
835
+ * hiding it via the icon's own long-press menu — shake is always the
836
+ * third back-door because it never depends on a visible control.
837
+ */
838
+ static async setQuickIconVisible(visible: boolean): Promise<void> {
839
+ await setQuickIconDisabled(!visible);
840
+ try {
841
+ const { DeviceEventEmitter } = require('react-native');
842
+ DeviceEventEmitter.emit(
843
+ visible ? 'yaverFeedback:quickIconShow' : 'yaverFeedback:quickIconHide',
844
+ );
845
+ } catch {
846
+ // emitter unavailable — preference is still persisted
847
+ }
848
+ }
849
+
850
+ /**
851
+ * Returns `true` when the user has chosen to hide the QuickActionIcon
852
+ * (via its long-press menu or `setQuickIconVisible(false)`).
853
+ * FeedbackModal uses this to surface a one-tap "Show quick icon"
854
+ * control so the user can bring the icon back without having to know
855
+ * about the programmatic API.
856
+ */
857
+ static async isQuickIconHidden(): Promise<boolean> {
858
+ return getQuickIconDisabled();
859
+ }
860
+
861
+ /** Clear the persisted "user hid the icon" flag. */
862
+ static async resetQuickIconPreference(): Promise<void> {
863
+ await YaverFeedback.setQuickIconVisible(true);
864
+ }
865
+
799
866
  /** Tear down the SDK (stop shake detector, clear state). */
800
867
  static destroy(): void {
801
868
  if (shakeDetector) {
package/src/index.ts CHANGED
@@ -45,7 +45,14 @@ export { AuthOverlay } from './AuthOverlay';
45
45
  export { ShakeDetector } from './ShakeDetector';
46
46
  export { FloatingButton } from './FloatingButton';
47
47
  export { FeedbackModal } from './FeedbackModal';
48
+ export { QuickActionIcon } from './QuickActionIcon';
49
+ export type { QuickActionIconProps } from './QuickActionIcon';
48
50
  export { FixReport } from './FixReport';
51
+ export {
52
+ getQuickIconDisabled,
53
+ setQuickIconDisabled,
54
+ clearQuickIconDisabled,
55
+ } from './preferences';
49
56
  export {
50
57
  configureAuthEndpoints,
51
58
  getConvexSiteUrl,
@@ -0,0 +1,55 @@
1
+ /**
2
+ * SDK user preferences persisted across launches.
3
+ *
4
+ * Currently only the quick-action icon's user-level dismiss flag
5
+ * lives here: the dev enables the icon via `FeedbackConfig.quickIcon`,
6
+ * but the *user* can long-press → Hide to opt out, and we remember
7
+ * that choice across launches so their next app session still
8
+ * respects it.
9
+ *
10
+ * AsyncStorage is an optional peer dep — if it's not installed the
11
+ * getters return `false` and the setters silently no-op, so the icon
12
+ * still works (it just can't remember the disable beyond the
13
+ * in-memory session).
14
+ */
15
+
16
+ let AsyncStorage: {
17
+ getItem: (key: string) => Promise<string | null>;
18
+ setItem: (key: string, value: string) => Promise<void>;
19
+ removeItem: (key: string) => Promise<void>;
20
+ } | null = null;
21
+ try {
22
+ AsyncStorage = require('@react-native-async-storage/async-storage').default;
23
+ } catch {
24
+ // not installed — degrade gracefully
25
+ }
26
+
27
+ const QUICK_ICON_DISABLED_KEY = 'yaver_feedback_quickicon_disabled';
28
+
29
+ /** True if the user has long-pressed the icon and chosen "Hide". */
30
+ export async function getQuickIconDisabled(): Promise<boolean> {
31
+ if (!AsyncStorage) return false;
32
+ try {
33
+ const v = await AsyncStorage.getItem(QUICK_ICON_DISABLED_KEY);
34
+ return v === '1';
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ export async function setQuickIconDisabled(disabled: boolean): Promise<void> {
41
+ if (!AsyncStorage) return;
42
+ try {
43
+ if (disabled) {
44
+ await AsyncStorage.setItem(QUICK_ICON_DISABLED_KEY, '1');
45
+ } else {
46
+ await AsyncStorage.removeItem(QUICK_ICON_DISABLED_KEY);
47
+ }
48
+ } catch {
49
+ // best-effort
50
+ }
51
+ }
52
+
53
+ export async function clearQuickIconDisabled(): Promise<void> {
54
+ await setQuickIconDisabled(false);
55
+ }
package/src/types.ts CHANGED
@@ -42,6 +42,34 @@ export interface FeedbackConfig {
42
42
  preferredDeviceId?: string;
43
43
  /** How feedback collection is triggered */
44
44
  trigger?: 'shake' | 'floating-button' | 'manual';
45
+ /**
46
+ * Small tap-to-open icon that floats above the app so the user
47
+ * doesn't have to shake every time they want to open feedback.
48
+ * Single tap → open the feedback modal; long-press → menu with
49
+ * "Hide icon" (persisted across launches via AsyncStorage).
50
+ *
51
+ * - `'auto'` (default) → `'always'` on iOS/Android, `'off'` on web.
52
+ * - `'always'` → visible from app launch.
53
+ * - `'after-shake'` → hidden until the first shake this session.
54
+ * - `'off'` → never rendered. Shake still works.
55
+ *
56
+ * The user can always override `'always'` / `'auto'` by long-pressing
57
+ * → Hide. Devs can clear that override via
58
+ * `YaverFeedback.resetQuickIconPreference()`.
59
+ */
60
+ quickIcon?: 'auto' | 'always' | 'after-shake' | 'off';
61
+ /**
62
+ * Background color for the quick-action icon. Default: '#6366f1'
63
+ * (indigo). Pick something distinctive so the icon never visually
64
+ * collides with your own FAB.
65
+ */
66
+ quickIconColor?: string;
67
+ /**
68
+ * Initial position of the quick-action icon, in pixels from the
69
+ * top-left. Default: near the top-right corner of the screen. The
70
+ * icon is draggable at runtime — this is only the first mount.
71
+ */
72
+ quickIconInitialPosition?: { x: number; y: number };
45
73
  /** Enable/disable the SDK. Defaults to __DEV__ */
46
74
  enabled?: boolean;
47
75
  /**