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.
@@ -13,31 +13,27 @@ 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';
24
22
  import { AuthOverlay } from './AuthOverlay';
23
+ import { QuickActionIcon } from './QuickActionIcon';
25
24
 
26
25
  /**
27
- * Simplified feedback modal — 5 actions:
26
+ * Simplified feedback modal — 4 actions:
28
27
  *
29
28
  * 1. Hot Reload — instant JS reload (most common use case)
30
- * 2. Screenshot + Fix — capture the underlying app (modal hidden
31
- * during capture), attach errors, trigger
32
- * a fix task on the agent
33
- * 3. Vibing — open a vibing session on the agent
34
- * 4. Start / Stop Recording screen-recording toggle
35
- * 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
36
34
  *
37
- * The header has an explicit X close icon on the right.
38
- * Live / Narrated / Batch modes, voice notes, and the streaming indicator
39
- * were removed in 0.7.0 — those flows never worked end-to-end against
40
- * 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.
41
37
  */
42
38
 
43
39
  interface LastVideo {
@@ -50,9 +46,7 @@ type ActionState =
50
46
  | 'hot-reloading'
51
47
  | 'capturing'
52
48
  | 'vibing'
53
- | 'sending-video'
54
- | 'recording-voice'
55
- | 'transcribing-voice';
49
+ | 'uploading-video';
56
50
 
57
51
  export const FeedbackModal: React.FC = () => {
58
52
  const [visible, setVisible] = useState(false);
@@ -61,11 +55,12 @@ export const FeedbackModal: React.FC = () => {
61
55
  const [toast, setToast] = useState<string | null>(null);
62
56
  const [progress, setProgress] = useState<number | null>(null);
63
57
  const [isRecordingVideo, setIsRecordingVideo] = useState(false);
64
- const [isRecordingVoice, setIsRecordingVoice] = useState(false);
65
- // Cached once on mount — bare-RN apps without expo-av get a clean
66
- // hidden button instead of a runtime error.
67
- const voiceSupported = useRef<boolean>(isVoiceCaptureSupported()).current;
68
58
  const [lastVideo, setLastVideo] = useState<LastVideo | null>(null);
59
+ // Tracks whether the user has hidden the QuickActionIcon via its
60
+ // long-press menu. Shake is always available, so the feedback modal
61
+ // is our guaranteed UI for bringing the icon back — we surface a
62
+ // small "Show quick icon" row when this is true.
63
+ const [quickIconHidden, setQuickIconHidden] = useState(false);
69
64
  // Vibing-input mode: same expand-on-tap pattern as email login.
70
65
  // Tap "Vibing" once → the button reveals an input + Send; that lets
71
66
  // the user say WHAT they want to vibe on instead of firing a canned
@@ -73,6 +68,7 @@ export const FeedbackModal: React.FC = () => {
73
68
  // the wrong project because the matcher grepped the prompt itself).
74
69
  const [showVibeInput, setShowVibeInput] = useState(false);
75
70
  const [vibePrompt, setVibePrompt] = useState('');
71
+ const [showCaptureChoices, setShowCaptureChoices] = useState(false);
76
72
  const [lastVibeTaskId, setLastVibeTaskId] = useState<string | null>(null);
77
73
  const mountedRef = useRef(true);
78
74
 
@@ -84,6 +80,15 @@ export const FeedbackModal: React.FC = () => {
84
80
  setError(null);
85
81
  setToast(null);
86
82
  setAction('idle');
83
+ setShowCaptureChoices(false);
84
+ // Re-read the "user hid the quick icon" flag on every open so
85
+ // the re-enable row reflects the latest preference (the user
86
+ // might have hidden or shown it between opens).
87
+ YaverFeedback.isQuickIconHidden()
88
+ .then((v) => {
89
+ if (mountedRef.current) setQuickIconHidden(v);
90
+ })
91
+ .catch(() => {});
87
92
  }
88
93
  });
89
94
  // Agent streams build / compile progress through the BlackBox
@@ -125,6 +130,7 @@ export const FeedbackModal: React.FC = () => {
125
130
  setError(null);
126
131
  setToast(null);
127
132
  setAction('idle');
133
+ setShowCaptureChoices(false);
128
134
  }, []);
129
135
 
130
136
  // Helper: run a P2P call; on network failure, ask YaverFeedback to
@@ -205,28 +211,61 @@ export const FeedbackModal: React.FC = () => {
205
211
  }
206
212
  }, [closeSoon, runWithReconnect]);
207
213
 
208
- // ─── 2. Screenshot + Fix ───────────────────────────────────────────
209
- //
210
- // Hide the modal first so the screenshot captures the actual screen
211
- // (the bug) — not the modal card. Await a short animation delay,
212
- // snapshot, upload the feedback bundle with any buffered errors, then
213
- // kick `/feedback/{id}/fix` to create the repair task.
214
- const handleScreenshotAndFix = useCallback(async () => {
214
+ const uploadBundleWithOptionalFix = useCallback(async (
215
+ bundle: FeedbackBundle,
216
+ fixOnUpload: boolean,
217
+ successToast: string,
218
+ failureToast?: string,
219
+ ) => {
215
220
  const client = YaverFeedback.getP2PClient();
216
221
  const config = YaverFeedback.getConfig();
217
222
  if (!client || !config?.agentUrl) {
218
223
  setError('Not connected to the agent yet.');
219
224
  return;
220
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 () => {
221
261
  setAction('capturing');
222
262
  setError(null);
263
+ setShowCaptureChoices(false);
223
264
 
224
- // Step 1: Hide the modal so the screenshot contains the real screen.
225
265
  setVisible(false);
226
- // Wait out the slide-down animation on both platforms.
227
266
  await new Promise((resolve) => setTimeout(resolve, 350));
228
267
 
229
- let path: string | null = null;
268
+ let path: string;
230
269
  try {
231
270
  path = await captureScreenshot();
232
271
  } catch (err: unknown) {
@@ -236,7 +275,6 @@ export const FeedbackModal: React.FC = () => {
236
275
  return;
237
276
  }
238
277
 
239
- // Step 2: Re-show the modal for progress + ack.
240
278
  setVisible(true);
241
279
  await new Promise((resolve) => setTimeout(resolve, 150));
242
280
 
@@ -250,7 +288,6 @@ export const FeedbackModal: React.FC = () => {
250
288
  screenWidth: width,
251
289
  screenHeight: height,
252
290
  };
253
-
254
291
  const capturedErrors = YaverFeedback.getCapturedErrors();
255
292
  const bundle: FeedbackBundle = {
256
293
  metadata: {
@@ -262,36 +299,61 @@ export const FeedbackModal: React.FC = () => {
262
299
  screenshots: [path],
263
300
  errors: capturedErrors.length > 0 ? capturedErrors : undefined,
264
301
  };
265
-
266
- const uploaded = await uploadFeedback(
267
- config.agentUrl,
268
- config.authToken ?? '',
302
+ await uploadBundleWithOptionalFix(
269
303
  bundle,
304
+ true,
305
+ 'Fix task started',
270
306
  );
271
- // The agent returns the new report id as `id` (see
272
- // feedback_http.go::ReceiveFeedback). Trigger the fix loop if we got
273
- // one back; otherwise just ack the upload.
274
- const reportId =
275
- (uploaded as { id?: string; reportId?: string } | null | undefined)?.id ??
276
- (uploaded as { reportId?: string } | null | undefined)?.reportId;
277
- if (reportId) {
278
- try {
279
- await client.triggerFix(reportId);
280
- setToast('Fix task started');
281
- } catch (err: unknown) {
282
- setToast('Report uploaded fix trigger failed');
283
- setError(err instanceof Error ? err.message : String(err));
284
- }
285
- } else {
286
- 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.');
287
342
  }
288
- closeSoon(1400);
343
+ await uploadBundleWithOptionalFix(
344
+ bundle,
345
+ picked.kind === 'image',
346
+ picked.kind === 'image' ? 'Fix task started' : 'File uploaded',
347
+ );
289
348
  } catch (err: unknown) {
290
- 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
+ }
291
353
  } finally {
292
354
  if (mountedRef.current) setAction('idle');
293
355
  }
294
- }, [closeSoon]);
356
+ }, [uploadBundleWithOptionalFix]);
295
357
 
296
358
  // ─── 3. Vibing ─────────────────────────────────────────────────────
297
359
  // First tap expands the input; second submit fires the actual
@@ -345,178 +407,120 @@ export const FeedbackModal: React.FC = () => {
345
407
  }
346
408
  }, [vibePrompt]);
347
409
 
348
- // ─── 4. Toggle screen recording ────────────────────────────────────
349
- const handleToggleRecording = useCallback(async () => {
410
+ // ─── 4. Screen recording ───────────────────────────────────────────
411
+ const handleScreenRecording = useCallback(async () => {
350
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
+
351
457
  if (isRecordingVideo) {
352
458
  try {
353
459
  const result = await stopVideoRecording();
354
460
  if (mountedRef.current) {
355
461
  setIsRecordingVideo(false);
356
462
  setLastVideo(result);
357
- 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.');
358
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);
359
498
  } catch (err: unknown) {
360
499
  setIsRecordingVideo(false);
361
500
  setError(err instanceof Error ? err.message : String(err));
501
+ } finally {
502
+ if (mountedRef.current) setAction('idle');
362
503
  }
363
504
  } else {
364
505
  try {
365
506
  await startVideoRecording();
366
507
  if (mountedRef.current) {
367
508
  setIsRecordingVideo(true);
368
- setToast('Recording…');
509
+ setToast('Recording… tap again to stop and upload');
369
510
  setLastVideo(null);
370
511
  }
371
512
  } catch (err: unknown) {
372
513
  setError(err instanceof Error ? err.message : String(err));
373
514
  }
374
515
  }
375
- }, [isRecordingVideo]);
376
-
377
- // ─── Voice note: record → transcribe → send as feedback ───────────
378
- // Tap once to start; tap again to stop. On stop: upload the audio
379
- // to the agent's /voice/transcribe (which routes through whichever
380
- // STT provider is configured — Whisper / Deepgram / OpenAI / etc.),
381
- // then file the transcript as a bug report. The audio file itself
382
- // is also attached to the feedback bundle so the agent can re-play
383
- // it if the transcript is wrong.
384
- const handleToggleVoice = useCallback(async () => {
385
- setError(null);
386
- if (!isRecordingVoice) {
387
- try {
388
- await startAudioRecording();
389
- if (mountedRef.current) {
390
- setIsRecordingVoice(true);
391
- setAction('recording-voice');
392
- setToast('Recording voice note…');
393
- }
394
- } catch (err: unknown) {
395
- setIsRecordingVoice(false);
396
- setAction('idle');
397
- setError(err instanceof Error ? err.message : String(err));
398
- }
399
- return;
400
- }
401
-
402
- // Stopping → transcribe → send.
403
- try {
404
- const audio = await stopAudioRecording();
405
- setIsRecordingVoice(false);
406
- if (!audio) {
407
- setAction('idle');
408
- return;
409
- }
410
- setAction('transcribing-voice');
411
- setToast('Transcribing…');
412
-
413
- const config = YaverFeedback.getConfig();
414
- if (!config?.agentUrl) {
415
- setError('Not connected to the agent yet.');
416
- setAction('idle');
417
- return;
418
- }
419
- let transcript = '';
420
- try {
421
- const client = YaverFeedback.getP2PClient();
422
- if (client) {
423
- const res = await client.transcribeVoice(audio.path);
424
- transcript = res.text ?? '';
425
- }
426
- } catch {
427
- // Transcription can fail (no STT provider configured on the
428
- // agent, network blip, etc.). Don't block the flow — ship
429
- // the raw audio file with a "[no transcript]" note so the
430
- // agent + human reviewer can still play it back.
431
- }
432
-
433
- const { Dimensions } = require('react-native');
434
- const { width, height } = Dimensions.get('window');
435
- const deviceInfo: DeviceInfo = {
436
- platform: Platform.OS,
437
- osVersion: String(Platform.Version),
438
- model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
439
- screenWidth: width,
440
- screenHeight: height,
441
- };
442
- const bundle: FeedbackBundle = {
443
- metadata: {
444
- timestamp: new Date().toISOString(),
445
- device: deviceInfo,
446
- app: {},
447
- userNote:
448
- transcript.length > 0
449
- ? `[Voice note] ${transcript}`
450
- : `[Voice note · ${Math.round(audio.duration)}s — transcription unavailable]`,
451
- },
452
- screenshots: [],
453
- audio: audio.path,
454
- errors: YaverFeedback.getCapturedErrors().length
455
- ? YaverFeedback.getCapturedErrors()
456
- : undefined,
457
- };
458
- await uploadFeedback(config.agentUrl, config.authToken ?? '', bundle);
459
- setToast(transcript ? `Sent: "${transcript.slice(0, 60)}${transcript.length > 60 ? '…' : ''}"` : 'Voice note sent');
460
- closeSoon(1800);
461
- } catch (err: unknown) {
462
- setError(err instanceof Error ? err.message : String(err));
463
- } finally {
464
- if (mountedRef.current) setAction('idle');
465
- }
466
- }, [isRecordingVoice, closeSoon]);
467
-
468
- // ─── 5. Send the last recorded video ───────────────────────────────
469
- const handleSendVideo = useCallback(async () => {
470
- const config = YaverFeedback.getConfig();
471
- if (!config?.agentUrl) {
472
- setError('Not connected to the agent yet.');
473
- return;
474
- }
475
- if (!lastVideo) {
476
- setError('No video recorded yet.');
477
- return;
478
- }
479
- setAction('sending-video');
480
- setError(null);
481
- try {
482
- const { Dimensions } = require('react-native');
483
- const { width, height } = Dimensions.get('window');
484
- const deviceInfo: DeviceInfo = {
485
- platform: Platform.OS,
486
- osVersion: String(Platform.Version),
487
- model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
488
- screenWidth: width,
489
- screenHeight: height,
490
- };
491
- const bundle: FeedbackBundle = {
492
- metadata: {
493
- timestamp: new Date().toISOString(),
494
- device: deviceInfo,
495
- app: {},
496
- userNote: '[Screen recording]',
497
- },
498
- screenshots: [],
499
- video: lastVideo.path,
500
- errors: YaverFeedback.getCapturedErrors().length
501
- ? YaverFeedback.getCapturedErrors()
502
- : undefined,
503
- };
504
- await uploadFeedback(config.agentUrl, config.authToken ?? '', bundle);
505
- setToast('Video sent');
506
- setLastVideo(null);
507
- closeSoon(1200);
508
- } catch (err: unknown) {
509
- setError(err instanceof Error ? err.message : String(err));
510
- } finally {
511
- if (mountedRef.current) setAction('idle');
512
- }
513
- }, [lastVideo, closeSoon]);
516
+ }, [closeSoon, isRecordingVideo, lastVideo]);
514
517
 
515
518
  const busy = action !== 'idle';
516
519
 
517
520
  return (
518
521
  <>
519
522
  <AuthOverlay />
523
+ <QuickActionIcon />
520
524
  {visible && (
521
525
  <Modal
522
526
  visible={visible}
@@ -550,19 +554,6 @@ export const FeedbackModal: React.FC = () => {
550
554
  busy={action === 'hot-reloading'}
551
555
  />
552
556
 
553
- {/* 2. Screenshot + Fix — for bug fixes */}
554
- <ActionRow
555
- label={
556
- action === 'capturing'
557
- ? 'Capturing…'
558
- : 'Screenshot & Fix'
559
- }
560
- tint="#22c55e"
561
- onPress={handleScreenshotAndFix}
562
- disabled={busy}
563
- busy={action === 'capturing'}
564
- />
565
-
566
557
  {/* 3. Vibing — expands to an input box on first tap
567
558
  so the user says WHAT they want to vibe on, just
568
559
  like the Yaver mobile app's Vibing tab. Second
@@ -622,46 +613,51 @@ export const FeedbackModal: React.FC = () => {
622
613
  </Text>
623
614
  )}
624
615
 
625
- {/* Voice note only rendered when expo-av is installed.
626
- Tap to start, tap again to stop → transcribes via
627
- the agent and files as a feedback report. */}
628
- {voiceSupported && (
616
+ {/* Screenshot / Upload */}
617
+ {!showCaptureChoices ? (
629
618
  <ActionRow
630
619
  label={
631
- action === 'transcribing-voice'
632
- ? 'Transcribing…'
633
- : isRecordingVoice
634
- ? 'Stop & Send Voice'
635
- : 'Voice Note'
620
+ action === 'capturing'
621
+ ? 'Working…'
622
+ : 'Screenshot / Upload'
636
623
  }
637
- tint={isRecordingVoice ? '#ef4444' : '#f472b6'}
638
- onPress={handleToggleVoice}
639
- disabled={busy && action !== 'recording-voice' && action !== 'idle'}
640
- busy={action === 'transcribing-voice'}
624
+ tint="#22c55e"
625
+ onPress={handleCaptureChoiceToggle}
626
+ disabled={busy}
627
+ busy={action === 'capturing'}
641
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>
642
644
  )}
643
645
 
644
- {/* 4. Start/Stop Recording */}
645
- <ActionRow
646
- label={isRecordingVideo ? 'Stop Recording' : 'Start Recording'}
647
- tint={isRecordingVideo ? '#ef4444' : '#60a5fa'}
648
- onPress={handleToggleRecording}
649
- disabled={busy && action !== 'idle' && !isRecordingVideo}
650
- />
651
-
652
- {/* 5. Send Video (only tappable when a clip is ready) */}
646
+ {/* 4. Screen recording */}
653
647
  <ActionRow
654
648
  label={
655
- action === 'sending-video'
656
- ? 'Sending…'
657
- : lastVideo
658
- ? `Send Video · ${Math.round(lastVideo.duration)}s`
659
- : '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'
660
656
  }
661
- tint="#a78bfa"
662
- onPress={handleSendVideo}
663
- disabled={busy || !lastVideo}
664
- busy={action === 'sending-video'}
657
+ tint={isRecordingVideo ? '#ef4444' : '#60a5fa'}
658
+ onPress={handleScreenRecording}
659
+ disabled={busy && action !== 'uploading-video' && !isRecordingVideo}
660
+ busy={action === 'uploading-video'}
665
661
  />
666
662
 
667
663
  {progress !== null && (
@@ -676,6 +672,46 @@ export const FeedbackModal: React.FC = () => {
676
672
  )}
677
673
  {toast && <Text style={styles.toast}>{toast}</Text>}
678
674
  {error && <Text style={styles.error}>{error}</Text>}
675
+
676
+ {/* Quick-icon toggle. The user's three ways to control
677
+ the floating icon are: (1) long-press the icon →
678
+ Hide, (2) tap this row to toggle it on/off, (3) shake
679
+ → this modal → tap this row. Shake is the unkillable
680
+ back-door when the icon is hidden and the dev hasn't
681
+ exposed their own settings UI. */}
682
+ <Pressable
683
+ onPress={async () => {
684
+ const next = !quickIconHidden;
685
+ setQuickIconHidden(next);
686
+ await YaverFeedback.setQuickIconVisible(!next);
687
+ }}
688
+ style={({ pressed }) => [
689
+ styles.quickIconToggle,
690
+ pressed && { opacity: 0.7 },
691
+ ]}
692
+ accessibilityRole="button"
693
+ accessibilityLabel={
694
+ quickIconHidden ? 'Show quick icon' : 'Hide quick icon'
695
+ }
696
+ >
697
+ <Text style={styles.quickIconToggleText}>
698
+ {quickIconHidden
699
+ ? '◯ Show quick-access icon'
700
+ : '● Hide quick-access icon'}
701
+ </Text>
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>
679
715
  </Pressable>
680
716
  </Pressable>
681
717
  </Modal>
@@ -825,6 +861,9 @@ const styles = StyleSheet.create({
825
861
  fontSize: 15,
826
862
  fontWeight: '700',
827
863
  },
864
+ captureChoices: {
865
+ gap: 10,
866
+ },
828
867
  progressTrack: {
829
868
  height: 6,
830
869
  borderRadius: 3,
@@ -849,4 +888,33 @@ const styles = StyleSheet.create({
849
888
  textAlign: 'center',
850
889
  marginTop: 4,
851
890
  },
891
+ quickIconToggle: {
892
+ marginTop: 4,
893
+ alignSelf: 'center',
894
+ paddingVertical: 6,
895
+ paddingHorizontal: 12,
896
+ },
897
+ quickIconToggleText: {
898
+ color: '#9ca3af',
899
+ fontSize: 12,
900
+ fontWeight: '500',
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
+ },
852
920
  });