yaver-feedback-react-native 0.8.1 → 0.8.2

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.
@@ -2,9 +2,12 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import {
3
3
  ActivityIndicator,
4
4
  DeviceEventEmitter,
5
+ Keyboard,
6
+ KeyboardAvoidingView,
5
7
  Modal,
6
8
  Platform,
7
9
  Pressable,
10
+ ScrollView,
8
11
  StyleSheet,
9
12
  Text,
10
13
  TextInput,
@@ -13,40 +16,50 @@ import {
13
16
  import { YaverFeedback } from './YaverFeedback';
14
17
  import {
15
18
  captureScreenshot,
16
- pickFeedbackFile,
17
- startVideoRecording,
18
- stopVideoRecording,
19
+ // Launch scope for the feedback test SDK is intentionally smaller for now.
20
+ // Keep the dormant file-upload and screen-recording helpers nearby, but
21
+ // comment them out until we bring them back with stronger test coverage.
22
+ // pickFeedbackFile,
23
+ // startVideoRecording,
24
+ // stopVideoRecording,
19
25
  } from './capture';
20
26
  import { uploadFeedback } from './upload';
21
27
  import { DeviceInfo, FeedbackBundle } from './types';
22
28
  import { AuthOverlay } from './AuthOverlay';
23
29
  import { QuickActionIcon } from './QuickActionIcon';
30
+ import { listReachableDevices, RemoteDevice } from './auth';
31
+ import {
32
+ QUICK_ICON_COLOR_PRESETS,
33
+ QuickIconColorPreset,
34
+ } from './preferences';
24
35
 
25
36
  /**
26
- * Simplified feedback modal — 4 actions:
37
+ * Simplified feedback modal — launch scope is 3 actions:
27
38
  *
28
39
  * 1. Hot Reload — instant JS reload (most common use case)
29
40
  * 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
41
+ * 3. Screenshot & Fix — capture the underlying app (modal hidden
42
+ * during capture), upload it, and trigger
43
+ * the fix loop
34
44
  *
35
45
  * The footer also has an explicit Cancel button so the icon tap path
36
46
  * feels like a standard action sheet rather than a hidden modal.
37
47
  */
38
48
 
39
- interface LastVideo {
40
- path: string;
41
- duration: number;
42
- }
43
-
44
49
  type ActionState =
45
50
  | 'idle'
46
51
  | 'hot-reloading'
47
52
  | 'capturing'
48
- | 'vibing'
49
- | 'uploading-video';
53
+ | 'vibing';
54
+
55
+ type MachineCardState = {
56
+ device: RemoteDevice | null;
57
+ reachable: boolean | null;
58
+ loading: boolean;
59
+ status: 'none' | 'live' | 'attention' | 'offline';
60
+ title: string;
61
+ detail: string;
62
+ };
50
63
 
51
64
  export const FeedbackModal: React.FC = () => {
52
65
  const [visible, setVisible] = useState(false);
@@ -54,8 +67,6 @@ export const FeedbackModal: React.FC = () => {
54
67
  const [error, setError] = useState<string | null>(null);
55
68
  const [toast, setToast] = useState<string | null>(null);
56
69
  const [progress, setProgress] = useState<number | null>(null);
57
- const [isRecordingVideo, setIsRecordingVideo] = useState(false);
58
- const [lastVideo, setLastVideo] = useState<LastVideo | null>(null);
59
70
  // Tracks whether the user has hidden the QuickActionIcon via its
60
71
  // long-press menu. Shake is always available, so the feedback modal
61
72
  // is our guaranteed UI for bringing the icon back — we surface a
@@ -68,10 +79,120 @@ export const FeedbackModal: React.FC = () => {
68
79
  // the wrong project because the matcher grepped the prompt itself).
69
80
  const [showVibeInput, setShowVibeInput] = useState(false);
70
81
  const [vibePrompt, setVibePrompt] = useState('');
71
- const [showCaptureChoices, setShowCaptureChoices] = useState(false);
72
82
  const [lastVibeTaskId, setLastVibeTaskId] = useState<string | null>(null);
83
+ const [quickIconColorPreset, setQuickIconColorPreset] =
84
+ useState<QuickIconColorPreset | null>(null);
85
+ const [machineCard, setMachineCard] = useState<MachineCardState>({
86
+ device: null,
87
+ reachable: null,
88
+ loading: false,
89
+ status: 'none',
90
+ title: 'No machine selected',
91
+ detail: 'Pick a remote dev machine before using the feedback actions.',
92
+ });
73
93
  const mountedRef = useRef(true);
74
94
 
95
+ const loadSelectedMachine = useCallback(async () => {
96
+ const cfg = YaverFeedback.getConfig();
97
+ if (!cfg?.authToken) {
98
+ if (mountedRef.current) {
99
+ setMachineCard({
100
+ device: null,
101
+ reachable: null,
102
+ loading: false,
103
+ status: 'none',
104
+ title: 'Not signed in',
105
+ detail: 'Sign in to pick and monitor a remote dev machine.',
106
+ });
107
+ }
108
+ return;
109
+ }
110
+ if (!cfg.preferredDeviceId) {
111
+ if (mountedRef.current) {
112
+ setMachineCard({
113
+ device: null,
114
+ reachable: null,
115
+ loading: false,
116
+ status: 'none',
117
+ title: 'No machine selected',
118
+ detail: 'Choose which machine this SDK should talk to.',
119
+ });
120
+ }
121
+ return;
122
+ }
123
+
124
+ if (mountedRef.current) {
125
+ setMachineCard((prev) => ({ ...prev, loading: true }));
126
+ }
127
+
128
+ try {
129
+ const devices = await listReachableDevices(cfg.authToken);
130
+ const all = [...devices.owned, ...devices.shared];
131
+ const device =
132
+ all.find((candidate) => candidate.deviceId === cfg.preferredDeviceId) ?? null;
133
+
134
+ if (!device) {
135
+ if (mountedRef.current) {
136
+ setMachineCard({
137
+ device: null,
138
+ reachable: null,
139
+ loading: false,
140
+ status: 'offline',
141
+ title: 'Selected machine missing',
142
+ detail: 'The saved machine was not returned by the device list. Re-select it.',
143
+ });
144
+ }
145
+ return;
146
+ }
147
+
148
+ let reachable: boolean | null = null;
149
+ const client = YaverFeedback.getP2PClient();
150
+ if (device.isOnline && !device.needsAuth && client) {
151
+ reachable = await client.health();
152
+ }
153
+
154
+ const hostHint = device.hostEmail ? ` via ${device.hostEmail}` : '';
155
+ let status: MachineCardState['status'] = 'live';
156
+ let detail = `${device.platform}${hostHint}`;
157
+
158
+ if (!device.isOnline) {
159
+ status = 'offline';
160
+ detail = 'Machine offline. Start `yaver serve` on the selected machine.';
161
+ } else if (device.needsAuth) {
162
+ status = 'attention';
163
+ detail = 'Machine needs pairing again before feedback actions can run.';
164
+ } else if (device.runnerDown) {
165
+ status = 'attention';
166
+ detail = 'Machine is online but the coding agent is down.';
167
+ } else if (reachable === false) {
168
+ status = 'offline';
169
+ detail = 'Machine selected, but the agent is not responding.';
170
+ }
171
+
172
+ if (mountedRef.current) {
173
+ setMachineCard({
174
+ device,
175
+ reachable,
176
+ loading: false,
177
+ status,
178
+ title: device.name || device.deviceId,
179
+ detail,
180
+ });
181
+ }
182
+ } catch (err) {
183
+ if (mountedRef.current) {
184
+ setMachineCard({
185
+ device: null,
186
+ reachable: null,
187
+ loading: false,
188
+ status: 'offline',
189
+ title: 'Machine status unavailable',
190
+ detail: err instanceof Error ? err.message : String(err),
191
+ });
192
+ }
193
+ }
194
+ }, []);
195
+
75
196
  useEffect(() => {
76
197
  mountedRef.current = true;
77
198
  const sub = DeviceEventEmitter.addListener('yaverFeedback:startReport', () => {
@@ -79,8 +200,10 @@ export const FeedbackModal: React.FC = () => {
79
200
  setVisible(true);
80
201
  setError(null);
81
202
  setToast(null);
203
+ setProgress(null);
82
204
  setAction('idle');
83
- setShowCaptureChoices(false);
205
+ setShowVibeInput(false);
206
+ setVibePrompt('');
84
207
  // Re-read the "user hid the quick icon" flag on every open so
85
208
  // the re-enable row reflects the latest preference (the user
86
209
  // might have hidden or shown it between opens).
@@ -89,6 +212,12 @@ export const FeedbackModal: React.FC = () => {
89
212
  if (mountedRef.current) setQuickIconHidden(v);
90
213
  })
91
214
  .catch(() => {});
215
+ YaverFeedback.getQuickIconColorPreset()
216
+ .then((preset) => {
217
+ if (mountedRef.current) setQuickIconColorPreset(preset);
218
+ })
219
+ .catch(() => {});
220
+ void loadSelectedMachine();
92
221
  }
93
222
  });
94
223
  // Agent streams build / compile progress through the BlackBox
@@ -117,7 +246,15 @@ export const FeedbackModal: React.FC = () => {
117
246
  sub.remove();
118
247
  statusSub.remove();
119
248
  };
120
- }, []);
249
+ }, [loadSelectedMachine]);
250
+
251
+ useEffect(() => {
252
+ if (!visible) return;
253
+ const interval = setInterval(() => {
254
+ void loadSelectedMachine();
255
+ }, 5000);
256
+ return () => clearInterval(interval);
257
+ }, [loadSelectedMachine, visible]);
121
258
 
122
259
  const closeSoon = useCallback((delayMs = 1200) => {
123
260
  setTimeout(() => {
@@ -129,8 +266,10 @@ export const FeedbackModal: React.FC = () => {
129
266
  setVisible(false);
130
267
  setError(null);
131
268
  setToast(null);
269
+ setProgress(null);
132
270
  setAction('idle');
133
- setShowCaptureChoices(false);
271
+ setShowVibeInput(false);
272
+ setVibePrompt('');
134
273
  }, []);
135
274
 
136
275
  // Helper: run a P2P call; on network failure, ask YaverFeedback to
@@ -188,28 +327,54 @@ export const FeedbackModal: React.FC = () => {
188
327
  setAction('hot-reloading');
189
328
  setError(null);
190
329
  setProgress(0);
191
- setToast('Sending…');
330
+ setToast('Contacting selected machine…');
192
331
  try {
332
+ await loadSelectedMachine();
333
+ const selected = await YaverFeedback.getSelectedRemoteDevice();
334
+ if (!selected) {
335
+ YaverFeedback.showMachinePicker();
336
+ throw new Error('No machine selected. Pick a machine and try again.');
337
+ }
338
+ if (selected.needsAuth) {
339
+ YaverFeedback.showMachinePicker();
340
+ throw new Error('Selected machine needs pairing again.');
341
+ }
342
+ if (!selected.isOnline) {
343
+ throw new Error('Selected machine is offline. Start `yaver serve` on it first.');
344
+ }
345
+
193
346
  // Default mode: bundle. Always rebuilds via the agent regardless
194
347
  // of Metro state. P2PClient.reloadApp auto-resolves projectName +
195
348
  // bundleId from expo-constants / NativeModules so the agent can
196
349
  // map this app to its MobileProject scan entry without needing
197
350
  // `yaver dev start` to have been run.
351
+ let ackMessage = 'Reload request acknowledged.';
198
352
  await runWithReconnect(async (client) => {
199
- await client.reloadApp('bundle');
353
+ const ack = await client.reloadApp('bundle');
354
+ ackMessage = ack.message;
355
+ setToast(ack.message);
356
+ setProgress(0.2);
200
357
  });
201
358
  // We don't auto-close here — the agent's BlackBox status pings
202
359
  // will keep the modal updated, and the on-device YaverBundleLoader
203
360
  // will reload the JS once the fresh bundle arrives. Modal stays
204
361
  // up for a beat so the user sees the final progress state.
362
+ setToast(ackMessage);
205
363
  closeSoon(2500);
206
364
  } catch (err: unknown) {
207
- setError(err instanceof Error ? err.message : String(err));
365
+ const message = err instanceof Error ? err.message : String(err);
366
+ setError(message);
367
+ setToast(
368
+ message.toLowerCase().indexOf('session expired') >= 0
369
+ ? 'Session expired. Sign in again.'
370
+ : 'Hot reload did not start.',
371
+ );
372
+ await loadSelectedMachine();
208
373
  setProgress(null);
209
374
  } finally {
210
375
  if (mountedRef.current) setAction('idle');
211
376
  }
212
- }, [closeSoon, runWithReconnect]);
377
+ }, [closeSoon, loadSelectedMachine, runWithReconnect]);
213
378
 
214
379
  const uploadBundleWithOptionalFix = useCallback(async (
215
380
  bundle: FeedbackBundle,
@@ -252,15 +417,9 @@ export const FeedbackModal: React.FC = () => {
252
417
  }
253
418
  }, [closeSoon]);
254
419
 
255
- // ─── 3. Screenshot / Upload ───────────────────────────────────────
256
- const handleCaptureChoiceToggle = useCallback(() => {
257
- setShowCaptureChoices((v) => !v);
258
- }, []);
259
-
260
420
  const handleScreenshotAndFix = useCallback(async () => {
261
421
  setAction('capturing');
262
422
  setError(null);
263
- setShowCaptureChoices(false);
264
423
 
265
424
  setVisible(false);
266
425
  await new Promise((resolve) => setTimeout(resolve, 350));
@@ -309,51 +468,11 @@ export const FeedbackModal: React.FC = () => {
309
468
  }
310
469
  }, [uploadBundleWithOptionalFix]);
311
470
 
471
+ /*
312
472
  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.');
342
- }
343
- await uploadBundleWithOptionalFix(
344
- bundle,
345
- picked.kind === 'image',
346
- picked.kind === 'image' ? 'Fix task started' : 'File uploaded',
347
- );
348
- } catch (err: unknown) {
349
- const message = err instanceof Error ? err.message : String(err);
350
- if (message !== 'File selection canceled.') {
351
- setError(message);
352
- }
353
- } finally {
354
- if (mountedRef.current) setAction('idle');
355
- }
473
+ ...
356
474
  }, [uploadBundleWithOptionalFix]);
475
+ */
357
476
 
358
477
  // ─── 3. Vibing ─────────────────────────────────────────────────────
359
478
  // First tap expands the input; second submit fires the actual
@@ -407,113 +526,11 @@ export const FeedbackModal: React.FC = () => {
407
526
  }
408
527
  }, [vibePrompt]);
409
528
 
410
- // ─── 4. Screen recording ───────────────────────────────────────────
529
+ /*
411
530
  const handleScreenRecording = useCallback(async () => {
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
-
457
- if (isRecordingVideo) {
458
- try {
459
- const result = await stopVideoRecording();
460
- if (mountedRef.current) {
461
- setIsRecordingVideo(false);
462
- setLastVideo(result);
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.');
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);
498
- } catch (err: unknown) {
499
- setIsRecordingVideo(false);
500
- setError(err instanceof Error ? err.message : String(err));
501
- } finally {
502
- if (mountedRef.current) setAction('idle');
503
- }
504
- } else {
505
- try {
506
- await startVideoRecording();
507
- if (mountedRef.current) {
508
- setIsRecordingVideo(true);
509
- setToast('Recording… tap again to stop and upload');
510
- setLastVideo(null);
511
- }
512
- } catch (err: unknown) {
513
- setError(err instanceof Error ? err.message : String(err));
514
- }
515
- }
531
+ ...
516
532
  }, [closeSoon, isRecordingVideo, lastVideo]);
533
+ */
517
534
 
518
535
  const busy = action !== 'idle';
519
536
 
@@ -529,7 +546,23 @@ export const FeedbackModal: React.FC = () => {
529
546
  onRequestClose={handleClose}
530
547
  >
531
548
  <Pressable style={styles.overlay} onPress={handleClose}>
532
- <Pressable style={styles.modal} onPress={(e) => e.stopPropagation()}>
549
+ <KeyboardAvoidingView
550
+ behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
551
+ style={styles.kbAvoider}
552
+ pointerEvents="box-none"
553
+ >
554
+ <Pressable
555
+ style={styles.modal}
556
+ onPress={(e) => {
557
+ e.stopPropagation();
558
+ Keyboard.dismiss();
559
+ }}
560
+ >
561
+ <ScrollView
562
+ style={styles.scroll}
563
+ contentContainerStyle={styles.scrollContent}
564
+ keyboardShouldPersistTaps="handled"
565
+ >
533
566
  <View style={styles.header}>
534
567
  <Text style={styles.title}>Send Feedback</Text>
535
568
  <Pressable
@@ -543,6 +576,111 @@ export const FeedbackModal: React.FC = () => {
543
576
  </Pressable>
544
577
  </View>
545
578
 
579
+ <Pressable
580
+ onPress={() => {
581
+ if (!YaverFeedback.isAuthed()) {
582
+ YaverFeedback.showLogin();
583
+ return;
584
+ }
585
+ YaverFeedback.showMachinePicker();
586
+ }}
587
+ style={[
588
+ styles.machineCard,
589
+ machineCard.status === 'live' && styles.machineCardLive,
590
+ machineCard.status === 'attention' && styles.machineCardAttention,
591
+ machineCard.status === 'offline' && styles.machineCardOffline,
592
+ ]}
593
+ >
594
+ <View style={styles.machineHeader}>
595
+ <View style={styles.machineTitleWrap}>
596
+ <View
597
+ style={[
598
+ styles.machineDot,
599
+ machineCard.status === 'live' && styles.machineDotLive,
600
+ machineCard.status === 'attention' && styles.machineDotAttention,
601
+ machineCard.status === 'offline' && styles.machineDotOffline,
602
+ ]}
603
+ />
604
+ <Text style={styles.machineLabel}>Selected Machine</Text>
605
+ </View>
606
+ <Text style={styles.machineAction}>
607
+ {machineCard.loading ? 'Refreshing…' : 'Change'}
608
+ </Text>
609
+ </View>
610
+ <Text style={styles.machineName}>
611
+ {machineCard.loading ? 'Checking machine…' : machineCard.title}
612
+ </Text>
613
+ <Text style={styles.machineMeta}>{machineCard.detail}</Text>
614
+ </Pressable>
615
+
616
+ {quickIconHidden && (
617
+ <View style={styles.quickIconNote}>
618
+ <Text style={styles.quickIconNoteText}>
619
+ Quick access icon is hidden. Shake the phone if you want feedback back fast.
620
+ </Text>
621
+ <Pressable
622
+ onPress={() => {
623
+ void YaverFeedback.setQuickIconVisible(true);
624
+ setQuickIconHidden(false);
625
+ }}
626
+ style={({ pressed }) => [
627
+ styles.quickIconToggle,
628
+ pressed && styles.buttonPressed,
629
+ ]}
630
+ >
631
+ <Text style={styles.quickIconToggleText}>Show quick icon again</Text>
632
+ </Pressable>
633
+ </View>
634
+ )}
635
+
636
+ <View style={styles.iconSelector}>
637
+ <Text style={styles.iconSelectorTitle}>Quick Icon Color</Text>
638
+ <Text style={styles.iconSelectorText}>
639
+ Pick a runtime color so the floating y icon does not overlap with your app UI.
640
+ </Text>
641
+ <View style={styles.iconSelectorGrid}>
642
+ {(Object.entries(QUICK_ICON_COLOR_PRESETS) as Array<
643
+ [QuickIconColorPreset, (typeof QUICK_ICON_COLOR_PRESETS)[QuickIconColorPreset]]
644
+ >).map(([preset, colors]) => {
645
+ const selected = quickIconColorPreset === preset;
646
+ return (
647
+ <Pressable
648
+ key={preset}
649
+ onPress={() => {
650
+ setQuickIconColorPreset(preset);
651
+ void YaverFeedback.setQuickIconColorPreset(preset);
652
+ }}
653
+ style={[
654
+ styles.iconOption,
655
+ selected && styles.iconOptionSelected,
656
+ ]}
657
+ >
658
+ <View
659
+ style={[
660
+ styles.iconOptionCircle,
661
+ {
662
+ backgroundColor: colors.backgroundColor,
663
+ borderColor: colors.borderColor,
664
+ shadowColor: colors.shadowColor,
665
+ },
666
+ ]}
667
+ >
668
+ <Text
669
+ style={[
670
+ styles.iconOptionLabel,
671
+ { color: colors.foregroundColor },
672
+ ]}
673
+ >
674
+ y
675
+ </Text>
676
+ </View>
677
+ <Text style={styles.iconOptionText}>{colors.label}</Text>
678
+ </Pressable>
679
+ );
680
+ })}
681
+ </View>
682
+ </View>
683
+
546
684
  {/* 1. Hot Reload — the common path */}
547
685
  <ActionRow
548
686
  label={
@@ -613,51 +751,13 @@ export const FeedbackModal: React.FC = () => {
613
751
  </Text>
614
752
  )}
615
753
 
616
- {/* Screenshot / Upload */}
617
- {!showCaptureChoices ? (
618
- <ActionRow
619
- label={
620
- action === 'capturing'
621
- ? 'Working…'
622
- : 'Screenshot / Upload'
623
- }
624
- tint="#22c55e"
625
- onPress={handleCaptureChoiceToggle}
626
- disabled={busy}
627
- busy={action === 'capturing'}
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>
644
- )}
645
-
646
- {/* 4. Screen recording */}
754
+ {/* Screenshot & Fix */}
647
755
  <ActionRow
648
- label={
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'
656
- }
657
- tint={isRecordingVideo ? '#ef4444' : '#60a5fa'}
658
- onPress={handleScreenRecording}
659
- disabled={busy && action !== 'uploading-video' && !isRecordingVideo}
660
- busy={action === 'uploading-video'}
756
+ label={action === 'capturing' ? 'Working…' : 'Screenshot & Fix'}
757
+ tint="#22c55e"
758
+ onPress={handleScreenshotAndFix}
759
+ disabled={busy}
760
+ busy={action === 'capturing'}
661
761
  />
662
762
 
663
763
  {progress !== null && (
@@ -673,34 +773,6 @@ export const FeedbackModal: React.FC = () => {
673
773
  {toast && <Text style={styles.toast}>{toast}</Text>}
674
774
  {error && <Text style={styles.error}>{error}</Text>}
675
775
 
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
776
  <Pressable
705
777
  onPress={handleClose}
706
778
  style={({ pressed }) => [
@@ -712,7 +784,9 @@ export const FeedbackModal: React.FC = () => {
712
784
  >
713
785
  <Text style={styles.cancelBtnText}>Cancel</Text>
714
786
  </Pressable>
787
+ </ScrollView>
715
788
  </Pressable>
789
+ </KeyboardAvoidingView>
716
790
  </Pressable>
717
791
  </Modal>
718
792
  )}
@@ -814,6 +888,10 @@ const styles = StyleSheet.create({
814
888
  backgroundColor: 'rgba(0,0,0,0.55)',
815
889
  justifyContent: 'flex-end',
816
890
  },
891
+ kbAvoider: {
892
+ width: '100%',
893
+ justifyContent: 'flex-end',
894
+ },
817
895
  modal: {
818
896
  backgroundColor: '#141422',
819
897
  borderTopLeftRadius: 22,
@@ -821,6 +899,14 @@ const styles = StyleSheet.create({
821
899
  padding: 22,
822
900
  paddingBottom: 36,
823
901
  gap: 12,
902
+ maxHeight: '92%',
903
+ },
904
+ scroll: {
905
+ maxHeight: '100%',
906
+ },
907
+ scrollContent: {
908
+ gap: 12,
909
+ paddingBottom: 8,
824
910
  },
825
911
  header: {
826
912
  flexDirection: 'row',
@@ -861,6 +947,74 @@ const styles = StyleSheet.create({
861
947
  fontSize: 15,
862
948
  fontWeight: '700',
863
949
  },
950
+ machineCard: {
951
+ borderRadius: 14,
952
+ borderWidth: 1,
953
+ padding: 14,
954
+ backgroundColor: 'rgba(255,255,255,0.04)',
955
+ borderColor: 'rgba(255,255,255,0.12)',
956
+ },
957
+ machineCardLive: {
958
+ backgroundColor: 'rgba(34,197,94,0.10)',
959
+ borderColor: 'rgba(34,197,94,0.35)',
960
+ },
961
+ machineCardAttention: {
962
+ backgroundColor: 'rgba(245,158,11,0.10)',
963
+ borderColor: 'rgba(245,158,11,0.35)',
964
+ },
965
+ machineCardOffline: {
966
+ backgroundColor: 'rgba(239,68,68,0.10)',
967
+ borderColor: 'rgba(239,68,68,0.35)',
968
+ },
969
+ machineHeader: {
970
+ flexDirection: 'row',
971
+ alignItems: 'center',
972
+ justifyContent: 'space-between',
973
+ marginBottom: 6,
974
+ },
975
+ machineTitleWrap: {
976
+ flexDirection: 'row',
977
+ alignItems: 'center',
978
+ gap: 8,
979
+ },
980
+ machineDot: {
981
+ width: 10,
982
+ height: 10,
983
+ borderRadius: 5,
984
+ backgroundColor: '#6b7280',
985
+ },
986
+ machineDotLive: {
987
+ backgroundColor: '#22c55e',
988
+ },
989
+ machineDotAttention: {
990
+ backgroundColor: '#f59e0b',
991
+ },
992
+ machineDotOffline: {
993
+ backgroundColor: '#ef4444',
994
+ },
995
+ machineLabel: {
996
+ color: '#cbd5e1',
997
+ fontSize: 12,
998
+ fontWeight: '700',
999
+ textTransform: 'uppercase',
1000
+ letterSpacing: 0.8,
1001
+ },
1002
+ machineAction: {
1003
+ color: '#a5b4fc',
1004
+ fontSize: 12,
1005
+ fontWeight: '700',
1006
+ },
1007
+ machineName: {
1008
+ color: '#fff',
1009
+ fontSize: 16,
1010
+ fontWeight: '700',
1011
+ },
1012
+ machineMeta: {
1013
+ color: '#cbd5e1',
1014
+ fontSize: 12,
1015
+ marginTop: 4,
1016
+ lineHeight: 17,
1017
+ },
864
1018
  captureChoices: {
865
1019
  gap: 10,
866
1020
  },
@@ -889,15 +1043,89 @@ const styles = StyleSheet.create({
889
1043
  marginTop: 4,
890
1044
  },
891
1045
  quickIconToggle: {
892
- marginTop: 4,
893
- alignSelf: 'center',
1046
+ marginTop: 6,
1047
+ alignSelf: 'flex-start',
894
1048
  paddingVertical: 6,
895
- paddingHorizontal: 12,
1049
+ paddingHorizontal: 10,
1050
+ borderRadius: 10,
1051
+ backgroundColor: 'rgba(255,255,255,0.05)',
896
1052
  },
897
1053
  quickIconToggleText: {
898
- color: '#9ca3af',
1054
+ color: '#cbd5e1',
1055
+ fontSize: 12,
1056
+ fontWeight: '700',
1057
+ },
1058
+ quickIconNote: {
1059
+ borderRadius: 12,
1060
+ borderWidth: 1,
1061
+ borderColor: 'rgba(251,191,36,0.28)',
1062
+ backgroundColor: 'rgba(251,191,36,0.08)',
1063
+ padding: 12,
1064
+ },
1065
+ quickIconNoteText: {
1066
+ color: '#fde68a',
899
1067
  fontSize: 12,
900
- fontWeight: '500',
1068
+ lineHeight: 17,
1069
+ },
1070
+ iconSelector: {
1071
+ borderRadius: 12,
1072
+ borderWidth: 1,
1073
+ borderColor: 'rgba(255,255,255,0.09)',
1074
+ backgroundColor: 'rgba(255,255,255,0.03)',
1075
+ padding: 12,
1076
+ gap: 10,
1077
+ },
1078
+ iconSelectorTitle: {
1079
+ color: '#f8fafc',
1080
+ fontSize: 13,
1081
+ fontWeight: '700',
1082
+ },
1083
+ iconSelectorText: {
1084
+ color: '#cbd5e1',
1085
+ fontSize: 12,
1086
+ lineHeight: 17,
1087
+ },
1088
+ iconSelectorGrid: {
1089
+ flexDirection: 'row',
1090
+ flexWrap: 'wrap',
1091
+ gap: 10,
1092
+ },
1093
+ iconOption: {
1094
+ width: '31%',
1095
+ minWidth: 84,
1096
+ borderRadius: 12,
1097
+ borderWidth: 1,
1098
+ borderColor: 'rgba(255,255,255,0.08)',
1099
+ backgroundColor: 'rgba(255,255,255,0.02)',
1100
+ paddingVertical: 10,
1101
+ paddingHorizontal: 8,
1102
+ alignItems: 'center',
1103
+ gap: 8,
1104
+ },
1105
+ iconOptionSelected: {
1106
+ borderColor: 'rgba(129,140,248,0.72)',
1107
+ backgroundColor: 'rgba(129,140,248,0.12)',
1108
+ },
1109
+ iconOptionCircle: {
1110
+ width: 38,
1111
+ height: 38,
1112
+ borderRadius: 19,
1113
+ alignItems: 'center',
1114
+ justifyContent: 'center',
1115
+ borderWidth: 2,
1116
+ shadowOffset: { width: 0, height: 2 },
1117
+ shadowOpacity: 0.28,
1118
+ shadowRadius: 5,
1119
+ elevation: 5,
1120
+ },
1121
+ iconOptionLabel: {
1122
+ fontSize: 18,
1123
+ fontWeight: '700',
1124
+ },
1125
+ iconOptionText: {
1126
+ color: '#e2e8f0',
1127
+ fontSize: 11,
1128
+ fontWeight: '600',
901
1129
  },
902
1130
  cancelBtn: {
903
1131
  marginTop: 4,