yaver-feedback-react-native 0.7.13 → 0.7.15
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/app.plugin.js +52 -4
- package/dist/FeedbackModal.js +104 -0
- package/dist/capture.d.ts +29 -4
- package/dist/capture.js +112 -4
- package/package.json +1 -1
- package/src/FeedbackModal.tsx +120 -1
- package/src/capture.ts +117 -4
package/app.plugin.js
CHANGED
|
@@ -188,9 +188,22 @@ function withYaverAppDelegateHook(config) {
|
|
|
188
188
|
);
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
// 2. Add reload notification handler and bridge recreation logic
|
|
192
|
-
//
|
|
193
|
-
|
|
191
|
+
// 2. Add reload notification handler and bridge recreation logic.
|
|
192
|
+
//
|
|
193
|
+
// Must be inserted into the AppDelegate class — NOT ReactNativeDelegate
|
|
194
|
+
// (a separate class below it in the same file). The handler accesses
|
|
195
|
+
// `self.window`, `self.reactNativeDelegate`, `self.reactNativeFactory`,
|
|
196
|
+
// and `self.bindReactNativeFactory(...)` — all of which only exist
|
|
197
|
+
// on AppDelegate. An earlier version of this plugin used
|
|
198
|
+
// lastIndexOf("}"), which in modern Expo templates lands inside
|
|
199
|
+
// ReactNativeDelegate, producing build errors like
|
|
200
|
+
// "cannot find 'setupYaverHotReload' in scope"
|
|
201
|
+
// "value of type 'ReactNativeDelegate' has no member 'window'"
|
|
202
|
+
//
|
|
203
|
+
// The anchor we want is the closing brace of the AppDelegate class
|
|
204
|
+
// declaration itself. Find its opening, then walk braces to its
|
|
205
|
+
// matching close.
|
|
206
|
+
const classCloseIndex = findAppDelegateClassClose(patched);
|
|
194
207
|
if (classCloseIndex > 0) {
|
|
195
208
|
const reloadHandler = `
|
|
196
209
|
// MARK: - Yaver Feedback SDK Hot Reload
|
|
@@ -257,8 +270,15 @@ function withYaverAppDelegateHook(config) {
|
|
|
257
270
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
|
258
271
|
guard let self = self else { return }
|
|
259
272
|
|
|
273
|
+
// No explicit override needed — the new delegate's bundleURL()
|
|
274
|
+
// has a patched branch that calls YaverHotReload.bundleURL()
|
|
275
|
+
// first, which returns the file we just saved. That avoids
|
|
276
|
+
// relying on a non-existent overrideBundleURL property on
|
|
277
|
+
// ExpoReactNativeFactoryDelegate (which is what errored out in
|
|
278
|
+
// 0.7.14's build on Expo SDK 54+).
|
|
279
|
+
_ = bundleURL // silence "unused" warning; the file path was
|
|
280
|
+
// baked into YaverHotReload by loadBundle() above
|
|
260
281
|
let delegate = ReactNativeDelegate()
|
|
261
|
-
delegate.overrideBundleURL = bundleURL
|
|
262
282
|
delegate.dependencyProvider = RCTAppDependencyProvider()
|
|
263
283
|
|
|
264
284
|
let factory = ExpoReactNativeFactory(delegate: delegate)
|
|
@@ -298,6 +318,34 @@ function withYaverAppDelegateHook(config) {
|
|
|
298
318
|
});
|
|
299
319
|
}
|
|
300
320
|
|
|
321
|
+
// Locate the closing brace of `class AppDelegate: ...` in an Expo
|
|
322
|
+
// Swift AppDelegate file. Returns the index of the matching `}` for
|
|
323
|
+
// the AppDelegate class's opening `{`, or -1 if not found. We need
|
|
324
|
+
// this because Expo's modern template declares TWO classes in the
|
|
325
|
+
// same file (AppDelegate + ReactNativeDelegate), and the SDK's
|
|
326
|
+
// reload handler only makes sense on the AppDelegate one.
|
|
327
|
+
function findAppDelegateClassClose(contents) {
|
|
328
|
+
// Match both `public class AppDelegate` and `class AppDelegate`,
|
|
329
|
+
// with either inheritance colon or a plain body.
|
|
330
|
+
const headerMatch = contents.match(/class\s+AppDelegate\s*[:{][^{]*\{/);
|
|
331
|
+
if (!headerMatch) return -1;
|
|
332
|
+
const bodyStart = headerMatch.index + headerMatch[0].length - 1; // index of the opening `{`
|
|
333
|
+
// Walk braces to find the matching close. Minimal — we don't try
|
|
334
|
+
// to parse strings/comments because the file is machine-generated
|
|
335
|
+
// by Expo, so braces inside strings are not a realistic concern
|
|
336
|
+
// at this layer.
|
|
337
|
+
let depth = 0;
|
|
338
|
+
for (let i = bodyStart; i < contents.length; i++) {
|
|
339
|
+
const ch = contents[i];
|
|
340
|
+
if (ch === "{") depth++;
|
|
341
|
+
else if (ch === "}") {
|
|
342
|
+
depth--;
|
|
343
|
+
if (depth === 0) return i;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return -1;
|
|
347
|
+
}
|
|
348
|
+
|
|
301
349
|
/**
|
|
302
350
|
* Copy Android native module source files and register the package.
|
|
303
351
|
* Also patches MainApplication to use hot-reloaded bundle on startup.
|
package/dist/FeedbackModal.js
CHANGED
|
@@ -47,6 +47,10 @@ const FeedbackModal = () => {
|
|
|
47
47
|
const [toast, setToast] = (0, react_1.useState)(null);
|
|
48
48
|
const [progress, setProgress] = (0, react_1.useState)(null);
|
|
49
49
|
const [isRecordingVideo, setIsRecordingVideo] = (0, react_1.useState)(false);
|
|
50
|
+
const [isRecordingVoice, setIsRecordingVoice] = (0, react_1.useState)(false);
|
|
51
|
+
// Cached once on mount — bare-RN apps without expo-av get a clean
|
|
52
|
+
// hidden button instead of a runtime error.
|
|
53
|
+
const voiceSupported = (0, react_1.useRef)((0, capture_1.isVoiceCaptureSupported)()).current;
|
|
50
54
|
const [lastVideo, setLastVideo] = (0, react_1.useState)(null);
|
|
51
55
|
const mountedRef = (0, react_1.useRef)(true);
|
|
52
56
|
(0, react_1.useEffect)(() => {
|
|
@@ -324,6 +328,97 @@ const FeedbackModal = () => {
|
|
|
324
328
|
}
|
|
325
329
|
}
|
|
326
330
|
}, [isRecordingVideo]);
|
|
331
|
+
// ─── Voice note: record → transcribe → send as feedback ───────────
|
|
332
|
+
// Tap once to start; tap again to stop. On stop: upload the audio
|
|
333
|
+
// to the agent's /voice/transcribe (which routes through whichever
|
|
334
|
+
// STT provider is configured — Whisper / Deepgram / OpenAI / etc.),
|
|
335
|
+
// then file the transcript as a bug report. The audio file itself
|
|
336
|
+
// is also attached to the feedback bundle so the agent can re-play
|
|
337
|
+
// it if the transcript is wrong.
|
|
338
|
+
const handleToggleVoice = (0, react_1.useCallback)(async () => {
|
|
339
|
+
setError(null);
|
|
340
|
+
if (!isRecordingVoice) {
|
|
341
|
+
try {
|
|
342
|
+
await (0, capture_1.startAudioRecording)();
|
|
343
|
+
if (mountedRef.current) {
|
|
344
|
+
setIsRecordingVoice(true);
|
|
345
|
+
setAction('recording-voice');
|
|
346
|
+
setToast('Recording voice note…');
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
setIsRecordingVoice(false);
|
|
351
|
+
setAction('idle');
|
|
352
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
// Stopping → transcribe → send.
|
|
357
|
+
try {
|
|
358
|
+
const audio = await (0, capture_1.stopAudioRecording)();
|
|
359
|
+
setIsRecordingVoice(false);
|
|
360
|
+
if (!audio) {
|
|
361
|
+
setAction('idle');
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
setAction('transcribing-voice');
|
|
365
|
+
setToast('Transcribing…');
|
|
366
|
+
const config = YaverFeedback_1.YaverFeedback.getConfig();
|
|
367
|
+
if (!config?.agentUrl) {
|
|
368
|
+
setError('Not connected to the agent yet.');
|
|
369
|
+
setAction('idle');
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
let transcript = '';
|
|
373
|
+
try {
|
|
374
|
+
const client = YaverFeedback_1.YaverFeedback.getP2PClient();
|
|
375
|
+
if (client) {
|
|
376
|
+
const res = await client.transcribeVoice(audio.path);
|
|
377
|
+
transcript = res.text ?? '';
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
// Transcription can fail (no STT provider configured on the
|
|
382
|
+
// agent, network blip, etc.). Don't block the flow — ship
|
|
383
|
+
// the raw audio file with a "[no transcript]" note so the
|
|
384
|
+
// agent + human reviewer can still play it back.
|
|
385
|
+
}
|
|
386
|
+
const { Dimensions } = require('react-native');
|
|
387
|
+
const { width, height } = Dimensions.get('window');
|
|
388
|
+
const deviceInfo = {
|
|
389
|
+
platform: react_native_1.Platform.OS,
|
|
390
|
+
osVersion: String(react_native_1.Platform.Version),
|
|
391
|
+
model: react_native_1.Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
|
|
392
|
+
screenWidth: width,
|
|
393
|
+
screenHeight: height,
|
|
394
|
+
};
|
|
395
|
+
const bundle = {
|
|
396
|
+
metadata: {
|
|
397
|
+
timestamp: new Date().toISOString(),
|
|
398
|
+
device: deviceInfo,
|
|
399
|
+
app: {},
|
|
400
|
+
userNote: transcript.length > 0
|
|
401
|
+
? `[Voice note] ${transcript}`
|
|
402
|
+
: `[Voice note · ${Math.round(audio.duration)}s — transcription unavailable]`,
|
|
403
|
+
},
|
|
404
|
+
screenshots: [],
|
|
405
|
+
audio: audio.path,
|
|
406
|
+
errors: YaverFeedback_1.YaverFeedback.getCapturedErrors().length
|
|
407
|
+
? YaverFeedback_1.YaverFeedback.getCapturedErrors()
|
|
408
|
+
: undefined,
|
|
409
|
+
};
|
|
410
|
+
await (0, upload_1.uploadFeedback)(config.agentUrl, config.authToken ?? '', bundle);
|
|
411
|
+
setToast(transcript ? `Sent: "${transcript.slice(0, 60)}${transcript.length > 60 ? '…' : ''}"` : 'Voice note sent');
|
|
412
|
+
closeSoon(1800);
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
416
|
+
}
|
|
417
|
+
finally {
|
|
418
|
+
if (mountedRef.current)
|
|
419
|
+
setAction('idle');
|
|
420
|
+
}
|
|
421
|
+
}, [isRecordingVoice, closeSoon]);
|
|
327
422
|
// ─── 5. Send the last recorded video ───────────────────────────────
|
|
328
423
|
const handleSendVideo = (0, react_1.useCallback)(async () => {
|
|
329
424
|
const config = YaverFeedback_1.YaverFeedback.getConfig();
|
|
@@ -397,6 +492,15 @@ const FeedbackModal = () => {
|
|
|
397
492
|
{/* 3. Vibing */}
|
|
398
493
|
<ActionRow label={action === 'vibing' ? 'Starting…' : 'Vibing'} tint="#818cf8" onPress={handleVibing} disabled={busy} busy={action === 'vibing'}/>
|
|
399
494
|
|
|
495
|
+
{/* Voice note — only rendered when expo-av is installed.
|
|
496
|
+
Tap to start, tap again to stop → transcribes via
|
|
497
|
+
the agent and files as a feedback report. */}
|
|
498
|
+
{voiceSupported && (<ActionRow label={action === 'transcribing-voice'
|
|
499
|
+
? 'Transcribing…'
|
|
500
|
+
: isRecordingVoice
|
|
501
|
+
? 'Stop & Send Voice'
|
|
502
|
+
: 'Voice Note'} tint={isRecordingVoice ? '#ef4444' : '#f472b6'} onPress={handleToggleVoice} disabled={busy && action !== 'recording-voice' && action !== 'idle'} busy={action === 'transcribing-voice'}/>)}
|
|
503
|
+
|
|
400
504
|
{/* 4. Start/Stop Recording */}
|
|
401
505
|
<ActionRow label={isRecordingVideo ? 'Stop Recording' : 'Start Recording'} tint={isRecordingVideo ? '#ef4444' : '#60a5fa'} onPress={handleToggleRecording} disabled={busy && action !== 'idle' && !isRecordingVideo}/>
|
|
402
506
|
|
package/dist/capture.d.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Screen capture helpers — screenshot + video recording.
|
|
2
|
+
* Screen capture helpers — screenshot + video + voice recording.
|
|
3
3
|
*
|
|
4
4
|
* Peer deps (all optional — loaded lazily):
|
|
5
5
|
* - `react-native-view-shot` — screenshot
|
|
6
6
|
* - `react-native-record-screen` — video recording (iOS ReplayKit /
|
|
7
7
|
* Android MediaProjection)
|
|
8
|
+
* - `expo-av` — audio recording for voice notes
|
|
8
9
|
*
|
|
9
10
|
* Each helper surfaces a clear error if the module is missing so a host
|
|
10
|
-
* app knows exactly which peer dep to add.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* app knows exactly which peer dep to add. Voice-note capture was
|
|
12
|
+
* removed in 0.7.0 and re-added in 0.7.14 as a narrow, "press to
|
|
13
|
+
* record → stop → transcribe via the agent → attach to feedback" flow.
|
|
14
|
+
* The broader live/narrated/batch modes from pre-0.7.0 stay removed.
|
|
13
15
|
*/
|
|
14
16
|
/**
|
|
15
17
|
* Capture the current screen as a PNG image.
|
|
@@ -38,3 +40,26 @@ export declare function stopVideoRecording(): Promise<{
|
|
|
38
40
|
}>;
|
|
39
41
|
/** Whether a video recording is currently active. */
|
|
40
42
|
export declare function isVideoRecording(): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Returns true if `expo-av` is installed and Audio.Recording is
|
|
45
|
+
* available — lets the modal hide the voice button in apps that
|
|
46
|
+
* haven't installed the peer dep, instead of throwing on tap.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isVoiceCaptureSupported(): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Begin recording audio from the device microphone. Requests
|
|
51
|
+
* microphone permission on first use. Resolves once recording has
|
|
52
|
+
* actually started, so the UI can flip to "Stop" immediately.
|
|
53
|
+
*/
|
|
54
|
+
export declare function startAudioRecording(): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Stop the current audio recording and return the on-device file path
|
|
57
|
+
* (usually a .m4a on iOS / .3gp on Android). Returns null if no
|
|
58
|
+
* recording was active.
|
|
59
|
+
*/
|
|
60
|
+
export declare function stopAudioRecording(): Promise<{
|
|
61
|
+
path: string;
|
|
62
|
+
duration: number;
|
|
63
|
+
} | null>;
|
|
64
|
+
/** Whether a voice-note recording is currently active. */
|
|
65
|
+
export declare function isAudioRecording(): boolean;
|
package/dist/capture.js
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* Screen capture helpers — screenshot + video recording.
|
|
3
|
+
* Screen capture helpers — screenshot + video + voice recording.
|
|
4
4
|
*
|
|
5
5
|
* Peer deps (all optional — loaded lazily):
|
|
6
6
|
* - `react-native-view-shot` — screenshot
|
|
7
7
|
* - `react-native-record-screen` — video recording (iOS ReplayKit /
|
|
8
8
|
* Android MediaProjection)
|
|
9
|
+
* - `expo-av` — audio recording for voice notes
|
|
9
10
|
*
|
|
10
11
|
* Each helper surfaces a clear error if the module is missing so a host
|
|
11
|
-
* app knows exactly which peer dep to add.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* app knows exactly which peer dep to add. Voice-note capture was
|
|
13
|
+
* removed in 0.7.0 and re-added in 0.7.14 as a narrow, "press to
|
|
14
|
+
* record → stop → transcribe via the agent → attach to feedback" flow.
|
|
15
|
+
* The broader live/narrated/batch modes from pre-0.7.0 stay removed.
|
|
14
16
|
*/
|
|
15
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
18
|
exports.captureScreenshot = captureScreenshot;
|
|
17
19
|
exports.startVideoRecording = startVideoRecording;
|
|
18
20
|
exports.stopVideoRecording = stopVideoRecording;
|
|
19
21
|
exports.isVideoRecording = isVideoRecording;
|
|
22
|
+
exports.isVoiceCaptureSupported = isVoiceCaptureSupported;
|
|
23
|
+
exports.startAudioRecording = startAudioRecording;
|
|
24
|
+
exports.stopAudioRecording = stopAudioRecording;
|
|
25
|
+
exports.isAudioRecording = isAudioRecording;
|
|
20
26
|
/**
|
|
21
27
|
* Capture the current screen as a PNG image.
|
|
22
28
|
* Requires `react-native-view-shot` to be installed.
|
|
@@ -111,3 +117,105 @@ async function stopVideoRecording() {
|
|
|
111
117
|
function isVideoRecording() {
|
|
112
118
|
return videoRecordingActive;
|
|
113
119
|
}
|
|
120
|
+
// ── Voice note recording ────────────────────────────────────────────
|
|
121
|
+
//
|
|
122
|
+
// Short audio recording, stopped on user tap. Lazy-loaded via expo-av —
|
|
123
|
+
// the SDK doesn't declare a hard dep on it so bare-RN apps that don't
|
|
124
|
+
// have Expo can still install the SDK. If expo-av is missing, the
|
|
125
|
+
// helpers throw a clear error and the modal's voice button gracefully
|
|
126
|
+
// hides itself.
|
|
127
|
+
let audioRecorderRef = null;
|
|
128
|
+
let audioRecorderActive = false;
|
|
129
|
+
function loadExpoAvOrThrow() {
|
|
130
|
+
try {
|
|
131
|
+
const mod = require('expo-av');
|
|
132
|
+
if (!mod?.Audio?.Recording) {
|
|
133
|
+
throw new Error('expo-av is installed but missing Audio.Recording');
|
|
134
|
+
}
|
|
135
|
+
return mod;
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
throw new Error('[YaverFeedback] Voice notes need `expo-av` as a peer dependency. ' +
|
|
139
|
+
'Add it with `npx expo install expo-av` and rebuild. ' +
|
|
140
|
+
String(err));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Returns true if `expo-av` is installed and Audio.Recording is
|
|
145
|
+
* available — lets the modal hide the voice button in apps that
|
|
146
|
+
* haven't installed the peer dep, instead of throwing on tap.
|
|
147
|
+
*/
|
|
148
|
+
function isVoiceCaptureSupported() {
|
|
149
|
+
try {
|
|
150
|
+
const mod = require('expo-av');
|
|
151
|
+
return !!mod?.Audio?.Recording;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Begin recording audio from the device microphone. Requests
|
|
159
|
+
* microphone permission on first use. Resolves once recording has
|
|
160
|
+
* actually started, so the UI can flip to "Stop" immediately.
|
|
161
|
+
*/
|
|
162
|
+
async function startAudioRecording() {
|
|
163
|
+
if (audioRecorderActive) {
|
|
164
|
+
throw new Error('[YaverFeedback] An audio recording is already in progress.');
|
|
165
|
+
}
|
|
166
|
+
const ExpoAv = loadExpoAvOrThrow();
|
|
167
|
+
const { Audio } = ExpoAv;
|
|
168
|
+
const perm = await Audio.requestPermissionsAsync();
|
|
169
|
+
if (!perm.granted) {
|
|
170
|
+
throw new Error('[YaverFeedback] Microphone permission denied. Enable it in Settings ▸ Your App ▸ Microphone.');
|
|
171
|
+
}
|
|
172
|
+
// Use the iOS/Android high-quality preset — transcription providers
|
|
173
|
+
// (Whisper / Deepgram / OpenAI) prefer 16 kHz+ mono but also handle
|
|
174
|
+
// the higher sample rates fine. Default preset is portable.
|
|
175
|
+
await Audio.setAudioModeAsync({
|
|
176
|
+
allowsRecordingIOS: true,
|
|
177
|
+
playsInSilentModeIOS: true,
|
|
178
|
+
staysActiveInBackground: false,
|
|
179
|
+
});
|
|
180
|
+
const recording = new Audio.Recording();
|
|
181
|
+
await recording.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY);
|
|
182
|
+
await recording.startAsync();
|
|
183
|
+
audioRecorderRef = recording;
|
|
184
|
+
audioRecorderActive = true;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Stop the current audio recording and return the on-device file path
|
|
188
|
+
* (usually a .m4a on iOS / .3gp on Android). Returns null if no
|
|
189
|
+
* recording was active.
|
|
190
|
+
*/
|
|
191
|
+
async function stopAudioRecording() {
|
|
192
|
+
if (!audioRecorderActive || !audioRecorderRef)
|
|
193
|
+
return null;
|
|
194
|
+
const recording = audioRecorderRef;
|
|
195
|
+
audioRecorderRef = null;
|
|
196
|
+
audioRecorderActive = false;
|
|
197
|
+
try {
|
|
198
|
+
await recording.stopAndUnloadAsync();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Second stop calls throw; ignore and use whatever state we have.
|
|
202
|
+
}
|
|
203
|
+
const uri = typeof recording.getURI === 'function' ? recording.getURI() : null;
|
|
204
|
+
if (!uri) {
|
|
205
|
+
throw new Error('[YaverFeedback] Audio recording produced no file.');
|
|
206
|
+
}
|
|
207
|
+
let durationMs = 0;
|
|
208
|
+
try {
|
|
209
|
+
const status = await recording.getStatusAsync();
|
|
210
|
+
durationMs = status?.durationMillis ?? 0;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// Status can fail after unload; leave duration at 0, transcription
|
|
214
|
+
// still works.
|
|
215
|
+
}
|
|
216
|
+
return { path: uri, duration: durationMs / 1000 };
|
|
217
|
+
}
|
|
218
|
+
/** Whether a voice-note recording is currently active. */
|
|
219
|
+
function isAudioRecording() {
|
|
220
|
+
return audioRecorderActive;
|
|
221
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yaver-feedback-react-native",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.15",
|
|
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",
|
package/src/FeedbackModal.tsx
CHANGED
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
captureScreenshot,
|
|
15
15
|
startVideoRecording,
|
|
16
16
|
stopVideoRecording,
|
|
17
|
+
startAudioRecording,
|
|
18
|
+
stopAudioRecording,
|
|
19
|
+
isVoiceCaptureSupported,
|
|
17
20
|
} from './capture';
|
|
18
21
|
import { uploadFeedback } from './upload';
|
|
19
22
|
import { DeviceInfo, FeedbackBundle } from './types';
|
|
@@ -46,7 +49,9 @@ type ActionState =
|
|
|
46
49
|
| 'hot-reloading'
|
|
47
50
|
| 'capturing'
|
|
48
51
|
| 'vibing'
|
|
49
|
-
| 'sending-video'
|
|
52
|
+
| 'sending-video'
|
|
53
|
+
| 'recording-voice'
|
|
54
|
+
| 'transcribing-voice';
|
|
50
55
|
|
|
51
56
|
export const FeedbackModal: React.FC = () => {
|
|
52
57
|
const [visible, setVisible] = useState(false);
|
|
@@ -55,6 +60,10 @@ export const FeedbackModal: React.FC = () => {
|
|
|
55
60
|
const [toast, setToast] = useState<string | null>(null);
|
|
56
61
|
const [progress, setProgress] = useState<number | null>(null);
|
|
57
62
|
const [isRecordingVideo, setIsRecordingVideo] = useState(false);
|
|
63
|
+
const [isRecordingVoice, setIsRecordingVoice] = useState(false);
|
|
64
|
+
// Cached once on mount — bare-RN apps without expo-av get a clean
|
|
65
|
+
// hidden button instead of a runtime error.
|
|
66
|
+
const voiceSupported = useRef<boolean>(isVoiceCaptureSupported()).current;
|
|
58
67
|
const [lastVideo, setLastVideo] = useState<LastVideo | null>(null);
|
|
59
68
|
const mountedRef = useRef(true);
|
|
60
69
|
|
|
@@ -339,6 +348,97 @@ export const FeedbackModal: React.FC = () => {
|
|
|
339
348
|
}
|
|
340
349
|
}, [isRecordingVideo]);
|
|
341
350
|
|
|
351
|
+
// ─── Voice note: record → transcribe → send as feedback ───────────
|
|
352
|
+
// Tap once to start; tap again to stop. On stop: upload the audio
|
|
353
|
+
// to the agent's /voice/transcribe (which routes through whichever
|
|
354
|
+
// STT provider is configured — Whisper / Deepgram / OpenAI / etc.),
|
|
355
|
+
// then file the transcript as a bug report. The audio file itself
|
|
356
|
+
// is also attached to the feedback bundle so the agent can re-play
|
|
357
|
+
// it if the transcript is wrong.
|
|
358
|
+
const handleToggleVoice = useCallback(async () => {
|
|
359
|
+
setError(null);
|
|
360
|
+
if (!isRecordingVoice) {
|
|
361
|
+
try {
|
|
362
|
+
await startAudioRecording();
|
|
363
|
+
if (mountedRef.current) {
|
|
364
|
+
setIsRecordingVoice(true);
|
|
365
|
+
setAction('recording-voice');
|
|
366
|
+
setToast('Recording voice note…');
|
|
367
|
+
}
|
|
368
|
+
} catch (err: unknown) {
|
|
369
|
+
setIsRecordingVoice(false);
|
|
370
|
+
setAction('idle');
|
|
371
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
372
|
+
}
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Stopping → transcribe → send.
|
|
377
|
+
try {
|
|
378
|
+
const audio = await stopAudioRecording();
|
|
379
|
+
setIsRecordingVoice(false);
|
|
380
|
+
if (!audio) {
|
|
381
|
+
setAction('idle');
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
setAction('transcribing-voice');
|
|
385
|
+
setToast('Transcribing…');
|
|
386
|
+
|
|
387
|
+
const config = YaverFeedback.getConfig();
|
|
388
|
+
if (!config?.agentUrl) {
|
|
389
|
+
setError('Not connected to the agent yet.');
|
|
390
|
+
setAction('idle');
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
let transcript = '';
|
|
394
|
+
try {
|
|
395
|
+
const client = YaverFeedback.getP2PClient();
|
|
396
|
+
if (client) {
|
|
397
|
+
const res = await client.transcribeVoice(audio.path);
|
|
398
|
+
transcript = res.text ?? '';
|
|
399
|
+
}
|
|
400
|
+
} catch {
|
|
401
|
+
// Transcription can fail (no STT provider configured on the
|
|
402
|
+
// agent, network blip, etc.). Don't block the flow — ship
|
|
403
|
+
// the raw audio file with a "[no transcript]" note so the
|
|
404
|
+
// agent + human reviewer can still play it back.
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const { Dimensions } = require('react-native');
|
|
408
|
+
const { width, height } = Dimensions.get('window');
|
|
409
|
+
const deviceInfo: DeviceInfo = {
|
|
410
|
+
platform: Platform.OS,
|
|
411
|
+
osVersion: String(Platform.Version),
|
|
412
|
+
model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
|
|
413
|
+
screenWidth: width,
|
|
414
|
+
screenHeight: height,
|
|
415
|
+
};
|
|
416
|
+
const bundle: FeedbackBundle = {
|
|
417
|
+
metadata: {
|
|
418
|
+
timestamp: new Date().toISOString(),
|
|
419
|
+
device: deviceInfo,
|
|
420
|
+
app: {},
|
|
421
|
+
userNote:
|
|
422
|
+
transcript.length > 0
|
|
423
|
+
? `[Voice note] ${transcript}`
|
|
424
|
+
: `[Voice note · ${Math.round(audio.duration)}s — transcription unavailable]`,
|
|
425
|
+
},
|
|
426
|
+
screenshots: [],
|
|
427
|
+
audio: audio.path,
|
|
428
|
+
errors: YaverFeedback.getCapturedErrors().length
|
|
429
|
+
? YaverFeedback.getCapturedErrors()
|
|
430
|
+
: undefined,
|
|
431
|
+
};
|
|
432
|
+
await uploadFeedback(config.agentUrl, config.authToken ?? '', bundle);
|
|
433
|
+
setToast(transcript ? `Sent: "${transcript.slice(0, 60)}${transcript.length > 60 ? '…' : ''}"` : 'Voice note sent');
|
|
434
|
+
closeSoon(1800);
|
|
435
|
+
} catch (err: unknown) {
|
|
436
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
437
|
+
} finally {
|
|
438
|
+
if (mountedRef.current) setAction('idle');
|
|
439
|
+
}
|
|
440
|
+
}, [isRecordingVoice, closeSoon]);
|
|
441
|
+
|
|
342
442
|
// ─── 5. Send the last recorded video ───────────────────────────────
|
|
343
443
|
const handleSendVideo = useCallback(async () => {
|
|
344
444
|
const config = YaverFeedback.getConfig();
|
|
@@ -446,6 +546,25 @@ export const FeedbackModal: React.FC = () => {
|
|
|
446
546
|
busy={action === 'vibing'}
|
|
447
547
|
/>
|
|
448
548
|
|
|
549
|
+
{/* Voice note — only rendered when expo-av is installed.
|
|
550
|
+
Tap to start, tap again to stop → transcribes via
|
|
551
|
+
the agent and files as a feedback report. */}
|
|
552
|
+
{voiceSupported && (
|
|
553
|
+
<ActionRow
|
|
554
|
+
label={
|
|
555
|
+
action === 'transcribing-voice'
|
|
556
|
+
? 'Transcribing…'
|
|
557
|
+
: isRecordingVoice
|
|
558
|
+
? 'Stop & Send Voice'
|
|
559
|
+
: 'Voice Note'
|
|
560
|
+
}
|
|
561
|
+
tint={isRecordingVoice ? '#ef4444' : '#f472b6'}
|
|
562
|
+
onPress={handleToggleVoice}
|
|
563
|
+
disabled={busy && action !== 'recording-voice' && action !== 'idle'}
|
|
564
|
+
busy={action === 'transcribing-voice'}
|
|
565
|
+
/>
|
|
566
|
+
)}
|
|
567
|
+
|
|
449
568
|
{/* 4. Start/Stop Recording */}
|
|
450
569
|
<ActionRow
|
|
451
570
|
label={isRecordingVideo ? 'Stop Recording' : 'Start Recording'}
|
package/src/capture.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Screen capture helpers — screenshot + video recording.
|
|
2
|
+
* Screen capture helpers — screenshot + video + voice recording.
|
|
3
3
|
*
|
|
4
4
|
* Peer deps (all optional — loaded lazily):
|
|
5
5
|
* - `react-native-view-shot` — screenshot
|
|
6
6
|
* - `react-native-record-screen` — video recording (iOS ReplayKit /
|
|
7
7
|
* Android MediaProjection)
|
|
8
|
+
* - `expo-av` — audio recording for voice notes
|
|
8
9
|
*
|
|
9
10
|
* Each helper surfaces a clear error if the module is missing so a host
|
|
10
|
-
* app knows exactly which peer dep to add.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* app knows exactly which peer dep to add. Voice-note capture was
|
|
12
|
+
* removed in 0.7.0 and re-added in 0.7.14 as a narrow, "press to
|
|
13
|
+
* record → stop → transcribe via the agent → attach to feedback" flow.
|
|
14
|
+
* The broader live/narrated/batch modes from pre-0.7.0 stay removed.
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
/**
|
|
@@ -118,3 +120,114 @@ export async function stopVideoRecording(): Promise<{
|
|
|
118
120
|
export function isVideoRecording(): boolean {
|
|
119
121
|
return videoRecordingActive;
|
|
120
122
|
}
|
|
123
|
+
|
|
124
|
+
// ── Voice note recording ────────────────────────────────────────────
|
|
125
|
+
//
|
|
126
|
+
// Short audio recording, stopped on user tap. Lazy-loaded via expo-av —
|
|
127
|
+
// the SDK doesn't declare a hard dep on it so bare-RN apps that don't
|
|
128
|
+
// have Expo can still install the SDK. If expo-av is missing, the
|
|
129
|
+
// helpers throw a clear error and the modal's voice button gracefully
|
|
130
|
+
// hides itself.
|
|
131
|
+
|
|
132
|
+
let audioRecorderRef: any = null;
|
|
133
|
+
let audioRecorderActive = false;
|
|
134
|
+
|
|
135
|
+
function loadExpoAvOrThrow(): any {
|
|
136
|
+
try {
|
|
137
|
+
const mod = require('expo-av');
|
|
138
|
+
if (!mod?.Audio?.Recording) {
|
|
139
|
+
throw new Error('expo-av is installed but missing Audio.Recording');
|
|
140
|
+
}
|
|
141
|
+
return mod;
|
|
142
|
+
} catch (err) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
'[YaverFeedback] Voice notes need `expo-av` as a peer dependency. ' +
|
|
145
|
+
'Add it with `npx expo install expo-av` and rebuild. ' +
|
|
146
|
+
String(err),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Returns true if `expo-av` is installed and Audio.Recording is
|
|
153
|
+
* available — lets the modal hide the voice button in apps that
|
|
154
|
+
* haven't installed the peer dep, instead of throwing on tap.
|
|
155
|
+
*/
|
|
156
|
+
export function isVoiceCaptureSupported(): boolean {
|
|
157
|
+
try {
|
|
158
|
+
const mod = require('expo-av');
|
|
159
|
+
return !!mod?.Audio?.Recording;
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Begin recording audio from the device microphone. Requests
|
|
167
|
+
* microphone permission on first use. Resolves once recording has
|
|
168
|
+
* actually started, so the UI can flip to "Stop" immediately.
|
|
169
|
+
*/
|
|
170
|
+
export async function startAudioRecording(): Promise<void> {
|
|
171
|
+
if (audioRecorderActive) {
|
|
172
|
+
throw new Error('[YaverFeedback] An audio recording is already in progress.');
|
|
173
|
+
}
|
|
174
|
+
const ExpoAv = loadExpoAvOrThrow();
|
|
175
|
+
const { Audio } = ExpoAv;
|
|
176
|
+
|
|
177
|
+
const perm = await Audio.requestPermissionsAsync();
|
|
178
|
+
if (!perm.granted) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
'[YaverFeedback] Microphone permission denied. Enable it in Settings ▸ Your App ▸ Microphone.',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Use the iOS/Android high-quality preset — transcription providers
|
|
185
|
+
// (Whisper / Deepgram / OpenAI) prefer 16 kHz+ mono but also handle
|
|
186
|
+
// the higher sample rates fine. Default preset is portable.
|
|
187
|
+
await Audio.setAudioModeAsync({
|
|
188
|
+
allowsRecordingIOS: true,
|
|
189
|
+
playsInSilentModeIOS: true,
|
|
190
|
+
staysActiveInBackground: false,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const recording = new Audio.Recording();
|
|
194
|
+
await recording.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY);
|
|
195
|
+
await recording.startAsync();
|
|
196
|
+
audioRecorderRef = recording;
|
|
197
|
+
audioRecorderActive = true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Stop the current audio recording and return the on-device file path
|
|
202
|
+
* (usually a .m4a on iOS / .3gp on Android). Returns null if no
|
|
203
|
+
* recording was active.
|
|
204
|
+
*/
|
|
205
|
+
export async function stopAudioRecording(): Promise<{ path: string; duration: number } | null> {
|
|
206
|
+
if (!audioRecorderActive || !audioRecorderRef) return null;
|
|
207
|
+
const recording = audioRecorderRef;
|
|
208
|
+
audioRecorderRef = null;
|
|
209
|
+
audioRecorderActive = false;
|
|
210
|
+
try {
|
|
211
|
+
await recording.stopAndUnloadAsync();
|
|
212
|
+
} catch {
|
|
213
|
+
// Second stop calls throw; ignore and use whatever state we have.
|
|
214
|
+
}
|
|
215
|
+
const uri = typeof recording.getURI === 'function' ? recording.getURI() : null;
|
|
216
|
+
if (!uri) {
|
|
217
|
+
throw new Error('[YaverFeedback] Audio recording produced no file.');
|
|
218
|
+
}
|
|
219
|
+
let durationMs = 0;
|
|
220
|
+
try {
|
|
221
|
+
const status = await recording.getStatusAsync();
|
|
222
|
+
durationMs = status?.durationMillis ?? 0;
|
|
223
|
+
} catch {
|
|
224
|
+
// Status can fail after unload; leave duration at 0, transcription
|
|
225
|
+
// still works.
|
|
226
|
+
}
|
|
227
|
+
return { path: uri, duration: durationMs / 1000 };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Whether a voice-note recording is currently active. */
|
|
231
|
+
export function isAudioRecording(): boolean {
|
|
232
|
+
return audioRecorderActive;
|
|
233
|
+
}
|