yaver-feedback-react-native 0.7.17 → 0.8.1
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 +40 -6
- package/dist/FeedbackModal.js +259 -203
- package/dist/P2PClient.js +7 -0
- package/dist/QuickActionIcon.d.ts +43 -0
- package/dist/QuickActionIcon.js +324 -0
- package/dist/YaverFeedback.d.ts +26 -0
- package/dist/YaverFeedback.js +62 -0
- package/dist/capture.d.ts +11 -0
- package/dist/capture.js +59 -0
- package/dist/index.d.ts +12 -7
- package/dist/index.js +16 -7
- package/dist/preferences.d.ts +18 -0
- package/dist/preferences.js +57 -0
- package/dist/types.d.ts +56 -0
- package/dist/upload.d.ts +1 -0
- package/dist/upload.js +8 -0
- package/package.json +6 -2
- package/src/FeedbackModal.tsx +311 -243
- package/src/P2PClient.ts +8 -0
- package/src/QuickActionIcon.tsx +375 -0
- package/src/YaverFeedback.ts +69 -0
- package/src/capture.ts +74 -0
- package/src/index.ts +16 -6
- package/src/preferences.ts +55 -0
- package/src/types.ts +53 -0
- package/src/upload.ts +9 -0
package/src/P2PClient.ts
CHANGED
|
@@ -179,6 +179,14 @@ export class P2PClient {
|
|
|
179
179
|
} as any);
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
if (bundle.audio) {
|
|
183
|
+
formData.append('audio', {
|
|
184
|
+
uri: Platform.OS === 'android' ? `file://${bundle.audio}` : bundle.audio,
|
|
185
|
+
type: 'audio/m4a',
|
|
186
|
+
name: 'voice_note.m4a',
|
|
187
|
+
} as any);
|
|
188
|
+
}
|
|
189
|
+
|
|
182
190
|
const response = await fetch(`${this.baseUrl}/feedback`, {
|
|
183
191
|
method: 'POST',
|
|
184
192
|
headers: {
|
|
@@ -0,0 +1,375 @@
|
|
|
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_BACKGROUND_COLOR = '#ff6b2c';
|
|
30
|
+
const DEFAULT_LABEL_COLOR = '#111111';
|
|
31
|
+
const DEFAULT_BORDER_COLOR = 'rgba(255,255,255,0.92)';
|
|
32
|
+
const DEFAULT_SHADOW_COLOR = '#000000';
|
|
33
|
+
const LONG_PRESS_MS = 550;
|
|
34
|
+
|
|
35
|
+
export interface QuickActionIconProps {
|
|
36
|
+
/** Deprecated alias for `backgroundColor`. */
|
|
37
|
+
color?: string;
|
|
38
|
+
/** Override the background from FeedbackConfig.quickIconBackgroundColor. */
|
|
39
|
+
backgroundColor?: string;
|
|
40
|
+
/** Override the label color from FeedbackConfig.quickIconForegroundColor. */
|
|
41
|
+
foregroundColor?: string;
|
|
42
|
+
/** Override the border color from FeedbackConfig.quickIconBorderColor. */
|
|
43
|
+
borderColor?: string;
|
|
44
|
+
/** Override the shadow color from FeedbackConfig.quickIconShadowColor. */
|
|
45
|
+
shadowColor?: string;
|
|
46
|
+
/** Override the initial position from FeedbackConfig.quickIconInitialPosition. */
|
|
47
|
+
initialPosition?: { x: number; y: number };
|
|
48
|
+
/** Override the icon diameter. Default 44. */
|
|
49
|
+
size?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Small tap-to-open icon for the Yaver Feedback SDK.
|
|
54
|
+
*
|
|
55
|
+
* Default UX:
|
|
56
|
+
* - **Tap** opens the feedback modal (same as shake).
|
|
57
|
+
* - **Long-press** (~550ms) opens a menu with "Open feedback" and
|
|
58
|
+
* "Hide icon". Hiding is persisted to AsyncStorage so the user's
|
|
59
|
+
* decision survives app relaunches.
|
|
60
|
+
* - **Drag** repositions the icon.
|
|
61
|
+
*
|
|
62
|
+
* Shake always keeps working independently — even when the icon is
|
|
63
|
+
* hidden the user can still shake to open feedback.
|
|
64
|
+
*
|
|
65
|
+
* Visibility is controlled by `FeedbackConfig.quickIcon`:
|
|
66
|
+
* - `'auto'` (default) → `'after-shake'` on iOS/Android, `'off'` on web.
|
|
67
|
+
* - `'always'` → visible from first render.
|
|
68
|
+
* - `'after-shake'` → hidden until `yaverFeedback:firstShake` fires.
|
|
69
|
+
* - `'off'` → never rendered.
|
|
70
|
+
*
|
|
71
|
+
* Suppressed entirely when the SDK is loaded inside Yaver's super-host
|
|
72
|
+
* (the Yaver mobile app owns the shake gesture + overlay in that case).
|
|
73
|
+
*/
|
|
74
|
+
export const QuickActionIcon: React.FC<QuickActionIconProps> = ({
|
|
75
|
+
color: colorProp,
|
|
76
|
+
backgroundColor: backgroundColorProp,
|
|
77
|
+
foregroundColor: foregroundColorProp,
|
|
78
|
+
borderColor: borderColorProp,
|
|
79
|
+
shadowColor: shadowColorProp,
|
|
80
|
+
initialPosition: initialPositionProp,
|
|
81
|
+
size = DEFAULT_SIZE,
|
|
82
|
+
}) => {
|
|
83
|
+
const config = YaverFeedback.getConfig();
|
|
84
|
+
|
|
85
|
+
const mode: 'always' | 'after-shake' | 'off' = (() => {
|
|
86
|
+
const raw = config?.quickIcon ?? 'auto';
|
|
87
|
+
if (raw === 'auto') {
|
|
88
|
+
return Platform.OS === 'web' ? 'off' : 'after-shake';
|
|
89
|
+
}
|
|
90
|
+
return raw;
|
|
91
|
+
})();
|
|
92
|
+
|
|
93
|
+
const backgroundColor =
|
|
94
|
+
backgroundColorProp ??
|
|
95
|
+
colorProp ??
|
|
96
|
+
config?.quickIconBackgroundColor ??
|
|
97
|
+
config?.quickIconColor ??
|
|
98
|
+
DEFAULT_BACKGROUND_COLOR;
|
|
99
|
+
const foregroundColor =
|
|
100
|
+
foregroundColorProp ??
|
|
101
|
+
config?.quickIconForegroundColor ??
|
|
102
|
+
DEFAULT_LABEL_COLOR;
|
|
103
|
+
const borderColor =
|
|
104
|
+
borderColorProp ??
|
|
105
|
+
config?.quickIconBorderColor ??
|
|
106
|
+
DEFAULT_BORDER_COLOR;
|
|
107
|
+
const shadowColor =
|
|
108
|
+
shadowColorProp ??
|
|
109
|
+
config?.quickIconShadowColor ??
|
|
110
|
+
DEFAULT_SHADOW_COLOR;
|
|
111
|
+
|
|
112
|
+
const { width, height } = Dimensions.get('window');
|
|
113
|
+
const defaultStart =
|
|
114
|
+
initialPositionProp ??
|
|
115
|
+
config?.quickIconInitialPosition ?? {
|
|
116
|
+
x: Math.max(width - size - 14, 0),
|
|
117
|
+
y: Math.max(Math.floor(height * 0.35), 80),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const pan = useRef(new Animated.ValueXY(defaultStart)).current;
|
|
121
|
+
const lastPos = useRef(defaultStart);
|
|
122
|
+
const dragStart = useRef<{ x: number; y: number } | null>(null);
|
|
123
|
+
const didDrag = useRef(false);
|
|
124
|
+
|
|
125
|
+
const [userDisabled, setUserDisabled] = useState<boolean | null>(null);
|
|
126
|
+
const [shakenThisSession, setShakenThisSession] = useState(false);
|
|
127
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
128
|
+
const [hostSuppressed] = useState<boolean>(() => isRunningInsideYaverHost());
|
|
129
|
+
|
|
130
|
+
// Load the persisted disable flag once on mount. Until it resolves we
|
|
131
|
+
// render nothing — a one-frame flash of the icon before hiding would
|
|
132
|
+
// be worse than a tiny delayed appearance.
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
let alive = true;
|
|
135
|
+
getQuickIconDisabled().then((v) => {
|
|
136
|
+
if (alive) setUserDisabled(v);
|
|
137
|
+
});
|
|
138
|
+
return () => {
|
|
139
|
+
alive = false;
|
|
140
|
+
};
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
143
|
+
// `after-shake` mode waits for the first shake before revealing
|
|
144
|
+
// itself. YaverFeedback emits this event from its shake callback.
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
const sub = DeviceEventEmitter.addListener(
|
|
147
|
+
'yaverFeedback:firstShake',
|
|
148
|
+
() => setShakenThisSession(true),
|
|
149
|
+
);
|
|
150
|
+
return () => sub.remove();
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
// Programmatic control: host apps can call
|
|
154
|
+
// `YaverFeedback.setQuickIconVisible(true)` to re-surface the icon
|
|
155
|
+
// after the user hid it (e.g. from a settings screen).
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
const showSub = DeviceEventEmitter.addListener(
|
|
158
|
+
'yaverFeedback:quickIconShow',
|
|
159
|
+
() => {
|
|
160
|
+
setUserDisabled(false);
|
|
161
|
+
void setQuickIconDisabled(false);
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
const hideSub = DeviceEventEmitter.addListener(
|
|
165
|
+
'yaverFeedback:quickIconHide',
|
|
166
|
+
() => {
|
|
167
|
+
setUserDisabled(true);
|
|
168
|
+
void setQuickIconDisabled(true);
|
|
169
|
+
setMenuOpen(false);
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
return () => {
|
|
173
|
+
showSub.remove();
|
|
174
|
+
hideSub.remove();
|
|
175
|
+
};
|
|
176
|
+
}, []);
|
|
177
|
+
|
|
178
|
+
const panResponder = useRef(
|
|
179
|
+
PanResponder.create({
|
|
180
|
+
onStartShouldSetPanResponder: () => true,
|
|
181
|
+
onMoveShouldSetPanResponder: (_, g) =>
|
|
182
|
+
Math.abs(g.dx) > 3 || Math.abs(g.dy) > 3,
|
|
183
|
+
onPanResponderGrant: () => {
|
|
184
|
+
didDrag.current = false;
|
|
185
|
+
dragStart.current = { ...lastPos.current };
|
|
186
|
+
pan.setOffset({ x: lastPos.current.x, y: lastPos.current.y });
|
|
187
|
+
pan.setValue({ x: 0, y: 0 });
|
|
188
|
+
},
|
|
189
|
+
onPanResponderMove: (_, g) => {
|
|
190
|
+
if (Math.abs(g.dx) > 3 || Math.abs(g.dy) > 3) {
|
|
191
|
+
didDrag.current = true;
|
|
192
|
+
}
|
|
193
|
+
Animated.event([null, { dx: pan.x, dy: pan.y }], {
|
|
194
|
+
useNativeDriver: false,
|
|
195
|
+
})(_, g);
|
|
196
|
+
},
|
|
197
|
+
onPanResponderRelease: (_, g) => {
|
|
198
|
+
pan.flattenOffset();
|
|
199
|
+
const start = dragStart.current ?? lastPos.current;
|
|
200
|
+
const maxX = Math.max(width - size, 0);
|
|
201
|
+
const maxY = Math.max(height - size, 0);
|
|
202
|
+
const nextX = Math.max(0, Math.min(maxX, start.x + g.dx));
|
|
203
|
+
const nextY = Math.max(0, Math.min(maxY, start.y + g.dy));
|
|
204
|
+
lastPos.current = { x: nextX, y: nextY };
|
|
205
|
+
Animated.spring(pan, {
|
|
206
|
+
toValue: { x: nextX, y: nextY },
|
|
207
|
+
useNativeDriver: false,
|
|
208
|
+
friction: 7,
|
|
209
|
+
}).start();
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
).current;
|
|
213
|
+
|
|
214
|
+
const openFeedback = useCallback(() => {
|
|
215
|
+
setMenuOpen(false);
|
|
216
|
+
void YaverFeedback.startReport();
|
|
217
|
+
}, []);
|
|
218
|
+
|
|
219
|
+
const hideForever = useCallback(() => {
|
|
220
|
+
setMenuOpen(false);
|
|
221
|
+
setUserDisabled(true);
|
|
222
|
+
void setQuickIconDisabled(true);
|
|
223
|
+
}, []);
|
|
224
|
+
|
|
225
|
+
if (hostSuppressed) return null;
|
|
226
|
+
if (mode === 'off') return null;
|
|
227
|
+
if (userDisabled === null) return null;
|
|
228
|
+
if (userDisabled) return null;
|
|
229
|
+
if (mode === 'after-shake' && !shakenThisSession) return null;
|
|
230
|
+
if (!YaverFeedback.isEnabled()) return null;
|
|
231
|
+
|
|
232
|
+
const visualSize = size;
|
|
233
|
+
const radius = visualSize / 2;
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<Animated.View
|
|
237
|
+
pointerEvents="box-none"
|
|
238
|
+
style={[
|
|
239
|
+
StyleSheet.absoluteFill,
|
|
240
|
+
{ zIndex: 9998 },
|
|
241
|
+
]}
|
|
242
|
+
>
|
|
243
|
+
<Animated.View
|
|
244
|
+
{...panResponder.panHandlers}
|
|
245
|
+
style={[
|
|
246
|
+
styles.container,
|
|
247
|
+
{
|
|
248
|
+
transform: [{ translateX: pan.x }, { translateY: pan.y }],
|
|
249
|
+
},
|
|
250
|
+
]}
|
|
251
|
+
>
|
|
252
|
+
<Pressable
|
|
253
|
+
onPress={() => {
|
|
254
|
+
if (didDrag.current) {
|
|
255
|
+
didDrag.current = false;
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
openFeedback();
|
|
259
|
+
}}
|
|
260
|
+
onLongPress={() => {
|
|
261
|
+
if (didDrag.current) return;
|
|
262
|
+
setMenuOpen((m) => !m);
|
|
263
|
+
}}
|
|
264
|
+
delayLongPress={LONG_PRESS_MS}
|
|
265
|
+
hitSlop={6}
|
|
266
|
+
accessibilityRole="button"
|
|
267
|
+
accessibilityLabel="Open Yaver feedback"
|
|
268
|
+
style={({ pressed }) => [
|
|
269
|
+
styles.icon,
|
|
270
|
+
{
|
|
271
|
+
width: visualSize,
|
|
272
|
+
height: visualSize,
|
|
273
|
+
borderRadius: radius,
|
|
274
|
+
backgroundColor,
|
|
275
|
+
borderColor,
|
|
276
|
+
shadowColor,
|
|
277
|
+
opacity: pressed ? 0.85 : 1,
|
|
278
|
+
},
|
|
279
|
+
]}
|
|
280
|
+
>
|
|
281
|
+
<Text
|
|
282
|
+
style={[
|
|
283
|
+
styles.iconLabel,
|
|
284
|
+
{
|
|
285
|
+
color: foregroundColor,
|
|
286
|
+
fontSize: Math.round(visualSize * 0.5),
|
|
287
|
+
},
|
|
288
|
+
]}
|
|
289
|
+
>
|
|
290
|
+
y
|
|
291
|
+
</Text>
|
|
292
|
+
</Pressable>
|
|
293
|
+
{menuOpen ? (
|
|
294
|
+
<View style={styles.menu}>
|
|
295
|
+
<Pressable
|
|
296
|
+
onPress={openFeedback}
|
|
297
|
+
style={({ pressed }) => [
|
|
298
|
+
styles.menuItem,
|
|
299
|
+
pressed && styles.menuItemPressed,
|
|
300
|
+
]}
|
|
301
|
+
>
|
|
302
|
+
<Text style={styles.menuItemText}>Open feedback</Text>
|
|
303
|
+
</Pressable>
|
|
304
|
+
<View style={styles.menuDivider} />
|
|
305
|
+
<Pressable
|
|
306
|
+
onPress={hideForever}
|
|
307
|
+
style={({ pressed }) => [
|
|
308
|
+
styles.menuItem,
|
|
309
|
+
pressed && styles.menuItemPressed,
|
|
310
|
+
]}
|
|
311
|
+
>
|
|
312
|
+
<Text style={[styles.menuItemText, styles.menuItemDanger]}>
|
|
313
|
+
Hide icon
|
|
314
|
+
</Text>
|
|
315
|
+
</Pressable>
|
|
316
|
+
</View>
|
|
317
|
+
) : null}
|
|
318
|
+
</Animated.View>
|
|
319
|
+
</Animated.View>
|
|
320
|
+
);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const styles = StyleSheet.create({
|
|
324
|
+
container: {
|
|
325
|
+
position: 'absolute',
|
|
326
|
+
top: 0,
|
|
327
|
+
left: 0,
|
|
328
|
+
alignItems: 'flex-start',
|
|
329
|
+
},
|
|
330
|
+
icon: {
|
|
331
|
+
alignItems: 'center',
|
|
332
|
+
justifyContent: 'center',
|
|
333
|
+
shadowOffset: { width: 0, height: 2 },
|
|
334
|
+
shadowOpacity: 0.34,
|
|
335
|
+
shadowRadius: 6,
|
|
336
|
+
elevation: 7,
|
|
337
|
+
borderWidth: 2,
|
|
338
|
+
},
|
|
339
|
+
iconLabel: {
|
|
340
|
+
fontWeight: '700',
|
|
341
|
+
includeFontPadding: false,
|
|
342
|
+
},
|
|
343
|
+
menu: {
|
|
344
|
+
marginTop: 6,
|
|
345
|
+
minWidth: 150,
|
|
346
|
+
backgroundColor: '#1f1f23',
|
|
347
|
+
borderRadius: 10,
|
|
348
|
+
paddingVertical: 4,
|
|
349
|
+
shadowColor: '#000',
|
|
350
|
+
shadowOffset: { width: 0, height: 2 },
|
|
351
|
+
shadowOpacity: 0.3,
|
|
352
|
+
shadowRadius: 6,
|
|
353
|
+
elevation: 6,
|
|
354
|
+
},
|
|
355
|
+
menuItem: {
|
|
356
|
+
paddingHorizontal: 14,
|
|
357
|
+
paddingVertical: 10,
|
|
358
|
+
},
|
|
359
|
+
menuItemPressed: {
|
|
360
|
+
backgroundColor: '#2a2a30',
|
|
361
|
+
},
|
|
362
|
+
menuItemText: {
|
|
363
|
+
color: '#f4f4f5',
|
|
364
|
+
fontSize: 14,
|
|
365
|
+
fontWeight: '500',
|
|
366
|
+
},
|
|
367
|
+
menuItemDanger: {
|
|
368
|
+
color: '#f97316',
|
|
369
|
+
},
|
|
370
|
+
menuDivider: {
|
|
371
|
+
height: StyleSheet.hairlineWidth,
|
|
372
|
+
backgroundColor: '#3f3f46',
|
|
373
|
+
marginHorizontal: 8,
|
|
374
|
+
},
|
|
375
|
+
});
|
package/src/YaverFeedback.ts
CHANGED
|
@@ -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
|
|
@@ -80,6 +92,7 @@ export class YaverFeedback {
|
|
|
80
92
|
autoLogin: true,
|
|
81
93
|
...cfg,
|
|
82
94
|
};
|
|
95
|
+
firstShakeFired = false;
|
|
83
96
|
|
|
84
97
|
// Route the in-SDK login screen to prod yaver.io by default; callers may
|
|
85
98
|
// override for staging via authConvexSiteUrl / authWebBaseUrl.
|
|
@@ -134,6 +147,7 @@ export class YaverFeedback {
|
|
|
134
147
|
if (enabled && config.trigger === 'shake') {
|
|
135
148
|
shakeDetector = new ShakeDetector();
|
|
136
149
|
shakeDetector.start(() => {
|
|
150
|
+
YaverFeedback.notifyShake();
|
|
137
151
|
if (config?.reportingOnly) {
|
|
138
152
|
YaverFeedback.sendAutoReport();
|
|
139
153
|
} else {
|
|
@@ -464,6 +478,7 @@ export class YaverFeedback {
|
|
|
464
478
|
if (config?.trigger === 'shake' && !shakeDetector) {
|
|
465
479
|
shakeDetector = new ShakeDetector();
|
|
466
480
|
shakeDetector.start(() => {
|
|
481
|
+
YaverFeedback.notifyShake();
|
|
467
482
|
if (config?.reportingOnly) {
|
|
468
483
|
YaverFeedback.sendAutoReport();
|
|
469
484
|
} else {
|
|
@@ -796,12 +811,66 @@ export class YaverFeedback {
|
|
|
796
811
|
}
|
|
797
812
|
}
|
|
798
813
|
|
|
814
|
+
/**
|
|
815
|
+
* Internal: fired from every shake path (dev-menu + accelerometer)
|
|
816
|
+
* before the feedback modal opens. Emits `yaverFeedback:firstShake`
|
|
817
|
+
* exactly once per process so QuickActionIcon's `'after-shake'` mode
|
|
818
|
+
* can surface itself on first shake and stay visible for the rest of
|
|
819
|
+
* the session.
|
|
820
|
+
*/
|
|
821
|
+
static notifyShake(): void {
|
|
822
|
+
if (firstShakeFired) return;
|
|
823
|
+
firstShakeFired = true;
|
|
824
|
+
try {
|
|
825
|
+
const { DeviceEventEmitter } = require('react-native');
|
|
826
|
+
DeviceEventEmitter.emit('yaverFeedback:firstShake');
|
|
827
|
+
} catch {
|
|
828
|
+
// emitter unavailable (e.g. jsdom unit test) — safe to ignore
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Show / hide the QuickActionIcon programmatically and persist the
|
|
834
|
+
* choice across launches. Host apps can call this from a settings
|
|
835
|
+
* screen so the user has a second way to re-enable the icon after
|
|
836
|
+
* hiding it via the icon's own long-press menu — shake is always the
|
|
837
|
+
* third back-door because it never depends on a visible control.
|
|
838
|
+
*/
|
|
839
|
+
static async setQuickIconVisible(visible: boolean): Promise<void> {
|
|
840
|
+
await setQuickIconDisabled(!visible);
|
|
841
|
+
try {
|
|
842
|
+
const { DeviceEventEmitter } = require('react-native');
|
|
843
|
+
DeviceEventEmitter.emit(
|
|
844
|
+
visible ? 'yaverFeedback:quickIconShow' : 'yaverFeedback:quickIconHide',
|
|
845
|
+
);
|
|
846
|
+
} catch {
|
|
847
|
+
// emitter unavailable — preference is still persisted
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Returns `true` when the user has chosen to hide the QuickActionIcon
|
|
853
|
+
* (via its long-press menu or `setQuickIconVisible(false)`).
|
|
854
|
+
* FeedbackModal uses this to surface a one-tap "Show quick icon"
|
|
855
|
+
* control so the user can bring the icon back without having to know
|
|
856
|
+
* about the programmatic API.
|
|
857
|
+
*/
|
|
858
|
+
static async isQuickIconHidden(): Promise<boolean> {
|
|
859
|
+
return getQuickIconDisabled();
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** Clear the persisted "user hid the icon" flag. */
|
|
863
|
+
static async resetQuickIconPreference(): Promise<void> {
|
|
864
|
+
await YaverFeedback.setQuickIconVisible(true);
|
|
865
|
+
}
|
|
866
|
+
|
|
799
867
|
/** Tear down the SDK (stop shake detector, clear state). */
|
|
800
868
|
static destroy(): void {
|
|
801
869
|
if (shakeDetector) {
|
|
802
870
|
shakeDetector.stop();
|
|
803
871
|
shakeDetector = null;
|
|
804
872
|
}
|
|
873
|
+
firstShakeFired = false;
|
|
805
874
|
enabled = false;
|
|
806
875
|
config = null;
|
|
807
876
|
p2pClient = null;
|
package/src/capture.ts
CHANGED
|
@@ -38,6 +38,80 @@ export async function captureScreenshot(): Promise<string> {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export interface PickedFeedbackFile {
|
|
42
|
+
path: string;
|
|
43
|
+
name: string;
|
|
44
|
+
mimeType?: string;
|
|
45
|
+
kind: 'image' | 'video' | 'audio' | 'unknown';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function classifyPickedFile(name: string, mimeType?: string): PickedFeedbackFile['kind'] {
|
|
49
|
+
const lowerName = name.toLowerCase();
|
|
50
|
+
const lowerMime = (mimeType ?? '').toLowerCase();
|
|
51
|
+
if (
|
|
52
|
+
lowerMime.startsWith('image/') ||
|
|
53
|
+
lowerName.endsWith('.png') ||
|
|
54
|
+
lowerName.endsWith('.jpg') ||
|
|
55
|
+
lowerName.endsWith('.jpeg') ||
|
|
56
|
+
lowerName.endsWith('.webp')
|
|
57
|
+
) {
|
|
58
|
+
return 'image';
|
|
59
|
+
}
|
|
60
|
+
if (
|
|
61
|
+
lowerMime.startsWith('video/') ||
|
|
62
|
+
lowerName.endsWith('.mp4') ||
|
|
63
|
+
lowerName.endsWith('.mov') ||
|
|
64
|
+
lowerName.endsWith('.m4v')
|
|
65
|
+
) {
|
|
66
|
+
return 'video';
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
lowerMime.startsWith('audio/') ||
|
|
70
|
+
lowerName.endsWith('.m4a') ||
|
|
71
|
+
lowerName.endsWith('.aac') ||
|
|
72
|
+
lowerName.endsWith('.wav') ||
|
|
73
|
+
lowerName.endsWith('.mp3')
|
|
74
|
+
) {
|
|
75
|
+
return 'audio';
|
|
76
|
+
}
|
|
77
|
+
return 'unknown';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Pick an existing media file from the device. Requires
|
|
82
|
+
* `expo-document-picker` to be installed.
|
|
83
|
+
*/
|
|
84
|
+
export async function pickFeedbackFile(): Promise<PickedFeedbackFile> {
|
|
85
|
+
try {
|
|
86
|
+
const picker = require('expo-document-picker');
|
|
87
|
+
const result = await picker.getDocumentAsync({
|
|
88
|
+
copyToCacheDirectory: true,
|
|
89
|
+
multiple: false,
|
|
90
|
+
type: ['image/*', 'video/*', 'audio/*'],
|
|
91
|
+
});
|
|
92
|
+
if (result?.canceled) {
|
|
93
|
+
throw new Error('File selection canceled.');
|
|
94
|
+
}
|
|
95
|
+
const asset = result?.assets?.[0];
|
|
96
|
+
if (!asset?.uri) {
|
|
97
|
+
throw new Error('No file selected.');
|
|
98
|
+
}
|
|
99
|
+
const name = asset.name || asset.uri.split('/').pop() || 'attachment';
|
|
100
|
+
const mimeType = asset.mimeType as string | undefined;
|
|
101
|
+
return {
|
|
102
|
+
path: asset.uri,
|
|
103
|
+
name,
|
|
104
|
+
mimeType,
|
|
105
|
+
kind: classifyPickedFile(name, mimeType),
|
|
106
|
+
};
|
|
107
|
+
} catch (err) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
'[YaverFeedback] File upload needs `expo-document-picker` as an optional peer dependency. ' +
|
|
110
|
+
String(err),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
41
115
|
let videoRecorderModule: any = null;
|
|
42
116
|
let videoRecordingActive = false;
|
|
43
117
|
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* yaver-feedback-react-native — Visual feedback SDK for Yaver.
|
|
3
3
|
*
|
|
4
|
-
* Shake-to-report surface with
|
|
4
|
+
* Shake-to-report surface with four core actions:
|
|
5
5
|
* 1. Hot Reload — instant JS reload
|
|
6
|
-
* 2.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 4.
|
|
10
|
-
*
|
|
6
|
+
* 2. Vibing — open a vibing session on the agent
|
|
7
|
+
* 3. Screenshot / Upload — capture the screen under the modal or
|
|
8
|
+
* upload existing media
|
|
9
|
+
* 4. Screen Recording — start, then stop + upload
|
|
10
|
+
*
|
|
11
|
+
* The small quick-access icon stays hidden until the first shake by
|
|
12
|
+
* default on mobile, then remains available unless the user hides it.
|
|
11
13
|
*
|
|
12
14
|
* @example
|
|
13
15
|
* ```tsx
|
|
@@ -45,7 +47,14 @@ export { AuthOverlay } from './AuthOverlay';
|
|
|
45
47
|
export { ShakeDetector } from './ShakeDetector';
|
|
46
48
|
export { FloatingButton } from './FloatingButton';
|
|
47
49
|
export { FeedbackModal } from './FeedbackModal';
|
|
50
|
+
export { QuickActionIcon } from './QuickActionIcon';
|
|
51
|
+
export type { QuickActionIconProps } from './QuickActionIcon';
|
|
48
52
|
export { FixReport } from './FixReport';
|
|
53
|
+
export {
|
|
54
|
+
getQuickIconDisabled,
|
|
55
|
+
setQuickIconDisabled,
|
|
56
|
+
clearQuickIconDisabled,
|
|
57
|
+
} from './preferences';
|
|
49
58
|
export {
|
|
50
59
|
configureAuthEndpoints,
|
|
51
60
|
getConvexSiteUrl,
|
|
@@ -76,6 +85,7 @@ export type {
|
|
|
76
85
|
} from './auth';
|
|
77
86
|
export {
|
|
78
87
|
captureScreenshot,
|
|
88
|
+
pickFeedbackFile,
|
|
79
89
|
startVideoRecording,
|
|
80
90
|
stopVideoRecording,
|
|
81
91
|
isVideoRecording,
|
|
@@ -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
|
+
}
|