yaver-feedback-react-native 0.6.0 → 0.7.0

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.
@@ -37,233 +37,226 @@ exports.FeedbackModal = void 0;
37
37
  const react_1 = __importStar(require("react"));
38
38
  const react_native_1 = require("react-native");
39
39
  const YaverFeedback_1 = require("./YaverFeedback");
40
- const BlackBox_1 = require("./BlackBox");
41
40
  const capture_1 = require("./capture");
42
41
  const upload_1 = require("./upload");
43
42
  const AuthOverlay_1 = require("./AuthOverlay");
44
- const MODE_LABELS = {
45
- live: 'Live',
46
- narrated: 'Narrated',
47
- batch: 'Batch',
48
- };
49
- /**
50
- * Full-screen modal for composing and sending a feedback report.
51
- * Renders when triggered by shake, floating button, or manual call.
52
- *
53
- * Supports three feedback modes:
54
- * - Live: stream events to the agent as they happen
55
- * - Narrated: record everything, send on stop
56
- * - Batch: dump everything at end (default)
57
- */
58
43
  const FeedbackModal = () => {
59
44
  const [visible, setVisible] = (0, react_1.useState)(false);
60
- const [timeline, setTimeline] = (0, react_1.useState)([]);
61
- const [isRecordingAudio, setIsRecordingAudio] = (0, react_1.useState)(false);
62
- const [isSending, setIsSending] = (0, react_1.useState)(false);
45
+ const [action, setAction] = (0, react_1.useState)('idle');
63
46
  const [error, setError] = (0, react_1.useState)(null);
64
- const [sent, setSent] = (0, react_1.useState)(false);
65
- const [mode, setMode] = (0, react_1.useState)('batch');
66
- const [commentary, setCommentary] = (0, react_1.useState)([]);
67
- const [isVoiceCommand, setIsVoiceCommand] = (0, react_1.useState)(false);
68
- const [isReloading, setIsReloading] = (0, react_1.useState)(false);
47
+ const [toast, setToast] = (0, react_1.useState)(null);
48
+ const [isRecordingVideo, setIsRecordingVideo] = (0, react_1.useState)(false);
49
+ const [lastVideo, setLastVideo] = (0, react_1.useState)(null);
69
50
  const mountedRef = (0, react_1.useRef)(true);
70
- const commentaryListRef = (0, react_1.useRef)(null);
71
51
  (0, react_1.useEffect)(() => {
72
52
  mountedRef.current = true;
73
53
  const sub = react_native_1.DeviceEventEmitter.addListener('yaverFeedback:startReport', () => {
74
54
  if (YaverFeedback_1.YaverFeedback.isEnabled()) {
75
55
  setVisible(true);
76
- setTimeline([]);
77
56
  setError(null);
78
- setSent(false);
79
- setCommentary([]);
80
- setMode(YaverFeedback_1.YaverFeedback.getFeedbackMode());
81
- }
82
- });
83
- // Listen for agent commentary events
84
- const commentarySub = react_native_1.DeviceEventEmitter.addListener('yaverFeedback:commentary', (event) => {
85
- if (mountedRef.current) {
86
- setCommentary((prev) => [...prev, event]);
57
+ setToast(null);
58
+ setAction('idle');
87
59
  }
88
60
  });
89
61
  return () => {
90
62
  mountedRef.current = false;
91
63
  sub.remove();
92
- commentarySub.remove();
93
64
  };
94
65
  }, []);
95
- const handleScreenshot = (0, react_1.useCallback)(async () => {
66
+ const closeSoon = (0, react_1.useCallback)((delayMs = 1200) => {
67
+ setTimeout(() => {
68
+ if (mountedRef.current)
69
+ setVisible(false);
70
+ }, delayMs);
71
+ }, []);
72
+ const handleClose = (0, react_1.useCallback)(() => {
73
+ setVisible(false);
74
+ setError(null);
75
+ setToast(null);
76
+ setAction('idle');
77
+ }, []);
78
+ // ─── 1. Hot reload ─────────────────────────────────────────────────
79
+ const handleHotReload = (0, react_1.useCallback)(async () => {
80
+ const client = YaverFeedback_1.YaverFeedback.getP2PClient();
81
+ if (!client) {
82
+ setError('Not connected to the agent yet.');
83
+ return;
84
+ }
85
+ setAction('hot-reloading');
86
+ setError(null);
96
87
  try {
97
- const path = await (0, capture_1.captureScreenshot)();
98
- if (mountedRef.current) {
99
- const event = {
100
- type: 'screenshot',
101
- path,
102
- timestamp: new Date().toISOString(),
103
- };
104
- setTimeline((prev) => [...prev, event]);
105
- // In live mode, stream the event immediately
106
- if (mode === 'live') {
107
- const client = YaverFeedback_1.YaverFeedback.getP2PClient();
108
- if (client) {
109
- try {
110
- await client.streamFeedback((async function* () {
111
- yield {
112
- type: 'screenshot',
113
- timestamp: event.timestamp,
114
- data: { path },
115
- };
116
- })());
117
- }
118
- catch (err) {
119
- console.warn('[YaverFeedback] Live stream failed:', err);
120
- }
121
- }
122
- }
123
- }
88
+ await client.reloadApp('dev');
89
+ setToast('Reload sent');
90
+ closeSoon(800);
124
91
  }
125
92
  catch (err) {
126
- if (mountedRef.current) {
127
- setError(String(err));
128
- }
93
+ setError(err instanceof Error ? err.message : String(err));
129
94
  }
130
- }, [mode]);
131
- const handleToggleAudio = (0, react_1.useCallback)(async () => {
132
- if (isRecordingAudio) {
133
- try {
134
- const result = await (0, capture_1.stopAudioRecording)();
135
- if (mountedRef.current) {
136
- setIsRecordingAudio(false);
137
- const event = {
138
- type: 'audio',
139
- path: result.path,
140
- timestamp: new Date().toISOString(),
141
- duration: result.duration,
142
- };
143
- setTimeline((prev) => [...prev, event]);
144
- // In live mode, stream the audio event
145
- if (mode === 'live') {
146
- const client = YaverFeedback_1.YaverFeedback.getP2PClient();
147
- if (client) {
148
- try {
149
- await client.streamFeedback((async function* () {
150
- yield {
151
- type: 'audio',
152
- timestamp: event.timestamp,
153
- data: { path: result.path, duration: result.duration },
154
- };
155
- })());
156
- }
157
- catch (err) {
158
- console.warn('[YaverFeedback] Live stream failed:', err);
159
- }
160
- }
161
- }
162
- }
163
- }
164
- catch (err) {
165
- if (mountedRef.current) {
166
- setIsRecordingAudio(false);
167
- setError(String(err));
168
- }
169
- }
95
+ finally {
96
+ if (mountedRef.current)
97
+ setAction('idle');
170
98
  }
171
- else {
172
- try {
173
- await (0, capture_1.startAudioRecording)();
174
- if (mountedRef.current) {
175
- setIsRecordingAudio(true);
99
+ }, [closeSoon]);
100
+ // ─── 2. Screenshot + Fix ───────────────────────────────────────────
101
+ //
102
+ // Hide the modal first so the screenshot captures the actual screen
103
+ // (the bug) — not the modal card. Await a short animation delay,
104
+ // snapshot, upload the feedback bundle with any buffered errors, then
105
+ // kick `/feedback/{id}/fix` to create the repair task.
106
+ const handleScreenshotAndFix = (0, react_1.useCallback)(async () => {
107
+ const client = YaverFeedback_1.YaverFeedback.getP2PClient();
108
+ const config = YaverFeedback_1.YaverFeedback.getConfig();
109
+ if (!client || !config?.agentUrl) {
110
+ setError('Not connected to the agent yet.');
111
+ return;
112
+ }
113
+ setAction('capturing');
114
+ setError(null);
115
+ // Step 1: Hide the modal so the screenshot contains the real screen.
116
+ setVisible(false);
117
+ // Wait out the slide-down animation on both platforms.
118
+ await new Promise((resolve) => setTimeout(resolve, 350));
119
+ let path = null;
120
+ try {
121
+ path = await (0, capture_1.captureScreenshot)();
122
+ }
123
+ catch (err) {
124
+ setVisible(true);
125
+ setError(err instanceof Error ? err.message : String(err));
126
+ setAction('idle');
127
+ return;
128
+ }
129
+ // Step 2: Re-show the modal for progress + ack.
130
+ setVisible(true);
131
+ await new Promise((resolve) => setTimeout(resolve, 150));
132
+ try {
133
+ const { Dimensions } = require('react-native');
134
+ const { width, height } = Dimensions.get('window');
135
+ const deviceInfo = {
136
+ platform: react_native_1.Platform.OS,
137
+ osVersion: String(react_native_1.Platform.Version),
138
+ model: react_native_1.Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
139
+ screenWidth: width,
140
+ screenHeight: height,
141
+ };
142
+ const capturedErrors = YaverFeedback_1.YaverFeedback.getCapturedErrors();
143
+ const bundle = {
144
+ metadata: {
145
+ timestamp: new Date().toISOString(),
146
+ device: deviceInfo,
147
+ app: {},
148
+ userNote: '[Screenshot + Fix]',
149
+ },
150
+ screenshots: [path],
151
+ errors: capturedErrors.length > 0 ? capturedErrors : undefined,
152
+ };
153
+ const uploaded = await (0, upload_1.uploadFeedback)(config.agentUrl, config.authToken ?? '', bundle);
154
+ // The agent returns the new report id as `id` (see
155
+ // feedback_http.go::ReceiveFeedback). Trigger the fix loop if we got
156
+ // one back; otherwise just ack the upload.
157
+ const reportId = uploaded?.id ??
158
+ uploaded?.reportId;
159
+ if (reportId) {
160
+ try {
161
+ await client.triggerFix(reportId);
162
+ setToast('Fix task started');
176
163
  }
177
- }
178
- catch (err) {
179
- if (mountedRef.current) {
180
- setError(String(err));
164
+ catch (err) {
165
+ setToast('Report uploaded — fix trigger failed');
166
+ setError(err instanceof Error ? err.message : String(err));
181
167
  }
182
168
  }
169
+ else {
170
+ setToast('Report uploaded');
171
+ }
172
+ closeSoon(1400);
173
+ }
174
+ catch (err) {
175
+ setError(err instanceof Error ? err.message : String(err));
176
+ }
177
+ finally {
178
+ if (mountedRef.current)
179
+ setAction('idle');
183
180
  }
184
- }, [isRecordingAudio, mode]);
185
- const handleVoiceCommand = (0, react_1.useCallback)(async () => {
186
- if (isVoiceCommand) {
187
- // Stop voice command and send as a task
181
+ }, [closeSoon]);
182
+ // ─── 3. Vibing ─────────────────────────────────────────────────────
183
+ const handleVibing = (0, react_1.useCallback)(async () => {
184
+ const client = YaverFeedback_1.YaverFeedback.getP2PClient();
185
+ if (!client) {
186
+ setError('Not connected to the agent yet.');
187
+ return;
188
+ }
189
+ setAction('vibing');
190
+ setError(null);
191
+ try {
192
+ const capturedErrors = YaverFeedback_1.YaverFeedback.getCapturedErrors();
193
+ const errNote = capturedErrors.length > 0
194
+ ? `\n\nRecent captured errors:\n` +
195
+ capturedErrors
196
+ .slice(-3)
197
+ .map((e) => `- ${e.message}`)
198
+ .join('\n')
199
+ : '';
200
+ const prompt = 'The user opened the feedback modal on their phone and tapped Vibing. ' +
201
+ 'Investigate whatever they are likely to be asking about — pick the ' +
202
+ 'next small improvement or fix based on recent activity and the ' +
203
+ 'current screen.' +
204
+ errNote;
205
+ await client.vibing(prompt);
206
+ setToast('Vibing task created');
207
+ closeSoon(1200);
208
+ }
209
+ catch (err) {
210
+ setError(err instanceof Error ? err.message : String(err));
211
+ }
212
+ finally {
213
+ if (mountedRef.current)
214
+ setAction('idle');
215
+ }
216
+ }, [closeSoon]);
217
+ // ─── 4. Toggle screen recording ────────────────────────────────────
218
+ const handleToggleRecording = (0, react_1.useCallback)(async () => {
219
+ setError(null);
220
+ if (isRecordingVideo) {
188
221
  try {
189
- const result = await (0, capture_1.stopAudioRecording)();
222
+ const result = await (0, capture_1.stopVideoRecording)();
190
223
  if (mountedRef.current) {
191
- setIsVoiceCommand(false);
192
- const client = YaverFeedback_1.YaverFeedback.getP2PClient();
193
- if (client) {
194
- try {
195
- await client.streamFeedback((async function* () {
196
- yield {
197
- type: 'voice_command',
198
- timestamp: new Date().toISOString(),
199
- data: { path: result.path, duration: result.duration },
200
- };
201
- })());
202
- }
203
- catch (err) {
204
- console.warn('[YaverFeedback] Voice command send failed:', err);
205
- if (mountedRef.current) {
206
- setError('Failed to send voice command.');
207
- }
208
- }
209
- }
224
+ setIsRecordingVideo(false);
225
+ setLastVideo(result);
226
+ setToast(`Recording stopped — ${Math.round(result.duration)}s`);
210
227
  }
211
228
  }
212
229
  catch (err) {
213
- if (mountedRef.current) {
214
- setIsVoiceCommand(false);
215
- setError(String(err));
216
- }
230
+ setIsRecordingVideo(false);
231
+ setError(err instanceof Error ? err.message : String(err));
217
232
  }
218
233
  }
219
234
  else {
220
235
  try {
221
- await (0, capture_1.startAudioRecording)();
236
+ await (0, capture_1.startVideoRecording)();
222
237
  if (mountedRef.current) {
223
- setIsVoiceCommand(true);
238
+ setIsRecordingVideo(true);
239
+ setToast('Recording…');
240
+ setLastVideo(null);
224
241
  }
225
242
  }
226
243
  catch (err) {
227
- if (mountedRef.current) {
228
- setError(String(err));
229
- }
244
+ setError(err instanceof Error ? err.message : String(err));
230
245
  }
231
246
  }
232
- }, [isVoiceCommand]);
233
- const handleReload = (0, react_1.useCallback)(async () => {
247
+ }, [isRecordingVideo]);
248
+ // ─── 5. Send the last recorded video ───────────────────────────────
249
+ const handleSendVideo = (0, react_1.useCallback)(async () => {
234
250
  const config = YaverFeedback_1.YaverFeedback.getConfig();
235
- if (!config?.agentUrl)
251
+ if (!config?.agentUrl) {
252
+ setError('Not connected to the agent yet.');
236
253
  return;
237
- setIsReloading(true);
238
- try {
239
- const response = await fetch(`${config.agentUrl.replace(/\/$/, '')}/dev/reload-app`, {
240
- method: 'POST',
241
- headers: {
242
- Authorization: `Bearer ${config.authToken}`,
243
- 'Content-Type': 'application/json',
244
- },
245
- body: JSON.stringify({ mode: 'dev' }),
246
- });
247
- if (response.ok) {
248
- BlackBox_1.BlackBox.lifecycle('Hot reload triggered from feedback SDK');
249
- }
250
- }
251
- catch (err) {
252
- if (mountedRef.current) {
253
- setError('Reload failed: ' + String(err));
254
- }
255
254
  }
256
- finally {
257
- if (mountedRef.current) {
258
- setIsReloading(false);
259
- }
260
- }
261
- }, []);
262
- const handleSend = (0, react_1.useCallback)(async () => {
263
- const config = YaverFeedback_1.YaverFeedback.getConfig();
264
- if (!config || !config.agentUrl)
255
+ if (!lastVideo) {
256
+ setError('No video recorded yet.');
265
257
  return;
266
- setIsSending(true);
258
+ }
259
+ setAction('sending-video');
267
260
  setError(null);
268
261
  try {
269
262
  const { Dimensions } = require('react-native');
@@ -275,349 +268,148 @@ const FeedbackModal = () => {
275
268
  screenWidth: width,
276
269
  screenHeight: height,
277
270
  };
278
- const screenshots = timeline
279
- .filter((e) => e.type === 'screenshot')
280
- .map((e) => e.path);
281
- const audioEvent = timeline.find((e) => e.type === 'audio');
282
- // Include captured errors from the error buffer
283
- const capturedErrors = YaverFeedback_1.YaverFeedback.getCapturedErrors();
284
271
  const bundle = {
285
272
  metadata: {
286
273
  timestamp: new Date().toISOString(),
287
274
  device: deviceInfo,
288
275
  app: {},
276
+ userNote: '[Screen recording]',
289
277
  },
290
- screenshots,
291
- audio: audioEvent?.path,
292
- errors: capturedErrors.length > 0 ? capturedErrors : undefined,
278
+ screenshots: [],
279
+ video: lastVideo.path,
280
+ errors: YaverFeedback_1.YaverFeedback.getCapturedErrors().length
281
+ ? YaverFeedback_1.YaverFeedback.getCapturedErrors()
282
+ : undefined,
293
283
  };
294
- await (0, upload_1.uploadFeedback)(config.agentUrl, config.authToken, bundle);
295
- if (mountedRef.current) {
296
- setSent(true);
297
- // Auto-close after a short delay
298
- setTimeout(() => {
299
- if (mountedRef.current) {
300
- setVisible(false);
301
- }
302
- }, 1500);
303
- }
284
+ await (0, upload_1.uploadFeedback)(config.agentUrl, config.authToken ?? '', bundle);
285
+ setToast('Video sent');
286
+ setLastVideo(null);
287
+ closeSoon(1200);
304
288
  }
305
289
  catch (err) {
306
- if (mountedRef.current) {
307
- setError(String(err));
308
- }
290
+ setError(err instanceof Error ? err.message : String(err));
309
291
  }
310
292
  finally {
311
- if (mountedRef.current) {
312
- setIsSending(false);
313
- }
293
+ if (mountedRef.current)
294
+ setAction('idle');
314
295
  }
315
- }, [timeline]);
316
- const handleCancel = (0, react_1.useCallback)(() => {
317
- setVisible(false);
318
- setTimeline([]);
319
- setError(null);
320
- setSent(false);
321
- setIsRecordingAudio(false);
322
- setIsVoiceCommand(false);
323
- setCommentary([]);
324
- }, []);
325
- const renderCommentaryItem = (0, react_1.useCallback)(({ item }) => (<react_native_1.View style={styles.commentaryBubble}>
326
- <react_native_1.Text style={styles.commentaryType}>{item.type}</react_native_1.Text>
327
- <react_native_1.Text style={styles.commentaryMessage}>{item.message}</react_native_1.Text>
328
- </react_native_1.View>), []);
329
- // The AuthOverlay must stay mounted so it can respond to login / picker
330
- // events even when the feedback modal itself isn't visible. The wrapping
331
- // fragment keeps the original return shape intact for the modal branch.
296
+ }, [lastVideo, closeSoon]);
297
+ const busy = action !== 'idle';
332
298
  return (<>
333
299
  <AuthOverlay_1.AuthOverlay />
334
- {visible && (<react_native_1.Modal visible={visible} animationType="slide" transparent onRequestClose={handleCancel}>
335
- <react_native_1.View style={styles.overlay}>
336
- <react_native_1.View style={styles.modal}>
337
- <react_native_1.Text style={styles.title}>Send Feedback</react_native_1.Text>
338
-
339
- {/* Mode selector */}
340
- <react_native_1.View style={styles.modeSelector}>
341
- {['live', 'narrated', 'batch'].map((m) => (<react_native_1.TouchableOpacity key={m} style={[styles.modeButton, mode === m && styles.modeButtonActive]} onPress={() => setMode(m)}>
342
- <react_native_1.Text style={[styles.modeButtonText, mode === m && styles.modeButtonTextActive]}>
343
- {MODE_LABELS[m]}
344
- </react_native_1.Text>
345
- </react_native_1.TouchableOpacity>))}
346
- </react_native_1.View>
347
-
348
- {/* Agent commentary (chat-like view) */}
349
- {commentary.length > 0 && (<react_native_1.FlatList ref={commentaryListRef} data={commentary} renderItem={renderCommentaryItem} keyExtractor={(item) => item.id} style={styles.commentaryList} onContentSizeChange={() => commentaryListRef.current?.scrollToEnd({ animated: true })}/>)}
300
+ {visible && (<react_native_1.Modal visible={visible} animationType="slide" transparent onRequestClose={handleClose}>
301
+ <react_native_1.Pressable style={styles.overlay} onPress={handleClose}>
302
+ <react_native_1.Pressable style={styles.modal} onPress={(e) => e.stopPropagation()}>
303
+ <react_native_1.View style={styles.header}>
304
+ <react_native_1.Text style={styles.title}>Send Feedback</react_native_1.Text>
305
+ <react_native_1.Pressable onPress={handleClose} hitSlop={12} style={styles.closeBtn} accessibilityRole="button" accessibilityLabel="Close">
306
+ <react_native_1.Text style={styles.closeIcon}>×</react_native_1.Text>
307
+ </react_native_1.Pressable>
308
+ </react_native_1.View>
350
309
 
351
- {/* Timeline of captured items */}
352
- {timeline.length > 0 && (<react_native_1.ScrollView style={styles.timeline} horizontal>
353
- {timeline.map((event, idx) => (<react_native_1.View key={idx} style={styles.timelineItem}>
354
- <react_native_1.Text style={styles.timelineIcon}>
355
- {event.type === 'screenshot'
356
- ? '[img]'
357
- : event.type === 'audio'
358
- ? '[mic]'
359
- : '[vid]'}
360
- </react_native_1.Text>
361
- <react_native_1.Text style={styles.timelineLabel}>{event.type}</react_native_1.Text>
362
- {event.duration != null && (<react_native_1.Text style={styles.timelineDuration}>
363
- {Math.round(event.duration)}s
364
- </react_native_1.Text>)}
365
- </react_native_1.View>))}
366
- </react_native_1.ScrollView>)}
310
+ {/* 1. Hot Reload the common path */}
311
+ <ActionRow label={action === 'hot-reloading' ? 'Reloading…' : 'Hot Reload'} tint="#fbbf24" onPress={handleHotReload} disabled={busy} busy={action === 'hot-reloading'}/>
367
312
 
368
- {/* Action buttons */}
369
- <react_native_1.View style={styles.actions}>
370
- <react_native_1.TouchableOpacity style={styles.actionButton} onPress={handleScreenshot}>
371
- <react_native_1.Text style={styles.actionText}>Take Screenshot</react_native_1.Text>
372
- </react_native_1.TouchableOpacity>
313
+ {/* 2. Screenshot + Fix — for bug fixes */}
314
+ <ActionRow label={action === 'capturing'
315
+ ? 'Capturing…'
316
+ : 'Screenshot & Fix'} tint="#22c55e" onPress={handleScreenshotAndFix} disabled={busy} busy={action === 'capturing'}/>
373
317
 
374
- <react_native_1.TouchableOpacity style={[styles.actionButton, isRecordingAudio && styles.actionButtonActive]} onPress={handleToggleAudio} disabled={isVoiceCommand}>
375
- <react_native_1.Text style={styles.actionText}>
376
- {isRecordingAudio ? 'Stop Recording' : 'Voice Note'}
377
- </react_native_1.Text>
378
- </react_native_1.TouchableOpacity>
379
- </react_native_1.View>
318
+ {/* 3. Vibing */}
319
+ <ActionRow label={action === 'vibing' ? 'Starting…' : 'Vibing'} tint="#818cf8" onPress={handleVibing} disabled={busy} busy={action === 'vibing'}/>
380
320
 
381
- {/* Hot Reload + Streaming status */}
382
- <react_native_1.View style={styles.actions}>
383
- <react_native_1.TouchableOpacity style={[styles.actionButton, styles.reloadButton]} onPress={handleReload} disabled={isReloading}>
384
- <react_native_1.Text style={styles.actionText}>
385
- {isReloading ? 'Reloading...' : 'Hot Reload'}
386
- </react_native_1.Text>
387
- </react_native_1.TouchableOpacity>
321
+ {/* 4. Start/Stop Recording */}
322
+ <ActionRow label={isRecordingVideo ? 'Stop Recording' : 'Start Recording'} tint={isRecordingVideo ? '#ef4444' : '#60a5fa'} onPress={handleToggleRecording} disabled={busy && action !== 'idle' && !isRecordingVideo}/>
388
323
 
389
- <react_native_1.View style={[styles.actionButton, styles.streamingIndicator]}>
390
- <react_native_1.View style={[styles.streamingDot, BlackBox_1.BlackBox.isStreaming && styles.streamingDotActive]}/>
391
- <react_native_1.Text style={styles.streamingText}>
392
- {BlackBox_1.BlackBox.isStreaming ? 'Streaming' : 'Not streaming'}
393
- </react_native_1.Text>
394
- </react_native_1.View>
395
- </react_native_1.View>
324
+ {/* 5. Send Video (only tappable when a clip is ready) */}
325
+ <ActionRow label={action === 'sending-video'
326
+ ? 'Sending…'
327
+ : lastVideo
328
+ ? `Send Video · ${Math.round(lastVideo.duration)}s`
329
+ : 'Send Video'} tint="#a78bfa" onPress={handleSendVideo} disabled={busy || !lastVideo} busy={action === 'sending-video'}/>
396
330
 
397
- {/* Voice command button */}
398
- {mode === 'live' && (<react_native_1.TouchableOpacity style={[styles.voiceCommandButton, isVoiceCommand && styles.voiceCommandActive]} onPress={handleVoiceCommand} disabled={isRecordingAudio}>
399
- <react_native_1.Text style={styles.voiceCommandText}>
400
- {isVoiceCommand ? 'Stop & Send Command' : 'Speak to Fix'}
401
- </react_native_1.Text>
402
- </react_native_1.TouchableOpacity>)}
403
-
404
- {/* Error display */}
405
- {error && <react_native_1.Text style={styles.error}>{error}</react_native_1.Text>}
406
-
407
- {/* Send / Cancel */}
408
- <react_native_1.View style={styles.footer}>
409
- <react_native_1.TouchableOpacity style={styles.cancelButton} onPress={handleCancel}>
410
- <react_native_1.Text style={styles.cancelText}>Cancel</react_native_1.Text>
411
- </react_native_1.TouchableOpacity>
412
-
413
- {sent ? (<react_native_1.View style={styles.sendButton}>
414
- <react_native_1.Text style={styles.sendText}>Sent!</react_native_1.Text>
415
- </react_native_1.View>) : (<react_native_1.TouchableOpacity style={[styles.sendButton, isSending && styles.sendButtonDisabled]} onPress={handleSend} disabled={isSending || timeline.length === 0}>
416
- {isSending ? (<react_native_1.ActivityIndicator color="#fff" size="small"/>) : (<react_native_1.Text style={styles.sendText}>Send Report</react_native_1.Text>)}
417
- </react_native_1.TouchableOpacity>)}
418
- </react_native_1.View>
419
- </react_native_1.View>
420
- </react_native_1.View>
421
- </react_native_1.Modal>)}
331
+ {toast && <react_native_1.Text style={styles.toast}>{toast}</react_native_1.Text>}
332
+ {error && <react_native_1.Text style={styles.error}>{error}</react_native_1.Text>}
333
+ </react_native_1.Pressable>
334
+ </react_native_1.Pressable>
335
+ </react_native_1.Modal>)}
422
336
  </>);
423
337
  };
424
338
  exports.FeedbackModal = FeedbackModal;
339
+ const ActionRow = ({ label, tint, onPress, disabled, busy, }) => (<react_native_1.Pressable onPress={onPress} disabled={disabled} style={({ pressed }) => [
340
+ styles.actionBtn,
341
+ {
342
+ borderColor: tint + '66',
343
+ backgroundColor: tint + '1f',
344
+ },
345
+ disabled && styles.actionBtnDisabled,
346
+ pressed && !disabled && { opacity: 0.7 },
347
+ ]} accessibilityRole="button" accessibilityLabel={label}>
348
+ {busy ? (<react_native_1.ActivityIndicator color={tint} size="small"/>) : (<react_native_1.Text style={[styles.actionText, { color: tint }]}>{label}</react_native_1.Text>)}
349
+ </react_native_1.Pressable>);
425
350
  const styles = react_native_1.StyleSheet.create({
426
351
  overlay: {
427
352
  flex: 1,
428
- backgroundColor: 'rgba(0,0,0,0.5)',
353
+ backgroundColor: 'rgba(0,0,0,0.55)',
429
354
  justifyContent: 'flex-end',
430
355
  },
431
356
  modal: {
432
- backgroundColor: '#1a1a2e',
433
- borderTopLeftRadius: 20,
434
- borderTopRightRadius: 20,
435
- padding: 24,
436
- paddingBottom: 40,
437
- maxHeight: '90%',
357
+ backgroundColor: '#141422',
358
+ borderTopLeftRadius: 22,
359
+ borderTopRightRadius: 22,
360
+ padding: 22,
361
+ paddingBottom: 36,
362
+ gap: 12,
363
+ },
364
+ header: {
365
+ flexDirection: 'row',
366
+ alignItems: 'center',
367
+ justifyContent: 'space-between',
368
+ marginBottom: 6,
438
369
  },
439
370
  title: {
440
371
  fontSize: 20,
441
372
  fontWeight: '700',
442
373
  color: '#fff',
443
- marginBottom: 12,
444
374
  },
445
- modeSelector: {
446
- flexDirection: 'row',
447
- gap: 8,
448
- marginBottom: 16,
449
- },
450
- modeButton: {
451
- flex: 1,
452
- paddingVertical: 8,
453
- borderRadius: 8,
375
+ closeBtn: {
376
+ width: 36,
377
+ height: 36,
378
+ borderRadius: 18,
454
379
  alignItems: 'center',
380
+ justifyContent: 'center',
455
381
  backgroundColor: 'rgba(255,255,255,0.08)',
456
- borderWidth: 1,
457
- borderColor: 'rgba(255,255,255,0.1)',
458
- },
459
- modeButtonActive: {
460
- backgroundColor: 'rgba(99,102,241,0.3)',
461
- borderColor: '#6366f1',
462
- },
463
- modeButtonText: {
464
- color: '#999',
465
- fontSize: 13,
466
- fontWeight: '600',
467
382
  },
468
- modeButtonTextActive: {
469
- color: '#c7c8ff',
470
- },
471
- commentaryList: {
472
- maxHeight: 140,
473
- marginBottom: 12,
474
- },
475
- commentaryBubble: {
476
- backgroundColor: 'rgba(99,102,241,0.15)',
477
- borderRadius: 10,
478
- padding: 10,
479
- marginBottom: 6,
480
- borderLeftWidth: 3,
481
- borderLeftColor: '#6366f1',
482
- },
483
- commentaryType: {
484
- color: '#8b8bf5',
485
- fontSize: 10,
486
- fontWeight: '700',
487
- textTransform: 'uppercase',
488
- marginBottom: 2,
489
- },
490
- commentaryMessage: {
491
- color: '#d0d0e0',
492
- fontSize: 13,
493
- lineHeight: 18,
494
- },
495
- timeline: {
496
- maxHeight: 80,
497
- marginBottom: 16,
383
+ closeIcon: {
384
+ color: '#fff',
385
+ fontSize: 22,
386
+ lineHeight: 24,
387
+ fontWeight: '400',
498
388
  },
499
- timelineItem: {
389
+ actionBtn: {
390
+ paddingVertical: 16,
391
+ borderRadius: 14,
500
392
  alignItems: 'center',
501
- marginRight: 16,
502
- backgroundColor: 'rgba(255,255,255,0.1)',
503
- borderRadius: 12,
504
- padding: 10,
505
- minWidth: 70,
506
- },
507
- timelineIcon: {
508
- fontSize: 14,
509
- color: '#ccc',
510
- fontWeight: '600',
511
- },
512
- timelineLabel: {
513
- color: '#ccc',
514
- fontSize: 11,
515
- marginTop: 4,
516
- },
517
- timelineDuration: {
518
- color: '#999',
519
- fontSize: 10,
520
- },
521
- actions: {
522
- flexDirection: 'row',
523
- gap: 12,
524
- marginBottom: 12,
525
- },
526
- actionButton: {
527
- flex: 1,
528
- backgroundColor: 'rgba(99,102,241,0.2)',
393
+ justifyContent: 'center',
529
394
  borderWidth: 1,
530
- borderColor: 'rgba(99,102,241,0.4)',
531
- borderRadius: 12,
532
- paddingVertical: 14,
533
- alignItems: 'center',
534
395
  },
535
- actionButtonActive: {
536
- backgroundColor: 'rgba(239,68,68,0.3)',
537
- borderColor: 'rgba(239,68,68,0.6)',
396
+ actionBtnDisabled: {
397
+ opacity: 0.35,
538
398
  },
539
399
  actionText: {
540
- color: '#fff',
541
- fontSize: 14,
542
- fontWeight: '600',
543
- },
544
- voiceCommandButton: {
545
- backgroundColor: 'rgba(34,197,94,0.2)',
546
- borderWidth: 1,
547
- borderColor: 'rgba(34,197,94,0.4)',
548
- borderRadius: 12,
549
- paddingVertical: 14,
550
- alignItems: 'center',
551
- marginBottom: 12,
552
- },
553
- voiceCommandActive: {
554
- backgroundColor: 'rgba(34,197,94,0.4)',
555
- borderColor: '#22c55e',
400
+ fontSize: 15,
401
+ fontWeight: '700',
556
402
  },
557
- voiceCommandText: {
403
+ toast: {
558
404
  color: '#22c55e',
559
- fontSize: 14,
560
- fontWeight: '600',
405
+ fontSize: 13,
406
+ textAlign: 'center',
407
+ marginTop: 4,
561
408
  },
562
409
  error: {
563
410
  color: '#ef4444',
564
411
  fontSize: 12,
565
- marginBottom: 12,
566
- },
567
- footer: {
568
- flexDirection: 'row',
569
- gap: 12,
570
- },
571
- cancelButton: {
572
- flex: 1,
573
- paddingVertical: 14,
574
- alignItems: 'center',
575
- borderRadius: 12,
576
- borderWidth: 1,
577
- borderColor: 'rgba(255,255,255,0.2)',
578
- },
579
- cancelText: {
580
- color: '#999',
581
- fontSize: 16,
582
- fontWeight: '600',
583
- },
584
- sendButton: {
585
- flex: 2,
586
- backgroundColor: '#6366f1',
587
- paddingVertical: 14,
588
- alignItems: 'center',
589
- borderRadius: 12,
590
- },
591
- sendButtonDisabled: {
592
- opacity: 0.5,
593
- },
594
- sendText: {
595
- color: '#fff',
596
- fontSize: 16,
597
- fontWeight: '700',
598
- },
599
- reloadButton: {
600
- backgroundColor: 'rgba(251,191,36,0.2)',
601
- borderColor: 'rgba(251,191,36,0.4)',
602
- },
603
- streamingIndicator: {
604
- flexDirection: 'row',
605
- justifyContent: 'center',
606
- backgroundColor: 'rgba(255,255,255,0.05)',
607
- borderColor: 'rgba(255,255,255,0.1)',
608
- },
609
- streamingDot: {
610
- width: 8,
611
- height: 8,
612
- borderRadius: 4,
613
- backgroundColor: '#555',
614
- marginRight: 6,
615
- },
616
- streamingDotActive: {
617
- backgroundColor: '#22c55e',
618
- },
619
- streamingText: {
620
- color: '#999',
621
- fontSize: 12,
412
+ textAlign: 'center',
413
+ marginTop: 4,
622
414
  },
623
415
  });