yaver-feedback-react-native 0.8.0 → 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.
@@ -13,11 +13,9 @@ import {
13
13
  import { YaverFeedback } from './YaverFeedback';
14
14
  import {
15
15
  captureScreenshot,
16
+ pickFeedbackFile,
16
17
  startVideoRecording,
17
18
  stopVideoRecording,
18
- startAudioRecording,
19
- stopAudioRecording,
20
- isVoiceCaptureSupported,
21
19
  } from './capture';
22
20
  import { uploadFeedback } from './upload';
23
21
  import { DeviceInfo, FeedbackBundle } from './types';
@@ -25,20 +23,17 @@ import { AuthOverlay } from './AuthOverlay';
25
23
  import { QuickActionIcon } from './QuickActionIcon';
26
24
 
27
25
  /**
28
- * Simplified feedback modal — 5 actions:
26
+ * Simplified feedback modal — 4 actions:
29
27
  *
30
28
  * 1. Hot Reload — instant JS reload (most common use case)
31
- * 2. Screenshot + Fix — capture the underlying app (modal hidden
32
- * during capture), attach errors, trigger
33
- * a fix task on the agent
34
- * 3. Vibing — open a vibing session on the agent
35
- * 4. Start / Stop Recording screen-recording toggle
36
- * 5. Send Video — submit the last recorded video
29
+ * 2. Vibing — open a vibing session on the agent
30
+ * 3. Screenshot / Upload — capture the underlying app (modal hidden
31
+ * during capture) or upload an existing
32
+ * media file through the Go agent
33
+ * 4. Screen Recording start recording, then stop + upload
37
34
  *
38
- * The header has an explicit X close icon on the right.
39
- * Live / Narrated / Batch modes, voice notes, and the streaming indicator
40
- * were removed in 0.7.0 — those flows never worked end-to-end against
41
- * the Go agent (see MISSINGS_FEEDBACK_SDK.md).
35
+ * The footer also has an explicit Cancel button so the icon tap path
36
+ * feels like a standard action sheet rather than a hidden modal.
42
37
  */
43
38
 
44
39
  interface LastVideo {
@@ -51,9 +46,7 @@ type ActionState =
51
46
  | 'hot-reloading'
52
47
  | 'capturing'
53
48
  | 'vibing'
54
- | 'sending-video'
55
- | 'recording-voice'
56
- | 'transcribing-voice';
49
+ | 'uploading-video';
57
50
 
58
51
  export const FeedbackModal: React.FC = () => {
59
52
  const [visible, setVisible] = useState(false);
@@ -62,10 +55,6 @@ export const FeedbackModal: React.FC = () => {
62
55
  const [toast, setToast] = useState<string | null>(null);
63
56
  const [progress, setProgress] = useState<number | null>(null);
64
57
  const [isRecordingVideo, setIsRecordingVideo] = useState(false);
65
- const [isRecordingVoice, setIsRecordingVoice] = useState(false);
66
- // Cached once on mount — bare-RN apps without expo-av get a clean
67
- // hidden button instead of a runtime error.
68
- const voiceSupported = useRef<boolean>(isVoiceCaptureSupported()).current;
69
58
  const [lastVideo, setLastVideo] = useState<LastVideo | null>(null);
70
59
  // Tracks whether the user has hidden the QuickActionIcon via its
71
60
  // long-press menu. Shake is always available, so the feedback modal
@@ -79,6 +68,7 @@ export const FeedbackModal: React.FC = () => {
79
68
  // the wrong project because the matcher grepped the prompt itself).
80
69
  const [showVibeInput, setShowVibeInput] = useState(false);
81
70
  const [vibePrompt, setVibePrompt] = useState('');
71
+ const [showCaptureChoices, setShowCaptureChoices] = useState(false);
82
72
  const [lastVibeTaskId, setLastVibeTaskId] = useState<string | null>(null);
83
73
  const mountedRef = useRef(true);
84
74
 
@@ -90,6 +80,7 @@ export const FeedbackModal: React.FC = () => {
90
80
  setError(null);
91
81
  setToast(null);
92
82
  setAction('idle');
83
+ setShowCaptureChoices(false);
93
84
  // Re-read the "user hid the quick icon" flag on every open so
94
85
  // the re-enable row reflects the latest preference (the user
95
86
  // might have hidden or shown it between opens).
@@ -139,6 +130,7 @@ export const FeedbackModal: React.FC = () => {
139
130
  setError(null);
140
131
  setToast(null);
141
132
  setAction('idle');
133
+ setShowCaptureChoices(false);
142
134
  }, []);
143
135
 
144
136
  // Helper: run a P2P call; on network failure, ask YaverFeedback to
@@ -219,28 +211,61 @@ export const FeedbackModal: React.FC = () => {
219
211
  }
220
212
  }, [closeSoon, runWithReconnect]);
221
213
 
222
- // ─── 2. Screenshot + Fix ───────────────────────────────────────────
223
- //
224
- // Hide the modal first so the screenshot captures the actual screen
225
- // (the bug) — not the modal card. Await a short animation delay,
226
- // snapshot, upload the feedback bundle with any buffered errors, then
227
- // kick `/feedback/{id}/fix` to create the repair task.
228
- const handleScreenshotAndFix = useCallback(async () => {
214
+ const uploadBundleWithOptionalFix = useCallback(async (
215
+ bundle: FeedbackBundle,
216
+ fixOnUpload: boolean,
217
+ successToast: string,
218
+ failureToast?: string,
219
+ ) => {
229
220
  const client = YaverFeedback.getP2PClient();
230
221
  const config = YaverFeedback.getConfig();
231
222
  if (!client || !config?.agentUrl) {
232
223
  setError('Not connected to the agent yet.');
233
224
  return;
234
225
  }
226
+ try {
227
+ const uploaded = await uploadFeedback(
228
+ config.agentUrl,
229
+ config.authToken ?? '',
230
+ bundle,
231
+ );
232
+ // The agent returns the new report id as `id` (see
233
+ // feedback_http.go::ReceiveFeedback). Trigger the fix loop if we got
234
+ // one back; otherwise just ack the upload.
235
+ const reportId =
236
+ (uploaded as { id?: string; reportId?: string } | null | undefined)?.id ??
237
+ (uploaded as { reportId?: string } | null | undefined)?.reportId;
238
+ if (reportId && fixOnUpload) {
239
+ try {
240
+ await client.triggerFix(reportId);
241
+ setToast(successToast);
242
+ } catch (err: unknown) {
243
+ setToast(failureToast ?? 'Report uploaded — fix trigger failed');
244
+ setError(err instanceof Error ? err.message : String(err));
245
+ }
246
+ } else {
247
+ setToast(successToast);
248
+ }
249
+ closeSoon(1400);
250
+ } catch (err: unknown) {
251
+ setError(err instanceof Error ? err.message : String(err));
252
+ }
253
+ }, [closeSoon]);
254
+
255
+ // ─── 3. Screenshot / Upload ───────────────────────────────────────
256
+ const handleCaptureChoiceToggle = useCallback(() => {
257
+ setShowCaptureChoices((v) => !v);
258
+ }, []);
259
+
260
+ const handleScreenshotAndFix = useCallback(async () => {
235
261
  setAction('capturing');
236
262
  setError(null);
263
+ setShowCaptureChoices(false);
237
264
 
238
- // Step 1: Hide the modal so the screenshot contains the real screen.
239
265
  setVisible(false);
240
- // Wait out the slide-down animation on both platforms.
241
266
  await new Promise((resolve) => setTimeout(resolve, 350));
242
267
 
243
- let path: string | null = null;
268
+ let path: string;
244
269
  try {
245
270
  path = await captureScreenshot();
246
271
  } catch (err: unknown) {
@@ -250,7 +275,6 @@ export const FeedbackModal: React.FC = () => {
250
275
  return;
251
276
  }
252
277
 
253
- // Step 2: Re-show the modal for progress + ack.
254
278
  setVisible(true);
255
279
  await new Promise((resolve) => setTimeout(resolve, 150));
256
280
 
@@ -264,7 +288,6 @@ export const FeedbackModal: React.FC = () => {
264
288
  screenWidth: width,
265
289
  screenHeight: height,
266
290
  };
267
-
268
291
  const capturedErrors = YaverFeedback.getCapturedErrors();
269
292
  const bundle: FeedbackBundle = {
270
293
  metadata: {
@@ -276,36 +299,61 @@ export const FeedbackModal: React.FC = () => {
276
299
  screenshots: [path],
277
300
  errors: capturedErrors.length > 0 ? capturedErrors : undefined,
278
301
  };
279
-
280
- const uploaded = await uploadFeedback(
281
- config.agentUrl,
282
- config.authToken ?? '',
302
+ await uploadBundleWithOptionalFix(
283
303
  bundle,
304
+ true,
305
+ 'Fix task started',
284
306
  );
285
- // The agent returns the new report id as `id` (see
286
- // feedback_http.go::ReceiveFeedback). Trigger the fix loop if we got
287
- // one back; otherwise just ack the upload.
288
- const reportId =
289
- (uploaded as { id?: string; reportId?: string } | null | undefined)?.id ??
290
- (uploaded as { reportId?: string } | null | undefined)?.reportId;
291
- if (reportId) {
292
- try {
293
- await client.triggerFix(reportId);
294
- setToast('Fix task started');
295
- } catch (err: unknown) {
296
- setToast('Report uploaded fix trigger failed');
297
- setError(err instanceof Error ? err.message : String(err));
298
- }
299
- } else {
300
- setToast('Report uploaded');
307
+ } finally {
308
+ if (mountedRef.current) setAction('idle');
309
+ }
310
+ }, [uploadBundleWithOptionalFix]);
311
+
312
+ const handleFileUpload = useCallback(async () => {
313
+ setAction('capturing');
314
+ setError(null);
315
+ setShowCaptureChoices(false);
316
+ try {
317
+ const picked = await pickFeedbackFile();
318
+ const { Dimensions } = require('react-native');
319
+ const { width, height } = Dimensions.get('window');
320
+ const deviceInfo: DeviceInfo = {
321
+ platform: Platform.OS,
322
+ osVersion: String(Platform.Version),
323
+ model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
324
+ screenWidth: width,
325
+ screenHeight: height,
326
+ };
327
+ const capturedErrors = YaverFeedback.getCapturedErrors();
328
+ const bundle: FeedbackBundle = {
329
+ metadata: {
330
+ timestamp: new Date().toISOString(),
331
+ device: deviceInfo,
332
+ app: {},
333
+ userNote: `[Uploaded file] ${picked.name}`,
334
+ },
335
+ screenshots: picked.kind === 'image' ? [picked.path] : [],
336
+ video: picked.kind === 'video' ? picked.path : undefined,
337
+ audio: picked.kind === 'audio' ? picked.path : undefined,
338
+ errors: capturedErrors.length > 0 ? capturedErrors : undefined,
339
+ };
340
+ if (picked.kind === 'unknown') {
341
+ throw new Error('Pick an image, video, or audio file.');
301
342
  }
302
- closeSoon(1400);
343
+ await uploadBundleWithOptionalFix(
344
+ bundle,
345
+ picked.kind === 'image',
346
+ picked.kind === 'image' ? 'Fix task started' : 'File uploaded',
347
+ );
303
348
  } catch (err: unknown) {
304
- setError(err instanceof Error ? err.message : String(err));
349
+ const message = err instanceof Error ? err.message : String(err);
350
+ if (message !== 'File selection canceled.') {
351
+ setError(message);
352
+ }
305
353
  } finally {
306
354
  if (mountedRef.current) setAction('idle');
307
355
  }
308
- }, [closeSoon]);
356
+ }, [uploadBundleWithOptionalFix]);
309
357
 
310
358
  // ─── 3. Vibing ─────────────────────────────────────────────────────
311
359
  // First tap expands the input; second submit fires the actual
@@ -359,172 +407,113 @@ export const FeedbackModal: React.FC = () => {
359
407
  }
360
408
  }, [vibePrompt]);
361
409
 
362
- // ─── 4. Toggle screen recording ────────────────────────────────────
363
- const handleToggleRecording = useCallback(async () => {
410
+ // ─── 4. Screen recording ───────────────────────────────────────────
411
+ const handleScreenRecording = useCallback(async () => {
364
412
  setError(null);
413
+ if (!isRecordingVideo && lastVideo) {
414
+ const config = YaverFeedback.getConfig();
415
+ if (!config?.agentUrl) {
416
+ setError('Not connected to the agent yet.');
417
+ return;
418
+ }
419
+ setAction('uploading-video');
420
+ try {
421
+ const { Dimensions } = require('react-native');
422
+ const { width, height } = Dimensions.get('window');
423
+ const deviceInfo: DeviceInfo = {
424
+ platform: Platform.OS,
425
+ osVersion: String(Platform.Version),
426
+ model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
427
+ screenWidth: width,
428
+ screenHeight: height,
429
+ };
430
+ const bundle: FeedbackBundle = {
431
+ metadata: {
432
+ timestamp: new Date().toISOString(),
433
+ device: deviceInfo,
434
+ app: {},
435
+ userNote: '[Screen recording]',
436
+ },
437
+ screenshots: [],
438
+ video: lastVideo.path,
439
+ errors: YaverFeedback.getCapturedErrors().length
440
+ ? YaverFeedback.getCapturedErrors()
441
+ : undefined,
442
+ };
443
+ await uploadFeedback(config.agentUrl, config.authToken ?? '', bundle);
444
+ if (mountedRef.current) {
445
+ setToast(`Recording uploaded — ${Math.round(lastVideo.duration)}s`);
446
+ setLastVideo(null);
447
+ }
448
+ closeSoon(1200);
449
+ } catch (err: unknown) {
450
+ setError(err instanceof Error ? err.message : String(err));
451
+ } finally {
452
+ if (mountedRef.current) setAction('idle');
453
+ }
454
+ return;
455
+ }
456
+
365
457
  if (isRecordingVideo) {
366
458
  try {
367
459
  const result = await stopVideoRecording();
368
460
  if (mountedRef.current) {
369
461
  setIsRecordingVideo(false);
370
462
  setLastVideo(result);
371
- setToast(`Recording stopped — ${Math.round(result.duration)}s`);
463
+ setAction('uploading-video');
464
+ setToast('Uploading recording…');
465
+ }
466
+ const config = YaverFeedback.getConfig();
467
+ if (!config?.agentUrl) {
468
+ throw new Error('Not connected to the agent yet.');
372
469
  }
470
+ const { Dimensions } = require('react-native');
471
+ const { width, height } = Dimensions.get('window');
472
+ const deviceInfo: DeviceInfo = {
473
+ platform: Platform.OS,
474
+ osVersion: String(Platform.Version),
475
+ model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
476
+ screenWidth: width,
477
+ screenHeight: height,
478
+ };
479
+ const bundle: FeedbackBundle = {
480
+ metadata: {
481
+ timestamp: new Date().toISOString(),
482
+ device: deviceInfo,
483
+ app: {},
484
+ userNote: '[Screen recording]',
485
+ },
486
+ screenshots: [],
487
+ video: result.path,
488
+ errors: YaverFeedback.getCapturedErrors().length
489
+ ? YaverFeedback.getCapturedErrors()
490
+ : undefined,
491
+ };
492
+ await uploadFeedback(config.agentUrl, config.authToken ?? '', bundle);
493
+ if (mountedRef.current) {
494
+ setToast(`Recording uploaded — ${Math.round(result.duration)}s`);
495
+ setLastVideo(null);
496
+ }
497
+ closeSoon(1200);
373
498
  } catch (err: unknown) {
374
499
  setIsRecordingVideo(false);
375
500
  setError(err instanceof Error ? err.message : String(err));
501
+ } finally {
502
+ if (mountedRef.current) setAction('idle');
376
503
  }
377
504
  } else {
378
505
  try {
379
506
  await startVideoRecording();
380
507
  if (mountedRef.current) {
381
508
  setIsRecordingVideo(true);
382
- setToast('Recording…');
509
+ setToast('Recording… tap again to stop and upload');
383
510
  setLastVideo(null);
384
511
  }
385
512
  } catch (err: unknown) {
386
513
  setError(err instanceof Error ? err.message : String(err));
387
514
  }
388
515
  }
389
- }, [isRecordingVideo]);
390
-
391
- // ─── Voice note: record → transcribe → send as feedback ───────────
392
- // Tap once to start; tap again to stop. On stop: upload the audio
393
- // to the agent's /voice/transcribe (which routes through whichever
394
- // STT provider is configured — Whisper / Deepgram / OpenAI / etc.),
395
- // then file the transcript as a bug report. The audio file itself
396
- // is also attached to the feedback bundle so the agent can re-play
397
- // it if the transcript is wrong.
398
- const handleToggleVoice = useCallback(async () => {
399
- setError(null);
400
- if (!isRecordingVoice) {
401
- try {
402
- await startAudioRecording();
403
- if (mountedRef.current) {
404
- setIsRecordingVoice(true);
405
- setAction('recording-voice');
406
- setToast('Recording voice note…');
407
- }
408
- } catch (err: unknown) {
409
- setIsRecordingVoice(false);
410
- setAction('idle');
411
- setError(err instanceof Error ? err.message : String(err));
412
- }
413
- return;
414
- }
415
-
416
- // Stopping → transcribe → send.
417
- try {
418
- const audio = await stopAudioRecording();
419
- setIsRecordingVoice(false);
420
- if (!audio) {
421
- setAction('idle');
422
- return;
423
- }
424
- setAction('transcribing-voice');
425
- setToast('Transcribing…');
426
-
427
- const config = YaverFeedback.getConfig();
428
- if (!config?.agentUrl) {
429
- setError('Not connected to the agent yet.');
430
- setAction('idle');
431
- return;
432
- }
433
- let transcript = '';
434
- try {
435
- const client = YaverFeedback.getP2PClient();
436
- if (client) {
437
- const res = await client.transcribeVoice(audio.path);
438
- transcript = res.text ?? '';
439
- }
440
- } catch {
441
- // Transcription can fail (no STT provider configured on the
442
- // agent, network blip, etc.). Don't block the flow — ship
443
- // the raw audio file with a "[no transcript]" note so the
444
- // agent + human reviewer can still play it back.
445
- }
446
-
447
- const { Dimensions } = require('react-native');
448
- const { width, height } = Dimensions.get('window');
449
- const deviceInfo: DeviceInfo = {
450
- platform: Platform.OS,
451
- osVersion: String(Platform.Version),
452
- model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
453
- screenWidth: width,
454
- screenHeight: height,
455
- };
456
- const bundle: FeedbackBundle = {
457
- metadata: {
458
- timestamp: new Date().toISOString(),
459
- device: deviceInfo,
460
- app: {},
461
- userNote:
462
- transcript.length > 0
463
- ? `[Voice note] ${transcript}`
464
- : `[Voice note · ${Math.round(audio.duration)}s — transcription unavailable]`,
465
- },
466
- screenshots: [],
467
- audio: audio.path,
468
- errors: YaverFeedback.getCapturedErrors().length
469
- ? YaverFeedback.getCapturedErrors()
470
- : undefined,
471
- };
472
- await uploadFeedback(config.agentUrl, config.authToken ?? '', bundle);
473
- setToast(transcript ? `Sent: "${transcript.slice(0, 60)}${transcript.length > 60 ? '…' : ''}"` : 'Voice note sent');
474
- closeSoon(1800);
475
- } catch (err: unknown) {
476
- setError(err instanceof Error ? err.message : String(err));
477
- } finally {
478
- if (mountedRef.current) setAction('idle');
479
- }
480
- }, [isRecordingVoice, closeSoon]);
481
-
482
- // ─── 5. Send the last recorded video ───────────────────────────────
483
- const handleSendVideo = useCallback(async () => {
484
- const config = YaverFeedback.getConfig();
485
- if (!config?.agentUrl) {
486
- setError('Not connected to the agent yet.');
487
- return;
488
- }
489
- if (!lastVideo) {
490
- setError('No video recorded yet.');
491
- return;
492
- }
493
- setAction('sending-video');
494
- setError(null);
495
- try {
496
- const { Dimensions } = require('react-native');
497
- const { width, height } = Dimensions.get('window');
498
- const deviceInfo: DeviceInfo = {
499
- platform: Platform.OS,
500
- osVersion: String(Platform.Version),
501
- model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
502
- screenWidth: width,
503
- screenHeight: height,
504
- };
505
- const bundle: FeedbackBundle = {
506
- metadata: {
507
- timestamp: new Date().toISOString(),
508
- device: deviceInfo,
509
- app: {},
510
- userNote: '[Screen recording]',
511
- },
512
- screenshots: [],
513
- video: lastVideo.path,
514
- errors: YaverFeedback.getCapturedErrors().length
515
- ? YaverFeedback.getCapturedErrors()
516
- : undefined,
517
- };
518
- await uploadFeedback(config.agentUrl, config.authToken ?? '', bundle);
519
- setToast('Video sent');
520
- setLastVideo(null);
521
- closeSoon(1200);
522
- } catch (err: unknown) {
523
- setError(err instanceof Error ? err.message : String(err));
524
- } finally {
525
- if (mountedRef.current) setAction('idle');
526
- }
527
- }, [lastVideo, closeSoon]);
516
+ }, [closeSoon, isRecordingVideo, lastVideo]);
528
517
 
529
518
  const busy = action !== 'idle';
530
519
 
@@ -565,19 +554,6 @@ export const FeedbackModal: React.FC = () => {
565
554
  busy={action === 'hot-reloading'}
566
555
  />
567
556
 
568
- {/* 2. Screenshot + Fix — for bug fixes */}
569
- <ActionRow
570
- label={
571
- action === 'capturing'
572
- ? 'Capturing…'
573
- : 'Screenshot & Fix'
574
- }
575
- tint="#22c55e"
576
- onPress={handleScreenshotAndFix}
577
- disabled={busy}
578
- busy={action === 'capturing'}
579
- />
580
-
581
557
  {/* 3. Vibing — expands to an input box on first tap
582
558
  so the user says WHAT they want to vibe on, just
583
559
  like the Yaver mobile app's Vibing tab. Second
@@ -637,46 +613,51 @@ export const FeedbackModal: React.FC = () => {
637
613
  </Text>
638
614
  )}
639
615
 
640
- {/* Voice note only rendered when expo-av is installed.
641
- Tap to start, tap again to stop → transcribes via
642
- the agent and files as a feedback report. */}
643
- {voiceSupported && (
616
+ {/* Screenshot / Upload */}
617
+ {!showCaptureChoices ? (
644
618
  <ActionRow
645
619
  label={
646
- action === 'transcribing-voice'
647
- ? 'Transcribing…'
648
- : isRecordingVoice
649
- ? 'Stop & Send Voice'
650
- : 'Voice Note'
620
+ action === 'capturing'
621
+ ? 'Working…'
622
+ : 'Screenshot / Upload'
651
623
  }
652
- tint={isRecordingVoice ? '#ef4444' : '#f472b6'}
653
- onPress={handleToggleVoice}
654
- disabled={busy && action !== 'recording-voice' && action !== 'idle'}
655
- busy={action === 'transcribing-voice'}
624
+ tint="#22c55e"
625
+ onPress={handleCaptureChoiceToggle}
626
+ disabled={busy}
627
+ busy={action === 'capturing'}
656
628
  />
629
+ ) : (
630
+ <View style={styles.captureChoices}>
631
+ <ActionRow
632
+ label="Take Screenshot"
633
+ tint="#22c55e"
634
+ onPress={handleScreenshotAndFix}
635
+ disabled={busy}
636
+ />
637
+ <ActionRow
638
+ label="Upload File"
639
+ tint="#34d399"
640
+ onPress={handleFileUpload}
641
+ disabled={busy}
642
+ />
643
+ </View>
657
644
  )}
658
645
 
659
- {/* 4. Start/Stop Recording */}
660
- <ActionRow
661
- label={isRecordingVideo ? 'Stop Recording' : 'Start Recording'}
662
- tint={isRecordingVideo ? '#ef4444' : '#60a5fa'}
663
- onPress={handleToggleRecording}
664
- disabled={busy && action !== 'idle' && !isRecordingVideo}
665
- />
666
-
667
- {/* 5. Send Video (only tappable when a clip is ready) */}
646
+ {/* 4. Screen recording */}
668
647
  <ActionRow
669
648
  label={
670
- action === 'sending-video'
671
- ? 'Sending…'
672
- : lastVideo
673
- ? `Send Video · ${Math.round(lastVideo.duration)}s`
674
- : 'Send Video'
649
+ action === 'uploading-video'
650
+ ? 'Uploading…'
651
+ : isRecordingVideo
652
+ ? 'Stop & Upload Recording'
653
+ : lastVideo
654
+ ? `Retry Upload Recording · ${Math.round(lastVideo.duration)}s`
655
+ : 'Screen Recording'
675
656
  }
676
- tint="#a78bfa"
677
- onPress={handleSendVideo}
678
- disabled={busy || !lastVideo}
679
- busy={action === 'sending-video'}
657
+ tint={isRecordingVideo ? '#ef4444' : '#60a5fa'}
658
+ onPress={handleScreenRecording}
659
+ disabled={busy && action !== 'uploading-video' && !isRecordingVideo}
660
+ busy={action === 'uploading-video'}
680
661
  />
681
662
 
682
663
  {progress !== null && (
@@ -719,6 +700,18 @@ export const FeedbackModal: React.FC = () => {
719
700
  : '● Hide quick-access icon'}
720
701
  </Text>
721
702
  </Pressable>
703
+
704
+ <Pressable
705
+ onPress={handleClose}
706
+ style={({ pressed }) => [
707
+ styles.cancelBtn,
708
+ pressed && styles.buttonPressed,
709
+ ]}
710
+ accessibilityRole="button"
711
+ accessibilityLabel="Cancel"
712
+ >
713
+ <Text style={styles.cancelBtnText}>Cancel</Text>
714
+ </Pressable>
722
715
  </Pressable>
723
716
  </Pressable>
724
717
  </Modal>
@@ -868,6 +861,9 @@ const styles = StyleSheet.create({
868
861
  fontSize: 15,
869
862
  fontWeight: '700',
870
863
  },
864
+ captureChoices: {
865
+ gap: 10,
866
+ },
871
867
  progressTrack: {
872
868
  height: 6,
873
869
  borderRadius: 3,
@@ -903,4 +899,22 @@ const styles = StyleSheet.create({
903
899
  fontSize: 12,
904
900
  fontWeight: '500',
905
901
  },
902
+ cancelBtn: {
903
+ marginTop: 4,
904
+ borderRadius: 14,
905
+ borderWidth: 1,
906
+ borderColor: 'rgba(255,255,255,0.1)',
907
+ paddingVertical: 15,
908
+ alignItems: 'center',
909
+ justifyContent: 'center',
910
+ backgroundColor: 'rgba(255,255,255,0.04)',
911
+ },
912
+ cancelBtnText: {
913
+ color: '#e5e7eb',
914
+ fontSize: 15,
915
+ fontWeight: '700',
916
+ },
917
+ buttonPressed: {
918
+ opacity: 0.7,
919
+ },
906
920
  });
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: {