yaver-feedback-react-native 0.2.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.
@@ -0,0 +1,678 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ DeviceEventEmitter,
5
+ FlatList,
6
+ Modal,
7
+ Platform,
8
+ ScrollView,
9
+ StyleSheet,
10
+ Text,
11
+ TouchableOpacity,
12
+ View,
13
+ } from 'react-native';
14
+ import { YaverFeedback } from './YaverFeedback';
15
+ import { BlackBox } from './BlackBox';
16
+ import { captureScreenshot, startAudioRecording, stopAudioRecording } from './capture';
17
+ import { uploadFeedback } from './upload';
18
+ import { TimelineEvent, DeviceInfo, FeedbackBundle, AgentCommentary } from './types';
19
+
20
+ type FeedbackMode = 'live' | 'narrated' | 'batch';
21
+
22
+ const MODE_LABELS: Record<FeedbackMode, string> = {
23
+ live: 'Live',
24
+ narrated: 'Narrated',
25
+ batch: 'Batch',
26
+ };
27
+
28
+ /**
29
+ * Full-screen modal for composing and sending a feedback report.
30
+ * Renders when triggered by shake, floating button, or manual call.
31
+ *
32
+ * Supports three feedback modes:
33
+ * - Live: stream events to the agent as they happen
34
+ * - Narrated: record everything, send on stop
35
+ * - Batch: dump everything at end (default)
36
+ */
37
+ export const FeedbackModal: React.FC = () => {
38
+ const [visible, setVisible] = useState(false);
39
+ const [timeline, setTimeline] = useState<TimelineEvent[]>([]);
40
+ const [isRecordingAudio, setIsRecordingAudio] = useState(false);
41
+ const [isSending, setIsSending] = useState(false);
42
+ const [error, setError] = useState<string | null>(null);
43
+ const [sent, setSent] = useState(false);
44
+ const [mode, setMode] = useState<FeedbackMode>('batch');
45
+ const [commentary, setCommentary] = useState<AgentCommentary[]>([]);
46
+ const [isVoiceCommand, setIsVoiceCommand] = useState(false);
47
+ const [isReloading, setIsReloading] = useState(false);
48
+ const mountedRef = useRef(true);
49
+ const commentaryListRef = useRef<FlatList>(null);
50
+
51
+ useEffect(() => {
52
+ mountedRef.current = true;
53
+ const sub = DeviceEventEmitter.addListener('yaverFeedback:startReport', () => {
54
+ if (YaverFeedback.isEnabled()) {
55
+ setVisible(true);
56
+ setTimeline([]);
57
+ setError(null);
58
+ setSent(false);
59
+ setCommentary([]);
60
+ setMode(YaverFeedback.getFeedbackMode());
61
+ }
62
+ });
63
+
64
+ // Listen for agent commentary events
65
+ const commentarySub = DeviceEventEmitter.addListener(
66
+ 'yaverFeedback:commentary',
67
+ (event: AgentCommentary) => {
68
+ if (mountedRef.current) {
69
+ setCommentary((prev) => [...prev, event]);
70
+ }
71
+ },
72
+ );
73
+
74
+ return () => {
75
+ mountedRef.current = false;
76
+ sub.remove();
77
+ commentarySub.remove();
78
+ };
79
+ }, []);
80
+
81
+ const handleScreenshot = useCallback(async () => {
82
+ try {
83
+ const path = await captureScreenshot();
84
+ if (mountedRef.current) {
85
+ const event: TimelineEvent = {
86
+ type: 'screenshot',
87
+ path,
88
+ timestamp: new Date().toISOString(),
89
+ };
90
+ setTimeline((prev) => [...prev, event]);
91
+
92
+ // In live mode, stream the event immediately
93
+ if (mode === 'live') {
94
+ const client = YaverFeedback.getP2PClient();
95
+ if (client) {
96
+ try {
97
+ await client.streamFeedback(
98
+ (async function* () {
99
+ yield {
100
+ type: 'screenshot',
101
+ timestamp: event.timestamp,
102
+ data: { path },
103
+ };
104
+ })(),
105
+ );
106
+ } catch (err) {
107
+ console.warn('[YaverFeedback] Live stream failed:', err);
108
+ }
109
+ }
110
+ }
111
+ }
112
+ } catch (err) {
113
+ if (mountedRef.current) {
114
+ setError(String(err));
115
+ }
116
+ }
117
+ }, [mode]);
118
+
119
+ const handleToggleAudio = useCallback(async () => {
120
+ if (isRecordingAudio) {
121
+ try {
122
+ const result = await stopAudioRecording();
123
+ if (mountedRef.current) {
124
+ setIsRecordingAudio(false);
125
+ const event: TimelineEvent = {
126
+ type: 'audio',
127
+ path: result.path,
128
+ timestamp: new Date().toISOString(),
129
+ duration: result.duration,
130
+ };
131
+ setTimeline((prev) => [...prev, event]);
132
+
133
+ // In live mode, stream the audio event
134
+ if (mode === 'live') {
135
+ const client = YaverFeedback.getP2PClient();
136
+ if (client) {
137
+ try {
138
+ await client.streamFeedback(
139
+ (async function* () {
140
+ yield {
141
+ type: 'audio',
142
+ timestamp: event.timestamp,
143
+ data: { path: result.path, duration: result.duration },
144
+ };
145
+ })(),
146
+ );
147
+ } catch (err) {
148
+ console.warn('[YaverFeedback] Live stream failed:', err);
149
+ }
150
+ }
151
+ }
152
+ }
153
+ } catch (err) {
154
+ if (mountedRef.current) {
155
+ setIsRecordingAudio(false);
156
+ setError(String(err));
157
+ }
158
+ }
159
+ } else {
160
+ try {
161
+ await startAudioRecording();
162
+ if (mountedRef.current) {
163
+ setIsRecordingAudio(true);
164
+ }
165
+ } catch (err) {
166
+ if (mountedRef.current) {
167
+ setError(String(err));
168
+ }
169
+ }
170
+ }
171
+ }, [isRecordingAudio, mode]);
172
+
173
+ const handleVoiceCommand = useCallback(async () => {
174
+ if (isVoiceCommand) {
175
+ // Stop voice command and send as a task
176
+ try {
177
+ const result = await stopAudioRecording();
178
+ if (mountedRef.current) {
179
+ setIsVoiceCommand(false);
180
+
181
+ const client = YaverFeedback.getP2PClient();
182
+ if (client) {
183
+ try {
184
+ await client.streamFeedback(
185
+ (async function* () {
186
+ yield {
187
+ type: 'voice_command',
188
+ timestamp: new Date().toISOString(),
189
+ data: { path: result.path, duration: result.duration },
190
+ };
191
+ })(),
192
+ );
193
+ } catch (err) {
194
+ console.warn('[YaverFeedback] Voice command send failed:', err);
195
+ if (mountedRef.current) {
196
+ setError('Failed to send voice command.');
197
+ }
198
+ }
199
+ }
200
+ }
201
+ } catch (err) {
202
+ if (mountedRef.current) {
203
+ setIsVoiceCommand(false);
204
+ setError(String(err));
205
+ }
206
+ }
207
+ } else {
208
+ try {
209
+ await startAudioRecording();
210
+ if (mountedRef.current) {
211
+ setIsVoiceCommand(true);
212
+ }
213
+ } catch (err) {
214
+ if (mountedRef.current) {
215
+ setError(String(err));
216
+ }
217
+ }
218
+ }
219
+ }, [isVoiceCommand]);
220
+
221
+ const handleReload = useCallback(async () => {
222
+ const config = YaverFeedback.getConfig();
223
+ if (!config?.agentUrl) return;
224
+
225
+ setIsReloading(true);
226
+ try {
227
+ const response = await fetch(`${config.agentUrl.replace(/\/$/, '')}/exec`, {
228
+ method: 'POST',
229
+ headers: {
230
+ Authorization: `Bearer ${config.authToken}`,
231
+ 'Content-Type': 'application/json',
232
+ },
233
+ body: JSON.stringify({ command: 'reload', type: 'hot-reload' }),
234
+ });
235
+ if (response.ok) {
236
+ BlackBox.lifecycle('Hot reload triggered from feedback SDK');
237
+ }
238
+ } catch (err) {
239
+ if (mountedRef.current) {
240
+ setError('Reload failed: ' + String(err));
241
+ }
242
+ } finally {
243
+ if (mountedRef.current) {
244
+ setIsReloading(false);
245
+ }
246
+ }
247
+ }, []);
248
+
249
+ const handleSend = useCallback(async () => {
250
+ const config = YaverFeedback.getConfig();
251
+ if (!config || !config.agentUrl) return;
252
+
253
+ setIsSending(true);
254
+ setError(null);
255
+
256
+ try {
257
+ const { Dimensions } = require('react-native');
258
+ const { width, height } = Dimensions.get('window');
259
+
260
+ const deviceInfo: DeviceInfo = {
261
+ platform: Platform.OS,
262
+ osVersion: String(Platform.Version),
263
+ model: Platform.OS === 'ios' ? 'iOS Device' : 'Android Device',
264
+ screenWidth: width,
265
+ screenHeight: height,
266
+ };
267
+
268
+ const screenshots = timeline
269
+ .filter((e) => e.type === 'screenshot')
270
+ .map((e) => e.path);
271
+
272
+ const audioEvent = timeline.find((e) => e.type === 'audio');
273
+
274
+ // Include captured errors from the error buffer
275
+ const capturedErrors = YaverFeedback.getCapturedErrors();
276
+
277
+ const bundle: FeedbackBundle = {
278
+ metadata: {
279
+ timestamp: new Date().toISOString(),
280
+ device: deviceInfo,
281
+ app: {},
282
+ },
283
+ screenshots,
284
+ audio: audioEvent?.path,
285
+ errors: capturedErrors.length > 0 ? capturedErrors : undefined,
286
+ };
287
+
288
+ await uploadFeedback(config.agentUrl, config.authToken, bundle);
289
+
290
+ if (mountedRef.current) {
291
+ setSent(true);
292
+ // Auto-close after a short delay
293
+ setTimeout(() => {
294
+ if (mountedRef.current) {
295
+ setVisible(false);
296
+ }
297
+ }, 1500);
298
+ }
299
+ } catch (err) {
300
+ if (mountedRef.current) {
301
+ setError(String(err));
302
+ }
303
+ } finally {
304
+ if (mountedRef.current) {
305
+ setIsSending(false);
306
+ }
307
+ }
308
+ }, [timeline]);
309
+
310
+ const handleCancel = useCallback(() => {
311
+ setVisible(false);
312
+ setTimeline([]);
313
+ setError(null);
314
+ setSent(false);
315
+ setIsRecordingAudio(false);
316
+ setIsVoiceCommand(false);
317
+ setCommentary([]);
318
+ }, []);
319
+
320
+ const renderCommentaryItem = useCallback(
321
+ ({ item }: { item: AgentCommentary }) => (
322
+ <View style={styles.commentaryBubble}>
323
+ <Text style={styles.commentaryType}>{item.type}</Text>
324
+ <Text style={styles.commentaryMessage}>{item.message}</Text>
325
+ </View>
326
+ ),
327
+ [],
328
+ );
329
+
330
+ if (!visible) return null;
331
+
332
+ return (
333
+ <Modal
334
+ visible={visible}
335
+ animationType="slide"
336
+ transparent
337
+ onRequestClose={handleCancel}
338
+ >
339
+ <View style={styles.overlay}>
340
+ <View style={styles.modal}>
341
+ <Text style={styles.title}>Send Feedback</Text>
342
+
343
+ {/* Mode selector */}
344
+ <View style={styles.modeSelector}>
345
+ {(['live', 'narrated', 'batch'] as FeedbackMode[]).map((m) => (
346
+ <TouchableOpacity
347
+ key={m}
348
+ style={[styles.modeButton, mode === m && styles.modeButtonActive]}
349
+ onPress={() => setMode(m)}
350
+ >
351
+ <Text
352
+ style={[styles.modeButtonText, mode === m && styles.modeButtonTextActive]}
353
+ >
354
+ {MODE_LABELS[m]}
355
+ </Text>
356
+ </TouchableOpacity>
357
+ ))}
358
+ </View>
359
+
360
+ {/* Agent commentary (chat-like view) */}
361
+ {commentary.length > 0 && (
362
+ <FlatList
363
+ ref={commentaryListRef}
364
+ data={commentary}
365
+ renderItem={renderCommentaryItem}
366
+ keyExtractor={(item) => item.id}
367
+ style={styles.commentaryList}
368
+ onContentSizeChange={() =>
369
+ commentaryListRef.current?.scrollToEnd({ animated: true })
370
+ }
371
+ />
372
+ )}
373
+
374
+ {/* Timeline of captured items */}
375
+ {timeline.length > 0 && (
376
+ <ScrollView style={styles.timeline} horizontal>
377
+ {timeline.map((event, idx) => (
378
+ <View key={idx} style={styles.timelineItem}>
379
+ <Text style={styles.timelineIcon}>
380
+ {event.type === 'screenshot'
381
+ ? '[img]'
382
+ : event.type === 'audio'
383
+ ? '[mic]'
384
+ : '[vid]'}
385
+ </Text>
386
+ <Text style={styles.timelineLabel}>{event.type}</Text>
387
+ {event.duration != null && (
388
+ <Text style={styles.timelineDuration}>
389
+ {Math.round(event.duration)}s
390
+ </Text>
391
+ )}
392
+ </View>
393
+ ))}
394
+ </ScrollView>
395
+ )}
396
+
397
+ {/* Action buttons */}
398
+ <View style={styles.actions}>
399
+ <TouchableOpacity style={styles.actionButton} onPress={handleScreenshot}>
400
+ <Text style={styles.actionText}>Take Screenshot</Text>
401
+ </TouchableOpacity>
402
+
403
+ <TouchableOpacity
404
+ style={[styles.actionButton, isRecordingAudio && styles.actionButtonActive]}
405
+ onPress={handleToggleAudio}
406
+ disabled={isVoiceCommand}
407
+ >
408
+ <Text style={styles.actionText}>
409
+ {isRecordingAudio ? 'Stop Recording' : 'Voice Note'}
410
+ </Text>
411
+ </TouchableOpacity>
412
+ </View>
413
+
414
+ {/* Hot Reload + Streaming status */}
415
+ <View style={styles.actions}>
416
+ <TouchableOpacity
417
+ style={[styles.actionButton, styles.reloadButton]}
418
+ onPress={handleReload}
419
+ disabled={isReloading}
420
+ >
421
+ <Text style={styles.actionText}>
422
+ {isReloading ? 'Reloading...' : 'Hot Reload'}
423
+ </Text>
424
+ </TouchableOpacity>
425
+
426
+ <View style={[styles.actionButton, styles.streamingIndicator]}>
427
+ <View style={[styles.streamingDot, BlackBox.isStreaming && styles.streamingDotActive]} />
428
+ <Text style={styles.streamingText}>
429
+ {BlackBox.isStreaming ? 'Streaming' : 'Not streaming'}
430
+ </Text>
431
+ </View>
432
+ </View>
433
+
434
+ {/* Voice command button */}
435
+ {mode === 'live' && (
436
+ <TouchableOpacity
437
+ style={[styles.voiceCommandButton, isVoiceCommand && styles.voiceCommandActive]}
438
+ onPress={handleVoiceCommand}
439
+ disabled={isRecordingAudio}
440
+ >
441
+ <Text style={styles.voiceCommandText}>
442
+ {isVoiceCommand ? 'Stop & Send Command' : 'Speak to Fix'}
443
+ </Text>
444
+ </TouchableOpacity>
445
+ )}
446
+
447
+ {/* Error display */}
448
+ {error && <Text style={styles.error}>{error}</Text>}
449
+
450
+ {/* Send / Cancel */}
451
+ <View style={styles.footer}>
452
+ <TouchableOpacity style={styles.cancelButton} onPress={handleCancel}>
453
+ <Text style={styles.cancelText}>Cancel</Text>
454
+ </TouchableOpacity>
455
+
456
+ {sent ? (
457
+ <View style={styles.sendButton}>
458
+ <Text style={styles.sendText}>Sent!</Text>
459
+ </View>
460
+ ) : (
461
+ <TouchableOpacity
462
+ style={[styles.sendButton, isSending && styles.sendButtonDisabled]}
463
+ onPress={handleSend}
464
+ disabled={isSending || timeline.length === 0}
465
+ >
466
+ {isSending ? (
467
+ <ActivityIndicator color="#fff" size="small" />
468
+ ) : (
469
+ <Text style={styles.sendText}>Send Report</Text>
470
+ )}
471
+ </TouchableOpacity>
472
+ )}
473
+ </View>
474
+ </View>
475
+ </View>
476
+ </Modal>
477
+ );
478
+ };
479
+
480
+ const styles = StyleSheet.create({
481
+ overlay: {
482
+ flex: 1,
483
+ backgroundColor: 'rgba(0,0,0,0.5)',
484
+ justifyContent: 'flex-end',
485
+ },
486
+ modal: {
487
+ backgroundColor: '#1a1a2e',
488
+ borderTopLeftRadius: 20,
489
+ borderTopRightRadius: 20,
490
+ padding: 24,
491
+ paddingBottom: 40,
492
+ maxHeight: '90%',
493
+ },
494
+ title: {
495
+ fontSize: 20,
496
+ fontWeight: '700',
497
+ color: '#fff',
498
+ marginBottom: 12,
499
+ },
500
+ modeSelector: {
501
+ flexDirection: 'row',
502
+ gap: 8,
503
+ marginBottom: 16,
504
+ },
505
+ modeButton: {
506
+ flex: 1,
507
+ paddingVertical: 8,
508
+ borderRadius: 8,
509
+ alignItems: 'center',
510
+ backgroundColor: 'rgba(255,255,255,0.08)',
511
+ borderWidth: 1,
512
+ borderColor: 'rgba(255,255,255,0.1)',
513
+ },
514
+ modeButtonActive: {
515
+ backgroundColor: 'rgba(99,102,241,0.3)',
516
+ borderColor: '#6366f1',
517
+ },
518
+ modeButtonText: {
519
+ color: '#999',
520
+ fontSize: 13,
521
+ fontWeight: '600',
522
+ },
523
+ modeButtonTextActive: {
524
+ color: '#c7c8ff',
525
+ },
526
+ commentaryList: {
527
+ maxHeight: 140,
528
+ marginBottom: 12,
529
+ },
530
+ commentaryBubble: {
531
+ backgroundColor: 'rgba(99,102,241,0.15)',
532
+ borderRadius: 10,
533
+ padding: 10,
534
+ marginBottom: 6,
535
+ borderLeftWidth: 3,
536
+ borderLeftColor: '#6366f1',
537
+ },
538
+ commentaryType: {
539
+ color: '#8b8bf5',
540
+ fontSize: 10,
541
+ fontWeight: '700',
542
+ textTransform: 'uppercase',
543
+ marginBottom: 2,
544
+ },
545
+ commentaryMessage: {
546
+ color: '#d0d0e0',
547
+ fontSize: 13,
548
+ lineHeight: 18,
549
+ },
550
+ timeline: {
551
+ maxHeight: 80,
552
+ marginBottom: 16,
553
+ },
554
+ timelineItem: {
555
+ alignItems: 'center',
556
+ marginRight: 16,
557
+ backgroundColor: 'rgba(255,255,255,0.1)',
558
+ borderRadius: 12,
559
+ padding: 10,
560
+ minWidth: 70,
561
+ },
562
+ timelineIcon: {
563
+ fontSize: 14,
564
+ color: '#ccc',
565
+ fontWeight: '600',
566
+ },
567
+ timelineLabel: {
568
+ color: '#ccc',
569
+ fontSize: 11,
570
+ marginTop: 4,
571
+ },
572
+ timelineDuration: {
573
+ color: '#999',
574
+ fontSize: 10,
575
+ },
576
+ actions: {
577
+ flexDirection: 'row',
578
+ gap: 12,
579
+ marginBottom: 12,
580
+ },
581
+ actionButton: {
582
+ flex: 1,
583
+ backgroundColor: 'rgba(99,102,241,0.2)',
584
+ borderWidth: 1,
585
+ borderColor: 'rgba(99,102,241,0.4)',
586
+ borderRadius: 12,
587
+ paddingVertical: 14,
588
+ alignItems: 'center',
589
+ },
590
+ actionButtonActive: {
591
+ backgroundColor: 'rgba(239,68,68,0.3)',
592
+ borderColor: 'rgba(239,68,68,0.6)',
593
+ },
594
+ actionText: {
595
+ color: '#fff',
596
+ fontSize: 14,
597
+ fontWeight: '600',
598
+ },
599
+ voiceCommandButton: {
600
+ backgroundColor: 'rgba(34,197,94,0.2)',
601
+ borderWidth: 1,
602
+ borderColor: 'rgba(34,197,94,0.4)',
603
+ borderRadius: 12,
604
+ paddingVertical: 14,
605
+ alignItems: 'center',
606
+ marginBottom: 12,
607
+ },
608
+ voiceCommandActive: {
609
+ backgroundColor: 'rgba(34,197,94,0.4)',
610
+ borderColor: '#22c55e',
611
+ },
612
+ voiceCommandText: {
613
+ color: '#22c55e',
614
+ fontSize: 14,
615
+ fontWeight: '600',
616
+ },
617
+ error: {
618
+ color: '#ef4444',
619
+ fontSize: 12,
620
+ marginBottom: 12,
621
+ },
622
+ footer: {
623
+ flexDirection: 'row',
624
+ gap: 12,
625
+ },
626
+ cancelButton: {
627
+ flex: 1,
628
+ paddingVertical: 14,
629
+ alignItems: 'center',
630
+ borderRadius: 12,
631
+ borderWidth: 1,
632
+ borderColor: 'rgba(255,255,255,0.2)',
633
+ },
634
+ cancelText: {
635
+ color: '#999',
636
+ fontSize: 16,
637
+ fontWeight: '600',
638
+ },
639
+ sendButton: {
640
+ flex: 2,
641
+ backgroundColor: '#6366f1',
642
+ paddingVertical: 14,
643
+ alignItems: 'center',
644
+ borderRadius: 12,
645
+ },
646
+ sendButtonDisabled: {
647
+ opacity: 0.5,
648
+ },
649
+ sendText: {
650
+ color: '#fff',
651
+ fontSize: 16,
652
+ fontWeight: '700',
653
+ },
654
+ reloadButton: {
655
+ backgroundColor: 'rgba(251,191,36,0.2)',
656
+ borderColor: 'rgba(251,191,36,0.4)',
657
+ },
658
+ streamingIndicator: {
659
+ flexDirection: 'row',
660
+ justifyContent: 'center',
661
+ backgroundColor: 'rgba(255,255,255,0.05)',
662
+ borderColor: 'rgba(255,255,255,0.1)',
663
+ },
664
+ streamingDot: {
665
+ width: 8,
666
+ height: 8,
667
+ borderRadius: 4,
668
+ backgroundColor: '#555',
669
+ marginRight: 6,
670
+ },
671
+ streamingDotActive: {
672
+ backgroundColor: '#22c55e',
673
+ },
674
+ streamingText: {
675
+ color: '#999',
676
+ fontSize: 12,
677
+ },
678
+ });