react-native-iinstall 0.1.0 → 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.
package/README.md CHANGED
@@ -54,10 +54,20 @@ export default App;
54
54
  Add to `AndroidManifest.xml`:
55
55
  ```xml
56
56
  <uses-permission android:name="android.permission.INTERNET" />
57
+ <!-- Required for Audio Feedback -->
58
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
59
+ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
60
+ <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
57
61
  ```
58
62
 
59
63
  ### iOS
60
- Add to `Info.plist` (if required by your specific usage, usually standard permissions suffice for screenshots/network).
64
+ Add to `Info.plist`:
65
+ ```xml
66
+ <key>NSMicrophoneUsageDescription</key>
67
+ <string>Allow access to microphone for audio feedback.</string>
68
+ <key>NSPhotoLibraryUsageDescription</key>
69
+ <string>Allow access to save screen recordings.</string>
70
+ ```
61
71
 
62
72
  ## Troubleshooting
63
73
 
@@ -3,6 +3,8 @@ interface FeedbackModalProps {
3
3
  visible: boolean;
4
4
  onClose: () => void;
5
5
  screenshotUri: string | null;
6
+ videoUri?: string | null;
7
+ onStartRecording?: () => void;
6
8
  apiKey: string;
7
9
  apiEndpoint: string;
8
10
  }
@@ -40,12 +40,70 @@ exports.FeedbackModal = void 0;
40
40
  const react_1 = __importStar(require("react"));
41
41
  const react_native_1 = require("react-native");
42
42
  const react_native_device_info_1 = __importDefault(require("react-native-device-info"));
43
- const FeedbackModal = ({ visible, onClose, screenshotUri, apiKey, apiEndpoint }) => {
43
+ const react_native_audio_recorder_player_1 = __importDefault(require("react-native-audio-recorder-player"));
44
+ const FeedbackModal = ({ visible, onClose, screenshotUri, videoUri, onStartRecording, apiKey, apiEndpoint }) => {
44
45
  const [description, setDescription] = (0, react_1.useState)('');
45
46
  const [isSending, setIsSending] = (0, react_1.useState)(false);
47
+ // Audio State
48
+ const [isRecordingAudio, setIsRecordingAudio] = (0, react_1.useState)(false);
49
+ const [audioUri, setAudioUri] = (0, react_1.useState)(null);
50
+ const [audioDuration, setAudioDuration] = (0, react_1.useState)('00:00');
51
+ const audioRecorderPlayer = (0, react_1.useRef)(new react_native_audio_recorder_player_1.default()).current;
52
+ (0, react_1.useEffect)(() => {
53
+ return () => {
54
+ // Cleanup
55
+ if (isRecordingAudio) {
56
+ audioRecorderPlayer.stopRecorder();
57
+ }
58
+ };
59
+ }, [isRecordingAudio]);
60
+ const onStartAudioRecord = async () => {
61
+ if (react_native_1.Platform.OS === 'android') {
62
+ try {
63
+ const grants = await react_native_1.PermissionsAndroid.requestMultiple([
64
+ react_native_1.PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE,
65
+ react_native_1.PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE,
66
+ react_native_1.PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
67
+ ]);
68
+ // Note: Android 13+ might need different permissions for media, but keeping it simple
69
+ if (grants['android.permission.RECORD_AUDIO'] !== react_native_1.PermissionsAndroid.RESULTS.GRANTED) {
70
+ react_native_1.Alert.alert('Permission needed', 'Audio recording permission is required.');
71
+ return;
72
+ }
73
+ }
74
+ catch (err) {
75
+ console.warn(err);
76
+ return;
77
+ }
78
+ }
79
+ try {
80
+ const result = await audioRecorderPlayer.startRecorder();
81
+ audioRecorderPlayer.addRecordBackListener((e) => {
82
+ setAudioDuration(audioRecorderPlayer.mmssss(Math.floor(e.currentPosition)));
83
+ return;
84
+ });
85
+ setIsRecordingAudio(true);
86
+ setAudioUri(null);
87
+ }
88
+ catch (error) {
89
+ console.error('Failed to start recording', error);
90
+ react_native_1.Alert.alert('Error', 'Could not start audio recording');
91
+ }
92
+ };
93
+ const onStopAudioRecord = async () => {
94
+ try {
95
+ const result = await audioRecorderPlayer.stopRecorder();
96
+ audioRecorderPlayer.removeRecordBackListener();
97
+ setIsRecordingAudio(false);
98
+ setAudioUri(result);
99
+ }
100
+ catch (error) {
101
+ console.error('Failed to stop recording', error);
102
+ }
103
+ };
46
104
  const sendFeedback = async () => {
47
- if (!description.trim()) {
48
- react_native_1.Alert.alert('Please describe the issue');
105
+ if (!description.trim() && !audioUri && !videoUri) {
106
+ react_native_1.Alert.alert('Please provide a description, audio, or video.');
49
107
  return;
50
108
  }
51
109
  setIsSending(true);
@@ -55,14 +113,30 @@ const FeedbackModal = ({ visible, onClose, screenshotUri, apiKey, apiEndpoint })
55
113
  if (screenshotUri) {
56
114
  const filename = screenshotUri.split('/').pop();
57
115
  const match = /\.(\w+)$/.exec(filename || '');
58
- const type = match ? `image/${match[1]}` : `image`;
116
+ const type = match ? `image/${match[1]}` : `image/png`;
59
117
  formData.append('screenshot', {
60
118
  uri: react_native_1.Platform.OS === 'ios' ? screenshotUri.replace('file://', '') : screenshotUri,
61
119
  name: filename || 'screenshot.png',
62
120
  type,
63
121
  });
64
122
  }
65
- // 2. Metadata
123
+ // 2. Audio
124
+ if (audioUri) {
125
+ formData.append('audio', {
126
+ uri: react_native_1.Platform.OS === 'ios' ? audioUri.replace('file://', '') : audioUri,
127
+ name: 'feedback.m4a',
128
+ type: 'audio/m4a',
129
+ });
130
+ }
131
+ // 3. Video
132
+ if (videoUri) {
133
+ formData.append('video', {
134
+ uri: react_native_1.Platform.OS === 'ios' ? videoUri.replace('file://', '') : videoUri,
135
+ name: 'screen_record.mp4',
136
+ type: 'video/mp4',
137
+ });
138
+ }
139
+ // 4. Metadata
66
140
  const metadata = {
67
141
  device: react_native_device_info_1.default.getModel(),
68
142
  systemName: react_native_device_info_1.default.getSystemName(),
@@ -73,11 +147,11 @@ const FeedbackModal = ({ visible, onClose, screenshotUri, apiKey, apiEndpoint })
73
147
  isEmulator: await react_native_device_info_1.default.isEmulator(),
74
148
  };
75
149
  formData.append('metadata', JSON.stringify(metadata));
76
- // 3. Other fields
150
+ // 5. Other fields
77
151
  formData.append('description', description);
78
152
  formData.append('apiKey', apiKey);
79
- formData.append('udid', await react_native_device_info_1.default.getUniqueId()); // Best effort UDID/ID
80
- // 4. Send
153
+ formData.append('udid', await react_native_device_info_1.default.getUniqueId());
154
+ // 6. Send
81
155
  const response = await fetch(`${apiEndpoint}/api/feedback`, {
82
156
  method: 'POST',
83
157
  headers: {
@@ -89,6 +163,8 @@ const FeedbackModal = ({ visible, onClose, screenshotUri, apiKey, apiEndpoint })
89
163
  if (response.ok) {
90
164
  react_native_1.Alert.alert('Success', 'Feedback sent successfully!');
91
165
  setDescription('');
166
+ setAudioUri(null);
167
+ setAudioDuration('00:00');
92
168
  onClose();
93
169
  }
94
170
  else {
@@ -115,12 +191,39 @@ const FeedbackModal = ({ visible, onClose, screenshotUri, apiKey, apiEndpoint })
115
191
  </react_native_1.TouchableOpacity>
116
192
  </react_native_1.View>
117
193
 
118
- {screenshotUri && (<react_native_1.View style={styles.previewContainer}>
119
- <react_native_1.Image source={{ uri: screenshotUri }} style={styles.preview} resizeMode="contain"/>
120
- <react_native_1.Text style={styles.previewLabel}>Screenshot Attached</react_native_1.Text>
121
- </react_native_1.View>)}
194
+ {/* Media Previews */}
195
+ <react_native_1.View style={styles.mediaRow}>
196
+ {/* Screenshot Preview */}
197
+ {screenshotUri && !videoUri && (<react_native_1.View style={styles.mediaItem}>
198
+ <react_native_1.Image source={{ uri: screenshotUri }} style={styles.mediaPreview} resizeMode="cover"/>
199
+ <react_native_1.View style={styles.mediaBadge}><react_native_1.Text style={styles.mediaBadgeText}>Screenshot</react_native_1.Text></react_native_1.View>
200
+ </react_native_1.View>)}
201
+
202
+ {/* Video Preview (Placeholder) */}
203
+ {videoUri && (<react_native_1.View style={styles.mediaItem}>
204
+ <react_native_1.View style={[styles.mediaPreview, { backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }]}>
205
+ <react_native_1.Text style={{ color: '#FFF' }}>▶ Video</react_native_1.Text>
206
+ </react_native_1.View>
207
+ <react_native_1.View style={styles.mediaBadge}><react_native_1.Text style={styles.mediaBadgeText}>Screen Rec</react_native_1.Text></react_native_1.View>
208
+ </react_native_1.View>)}
209
+ </react_native_1.View>
210
+
211
+ <react_native_1.TextInput style={styles.input} placeholder="What happened? Describe the bug..." placeholderTextColor="#999" multiline value={description} onChangeText={setDescription}/>
212
+
213
+ {/* Media Actions Row */}
214
+ <react_native_1.View style={styles.actionsRow}>
215
+ {/* Audio Recorder */}
216
+ <react_native_1.TouchableOpacity style={[styles.actionBtn, isRecordingAudio ? styles.recordingBtn : undefined, audioUri ? styles.hasAudioBtn : undefined]} onPress={isRecordingAudio ? onStopAudioRecord : onStartAudioRecord}>
217
+ <react_native_1.Text style={[styles.actionBtnText, (isRecordingAudio || audioUri) ? { color: '#FFF' } : undefined]}>
218
+ {isRecordingAudio ? `Stop (${audioDuration})` : audioUri ? 'Re-record Audio' : '🎙 Record Audio'}
219
+ </react_native_1.Text>
220
+ </react_native_1.TouchableOpacity>
122
221
 
123
- <react_native_1.TextInput style={styles.input} placeholder="What happened? Describe the bug..." placeholderTextColor="#999" multiline value={description} onChangeText={setDescription} autoFocus/>
222
+ {/* Screen Recorder */}
223
+ {onStartRecording && !videoUri && (<react_native_1.TouchableOpacity style={styles.actionBtn} onPress={onStartRecording}>
224
+ <react_native_1.Text style={styles.actionBtnText}>🎥 Record Screen</react_native_1.Text>
225
+ </react_native_1.TouchableOpacity>)}
226
+ </react_native_1.View>
124
227
 
125
228
  <react_native_1.TouchableOpacity style={[styles.submitBtn, isSending && styles.disabledBtn]} onPress={sendFeedback} disabled={isSending}>
126
229
  {isSending ? (<react_native_1.ActivityIndicator color="#FFF"/>) : (<react_native_1.Text style={styles.submitText}>Send Feedback</react_native_1.Text>)}
@@ -141,7 +244,8 @@ const styles = react_native_1.StyleSheet.create({
141
244
  borderTopLeftRadius: 20,
142
245
  borderTopRightRadius: 20,
143
246
  padding: 20,
144
- minHeight: 400,
247
+ paddingBottom: 40,
248
+ maxHeight: '90%',
145
249
  },
146
250
  header: {
147
251
  flexDirection: 'row',
@@ -159,47 +263,79 @@ const styles = react_native_1.StyleSheet.create({
159
263
  color: '#999',
160
264
  padding: 5,
161
265
  },
162
- previewContainer: {
163
- height: 150,
164
- backgroundColor: '#f0f0f0',
165
- borderRadius: 10,
266
+ mediaRow: {
267
+ flexDirection: 'row',
166
268
  marginBottom: 15,
167
- justifyContent: 'center',
168
- alignItems: 'center',
269
+ },
270
+ mediaItem: {
271
+ width: 100,
272
+ height: 100,
273
+ borderRadius: 10,
169
274
  overflow: 'hidden',
275
+ marginRight: 10,
276
+ position: 'relative',
170
277
  },
171
- preview: {
278
+ mediaPreview: {
172
279
  width: '100%',
173
280
  height: '100%',
174
281
  },
175
- previewLabel: {
282
+ mediaBadge: {
176
283
  position: 'absolute',
177
- bottom: 5,
178
- right: 5,
284
+ bottom: 0,
285
+ left: 0,
286
+ right: 0,
179
287
  backgroundColor: 'rgba(0,0,0,0.6)',
288
+ padding: 4,
289
+ alignItems: 'center',
290
+ },
291
+ mediaBadgeText: {
180
292
  color: '#FFF',
181
293
  fontSize: 10,
182
- padding: 4,
183
- borderRadius: 4,
294
+ fontWeight: 'bold',
184
295
  },
185
296
  input: {
186
297
  backgroundColor: '#f9f9f9',
187
298
  borderRadius: 10,
188
299
  padding: 15,
189
- height: 120,
300
+ height: 100,
190
301
  textAlignVertical: 'top',
191
302
  fontSize: 16,
192
303
  color: '#000',
304
+ marginBottom: 15,
305
+ },
306
+ actionsRow: {
307
+ flexDirection: 'row',
308
+ justifyContent: 'space-between',
193
309
  marginBottom: 20,
194
310
  },
311
+ actionBtn: {
312
+ flex: 1,
313
+ backgroundColor: '#f0f0f0',
314
+ padding: 12,
315
+ borderRadius: 8,
316
+ alignItems: 'center',
317
+ marginHorizontal: 5,
318
+ },
319
+ recordingBtn: {
320
+ backgroundColor: '#ff4444',
321
+ },
322
+ hasAudioBtn: {
323
+ backgroundColor: '#4CAF50',
324
+ },
325
+ actionBtnText: {
326
+ fontSize: 14,
327
+ fontWeight: '600',
328
+ color: '#333',
329
+ },
195
330
  submitBtn: {
196
- backgroundColor: '#007AFF',
197
- padding: 15,
331
+ backgroundColor: '#000',
198
332
  borderRadius: 12,
333
+ padding: 16,
199
334
  alignItems: 'center',
335
+ marginTop: 10,
200
336
  },
201
337
  disabledBtn: {
202
- backgroundColor: '#ccc',
338
+ opacity: 0.7,
203
339
  },
204
340
  submitText: {
205
341
  color: '#FFF',
package/lib/index.js CHANGED
@@ -32,6 +32,9 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
35
38
  Object.defineProperty(exports, "__esModule", { value: true });
36
39
  exports.IInstall = void 0;
37
40
  const react_1 = __importStar(require("react"));
@@ -39,22 +42,23 @@ const react_native_1 = require("react-native");
39
42
  const react_native_view_shot_1 = require("react-native-view-shot");
40
43
  const ShakeDetector_1 = require("./ShakeDetector");
41
44
  const FeedbackModal_1 = require("./FeedbackModal");
45
+ const react_native_record_screen_1 = __importDefault(require("react-native-record-screen"));
42
46
  const IInstall = ({ apiKey, apiEndpoint = 'https://iinstall.app', children, enabled = true }) => {
43
47
  const [modalVisible, setModalVisible] = (0, react_1.useState)(false);
44
48
  const [screenshotUri, setScreenshotUri] = (0, react_1.useState)(null);
49
+ const [videoUri, setVideoUri] = (0, react_1.useState)(null);
50
+ const [isRecording, setIsRecording] = (0, react_1.useState)(false);
45
51
  const shakeDetectorRef = (0, react_1.useRef)(null);
52
+ // Refs for stable access in shake callback
53
+ const isRecordingRef = (0, react_1.useRef)(isRecording);
54
+ const modalVisibleRef = (0, react_1.useRef)(modalVisible);
46
55
  (0, react_1.useEffect)(() => {
47
- if (!enabled)
48
- return;
49
- shakeDetectorRef.current = new ShakeDetector_1.ShakeDetector(handleShake);
50
- shakeDetectorRef.current.start();
51
- return () => {
52
- shakeDetectorRef.current?.stop();
53
- };
54
- }, [enabled]);
56
+ isRecordingRef.current = isRecording;
57
+ modalVisibleRef.current = modalVisible;
58
+ }, [isRecording, modalVisible]);
55
59
  const handleShake = async () => {
56
- if (modalVisible)
57
- return; // Already open
60
+ if (modalVisibleRef.current || isRecordingRef.current)
61
+ return;
58
62
  try {
59
63
  // Capture screenshot
60
64
  const uri = await (0, react_native_view_shot_1.captureScreen)({
@@ -66,15 +70,112 @@ const IInstall = ({ apiKey, apiEndpoint = 'https://iinstall.app', children, enab
66
70
  }
67
71
  catch (error) {
68
72
  console.error('IInstall: Failed to capture screenshot', error);
69
- // Still show modal without screenshot
70
73
  setScreenshotUri(null);
71
74
  setModalVisible(true);
72
75
  }
73
76
  };
74
- return (<react_native_1.View style={react_native_1.StyleSheet.absoluteFill}>
75
- {children}
76
- <FeedbackModal_1.FeedbackModal visible={modalVisible} onClose={() => setModalVisible(false)} screenshotUri={screenshotUri} apiKey={apiKey} apiEndpoint={apiEndpoint}/>
77
+ // Keep handleShake stable for the detector
78
+ const handleShakeCallback = (0, react_1.useRef)(handleShake);
79
+ (0, react_1.useEffect)(() => {
80
+ handleShakeCallback.current = handleShake;
81
+ });
82
+ (0, react_1.useEffect)(() => {
83
+ if (!enabled)
84
+ return;
85
+ // Use a wrapper to call the current handleShake
86
+ shakeDetectorRef.current = new ShakeDetector_1.ShakeDetector(() => handleShakeCallback.current());
87
+ shakeDetectorRef.current.start();
88
+ return () => {
89
+ shakeDetectorRef.current?.stop();
90
+ };
91
+ }, [enabled]);
92
+ const handleStartRecording = async () => {
93
+ setModalVisible(false);
94
+ setIsRecording(true);
95
+ try {
96
+ await react_native_record_screen_1.default.startRecording({ mic: true }).catch((error) => console.error(error));
97
+ }
98
+ catch (e) {
99
+ console.error('Failed to start recording', e);
100
+ setIsRecording(false);
101
+ setModalVisible(true); // Re-open if failed
102
+ }
103
+ };
104
+ const handleStopRecording = async () => {
105
+ try {
106
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
107
+ const res = await react_native_record_screen_1.default.stopRecording().catch((error) => console.error(error));
108
+ setIsRecording(false);
109
+ if (res) {
110
+ setVideoUri(res.result.outputURL);
111
+ setModalVisible(true);
112
+ }
113
+ }
114
+ catch (e) {
115
+ console.error('Failed to stop recording', e);
116
+ setIsRecording(false);
117
+ }
118
+ };
119
+ return (<react_native_1.View style={react_native_1.StyleSheet.absoluteFill} pointerEvents="box-none">
120
+ <react_native_1.View style={react_native_1.StyleSheet.absoluteFill}>
121
+ {children}
122
+ </react_native_1.View>
123
+
124
+ {isRecording && (<react_native_1.SafeAreaView style={styles.recordingOverlay} pointerEvents="box-none">
125
+ <react_native_1.View style={styles.recordingContainer}>
126
+ <react_native_1.View style={styles.recordingDot}/>
127
+ <react_native_1.Text style={styles.recordingText}>Recording Screen...</react_native_1.Text>
128
+ <react_native_1.TouchableOpacity onPress={handleStopRecording} style={styles.stopButton}>
129
+ <react_native_1.Text style={styles.stopButtonText}>Stop</react_native_1.Text>
130
+ </react_native_1.TouchableOpacity>
131
+ </react_native_1.View>
132
+ </react_native_1.SafeAreaView>)}
133
+
134
+ <FeedbackModal_1.FeedbackModal visible={modalVisible} onClose={() => {
135
+ setModalVisible(false);
136
+ setVideoUri(null); // Clear video on close
137
+ setScreenshotUri(null);
138
+ }} screenshotUri={screenshotUri} videoUri={videoUri} onStartRecording={handleStartRecording} apiKey={apiKey} apiEndpoint={apiEndpoint}/>
77
139
  </react_native_1.View>);
78
140
  };
79
141
  exports.IInstall = IInstall;
142
+ const styles = react_native_1.StyleSheet.create({
143
+ recordingOverlay: {
144
+ position: 'absolute',
145
+ top: 50,
146
+ alignSelf: 'center',
147
+ zIndex: 9999,
148
+ },
149
+ recordingContainer: {
150
+ flexDirection: 'row',
151
+ alignItems: 'center',
152
+ backgroundColor: 'rgba(0,0,0,0.8)',
153
+ padding: 10,
154
+ borderRadius: 30,
155
+ paddingHorizontal: 20,
156
+ },
157
+ recordingDot: {
158
+ width: 10,
159
+ height: 10,
160
+ borderRadius: 5,
161
+ backgroundColor: '#ff4444',
162
+ marginRight: 10,
163
+ },
164
+ recordingText: {
165
+ color: '#FFF',
166
+ fontWeight: '600',
167
+ marginRight: 15,
168
+ },
169
+ stopButton: {
170
+ backgroundColor: '#FFF',
171
+ paddingVertical: 5,
172
+ paddingHorizontal: 15,
173
+ borderRadius: 15,
174
+ },
175
+ stopButtonText: {
176
+ color: '#000',
177
+ fontWeight: 'bold',
178
+ fontSize: 12,
179
+ }
180
+ });
80
181
  exports.default = exports.IInstall;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "react-native-iinstall",
3
- "version": "0.1.0",
4
- "description": "IInstall SDK for React Native - Shake to Report",
3
+ "version": "0.2.0",
4
+ "description": "IInstall SDK for React Native - Shake to Report with Audio & Video",
5
5
  "author": "TesterFlow Team",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -33,6 +33,8 @@
33
33
  "react-native-sensors": "^7.3.0",
34
34
  "react-native-view-shot": "^3.1.2",
35
35
  "react-native-device-info": "^10.0.0",
36
+ "react-native-audio-recorder-player": "^3.6.4",
37
+ "react-native-record-screen": "*",
36
38
  "rxjs": "^7.0.0"
37
39
  },
38
40
  "devDependencies": {
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect } from 'react';
1
+ import React, { useState, useEffect, useRef } from 'react';
2
2
  import {
3
3
  Modal,
4
4
  View,
@@ -10,14 +10,18 @@ import {
10
10
  Image,
11
11
  Alert,
12
12
  Platform,
13
- KeyboardAvoidingView
13
+ KeyboardAvoidingView,
14
+ PermissionsAndroid
14
15
  } from 'react-native';
15
16
  import DeviceInfo from 'react-native-device-info';
17
+ import AudioRecorderPlayer from 'react-native-audio-recorder-player';
16
18
 
17
19
  interface FeedbackModalProps {
18
20
  visible: boolean;
19
21
  onClose: () => void;
20
22
  screenshotUri: string | null;
23
+ videoUri?: string | null;
24
+ onStartRecording?: () => void;
21
25
  apiKey: string;
22
26
  apiEndpoint: string;
23
27
  }
@@ -26,15 +30,79 @@ export const FeedbackModal: React.FC<FeedbackModalProps> = ({
26
30
  visible,
27
31
  onClose,
28
32
  screenshotUri,
33
+ videoUri,
34
+ onStartRecording,
29
35
  apiKey,
30
36
  apiEndpoint
31
37
  }) => {
32
38
  const [description, setDescription] = useState('');
33
39
  const [isSending, setIsSending] = useState(false);
40
+
41
+ // Audio State
42
+ const [isRecordingAudio, setIsRecordingAudio] = useState(false);
43
+ const [audioUri, setAudioUri] = useState<string | null>(null);
44
+ const [audioDuration, setAudioDuration] = useState('00:00');
45
+
46
+ const audioRecorderPlayer = useRef(new AudioRecorderPlayer()).current;
47
+
48
+ useEffect(() => {
49
+ return () => {
50
+ // Cleanup
51
+ if (isRecordingAudio) {
52
+ audioRecorderPlayer.stopRecorder();
53
+ }
54
+ };
55
+ }, [isRecordingAudio]);
56
+
57
+ const onStartAudioRecord = async () => {
58
+ if (Platform.OS === 'android') {
59
+ try {
60
+ const grants = await PermissionsAndroid.requestMultiple([
61
+ PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE,
62
+ PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE,
63
+ PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
64
+ ]);
65
+ // Note: Android 13+ might need different permissions for media, but keeping it simple
66
+ if (
67
+ grants['android.permission.RECORD_AUDIO'] !== PermissionsAndroid.RESULTS.GRANTED
68
+ ) {
69
+ Alert.alert('Permission needed', 'Audio recording permission is required.');
70
+ return;
71
+ }
72
+ } catch (err) {
73
+ console.warn(err);
74
+ return;
75
+ }
76
+ }
77
+
78
+ try {
79
+ const result = await audioRecorderPlayer.startRecorder();
80
+ audioRecorderPlayer.addRecordBackListener((e) => {
81
+ setAudioDuration(audioRecorderPlayer.mmssss(Math.floor(e.currentPosition)));
82
+ return;
83
+ });
84
+ setIsRecordingAudio(true);
85
+ setAudioUri(null);
86
+ } catch (error) {
87
+ console.error('Failed to start recording', error);
88
+ Alert.alert('Error', 'Could not start audio recording');
89
+ }
90
+ };
91
+
92
+ const onStopAudioRecord = async () => {
93
+ try {
94
+ const result = await audioRecorderPlayer.stopRecorder();
95
+ audioRecorderPlayer.removeRecordBackListener();
96
+ setIsRecordingAudio(false);
97
+ setAudioUri(result);
98
+ } catch (error) {
99
+ console.error('Failed to stop recording', error);
100
+ }
101
+ };
34
102
 
35
103
  const sendFeedback = async () => {
36
- if (!description.trim()) {
37
- Alert.alert('Please describe the issue');
104
+ if (!description.trim() && !audioUri && !videoUri) {
105
+ Alert.alert('Please provide a description, audio, or video.');
38
106
  return;
39
107
  }
40
108
 
@@ -47,7 +115,7 @@ export const FeedbackModal: React.FC<FeedbackModalProps> = ({
47
115
  if (screenshotUri) {
48
116
  const filename = screenshotUri.split('/').pop();
49
117
  const match = /\.(\w+)$/.exec(filename || '');
50
- const type = match ? `image/${match[1]}` : `image`;
118
+ const type = match ? `image/${match[1]}` : `image/png`;
51
119
 
52
120
  formData.append('screenshot', {
53
121
  uri: Platform.OS === 'ios' ? screenshotUri.replace('file://', '') : screenshotUri,
@@ -56,7 +124,25 @@ export const FeedbackModal: React.FC<FeedbackModalProps> = ({
56
124
  } as any);
57
125
  }
58
126
 
59
- // 2. Metadata
127
+ // 2. Audio
128
+ if (audioUri) {
129
+ formData.append('audio', {
130
+ uri: Platform.OS === 'ios' ? audioUri.replace('file://', '') : audioUri,
131
+ name: 'feedback.m4a',
132
+ type: 'audio/m4a',
133
+ } as any);
134
+ }
135
+
136
+ // 3. Video
137
+ if (videoUri) {
138
+ formData.append('video', {
139
+ uri: Platform.OS === 'ios' ? videoUri.replace('file://', '') : videoUri,
140
+ name: 'screen_record.mp4',
141
+ type: 'video/mp4',
142
+ } as any);
143
+ }
144
+
145
+ // 4. Metadata
60
146
  const metadata = {
61
147
  device: DeviceInfo.getModel(),
62
148
  systemName: DeviceInfo.getSystemName(),
@@ -68,12 +154,12 @@ export const FeedbackModal: React.FC<FeedbackModalProps> = ({
68
154
  };
69
155
  formData.append('metadata', JSON.stringify(metadata));
70
156
 
71
- // 3. Other fields
157
+ // 5. Other fields
72
158
  formData.append('description', description);
73
159
  formData.append('apiKey', apiKey);
74
- formData.append('udid', await DeviceInfo.getUniqueId()); // Best effort UDID/ID
160
+ formData.append('udid', await DeviceInfo.getUniqueId());
75
161
 
76
- // 4. Send
162
+ // 6. Send
77
163
  const response = await fetch(`${apiEndpoint}/api/feedback`, {
78
164
  method: 'POST',
79
165
  headers: {
@@ -87,6 +173,8 @@ export const FeedbackModal: React.FC<FeedbackModalProps> = ({
87
173
  if (response.ok) {
88
174
  Alert.alert('Success', 'Feedback sent successfully!');
89
175
  setDescription('');
176
+ setAudioUri(null);
177
+ setAudioDuration('00:00');
90
178
  onClose();
91
179
  } else {
92
180
  throw new Error(result.error || 'Failed to send');
@@ -116,12 +204,26 @@ export const FeedbackModal: React.FC<FeedbackModalProps> = ({
116
204
  </TouchableOpacity>
117
205
  </View>
118
206
 
119
- {screenshotUri && (
120
- <View style={styles.previewContainer}>
121
- <Image source={{ uri: screenshotUri }} style={styles.preview} resizeMode="contain" />
122
- <Text style={styles.previewLabel}>Screenshot Attached</Text>
123
- </View>
124
- )}
207
+ {/* Media Previews */}
208
+ <View style={styles.mediaRow}>
209
+ {/* Screenshot Preview */}
210
+ {screenshotUri && !videoUri && (
211
+ <View style={styles.mediaItem}>
212
+ <Image source={{ uri: screenshotUri }} style={styles.mediaPreview} resizeMode="cover" />
213
+ <View style={styles.mediaBadge}><Text style={styles.mediaBadgeText}>Screenshot</Text></View>
214
+ </View>
215
+ )}
216
+
217
+ {/* Video Preview (Placeholder) */}
218
+ {videoUri && (
219
+ <View style={styles.mediaItem}>
220
+ <View style={[styles.mediaPreview, { backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }]}>
221
+ <Text style={{color: '#FFF'}}>▶ Video</Text>
222
+ </View>
223
+ <View style={styles.mediaBadge}><Text style={styles.mediaBadgeText}>Screen Rec</Text></View>
224
+ </View>
225
+ )}
226
+ </View>
125
227
 
126
228
  <TextInput
127
229
  style={styles.input}
@@ -130,9 +232,31 @@ export const FeedbackModal: React.FC<FeedbackModalProps> = ({
130
232
  multiline
131
233
  value={description}
132
234
  onChangeText={setDescription}
133
- autoFocus
134
235
  />
135
236
 
237
+ {/* Media Actions Row */}
238
+ <View style={styles.actionsRow}>
239
+ {/* Audio Recorder */}
240
+ <TouchableOpacity
241
+ style={[styles.actionBtn, isRecordingAudio ? styles.recordingBtn : undefined, audioUri ? styles.hasAudioBtn : undefined]}
242
+ onPress={isRecordingAudio ? onStopAudioRecord : onStartAudioRecord}
243
+ >
244
+ <Text style={[styles.actionBtnText, (isRecordingAudio || audioUri) ? { color: '#FFF' } : undefined]}>
245
+ {isRecordingAudio ? `Stop (${audioDuration})` : audioUri ? 'Re-record Audio' : '🎙 Record Audio'}
246
+ </Text>
247
+ </TouchableOpacity>
248
+
249
+ {/* Screen Recorder */}
250
+ {onStartRecording && !videoUri && (
251
+ <TouchableOpacity
252
+ style={styles.actionBtn}
253
+ onPress={onStartRecording}
254
+ >
255
+ <Text style={styles.actionBtnText}>🎥 Record Screen</Text>
256
+ </TouchableOpacity>
257
+ )}
258
+ </View>
259
+
136
260
  <TouchableOpacity
137
261
  style={[styles.submitBtn, isSending && styles.disabledBtn]}
138
262
  onPress={sendFeedback}
@@ -161,7 +285,8 @@ const styles = StyleSheet.create({
161
285
  borderTopLeftRadius: 20,
162
286
  borderTopRightRadius: 20,
163
287
  padding: 20,
164
- minHeight: 400,
288
+ paddingBottom: 40,
289
+ maxHeight: '90%',
165
290
  },
166
291
  header: {
167
292
  flexDirection: 'row',
@@ -179,47 +304,79 @@ const styles = StyleSheet.create({
179
304
  color: '#999',
180
305
  padding: 5,
181
306
  },
182
- previewContainer: {
183
- height: 150,
184
- backgroundColor: '#f0f0f0',
185
- borderRadius: 10,
186
- marginBottom: 15,
187
- justifyContent: 'center',
188
- alignItems: 'center',
189
- overflow: 'hidden',
307
+ mediaRow: {
308
+ flexDirection: 'row',
309
+ marginBottom: 15,
190
310
  },
191
- preview: {
192
- width: '100%',
193
- height: '100%',
311
+ mediaItem: {
312
+ width: 100,
313
+ height: 100,
314
+ borderRadius: 10,
315
+ overflow: 'hidden',
316
+ marginRight: 10,
317
+ position: 'relative',
194
318
  },
195
- previewLabel: {
196
- position: 'absolute',
197
- bottom: 5,
198
- right: 5,
199
- backgroundColor: 'rgba(0,0,0,0.6)',
200
- color: '#FFF',
201
- fontSize: 10,
202
- padding: 4,
203
- borderRadius: 4,
319
+ mediaPreview: {
320
+ width: '100%',
321
+ height: '100%',
322
+ },
323
+ mediaBadge: {
324
+ position: 'absolute',
325
+ bottom: 0,
326
+ left: 0,
327
+ right: 0,
328
+ backgroundColor: 'rgba(0,0,0,0.6)',
329
+ padding: 4,
330
+ alignItems: 'center',
331
+ },
332
+ mediaBadgeText: {
333
+ color: '#FFF',
334
+ fontSize: 10,
335
+ fontWeight: 'bold',
204
336
  },
205
337
  input: {
206
338
  backgroundColor: '#f9f9f9',
207
339
  borderRadius: 10,
208
340
  padding: 15,
209
- height: 120,
341
+ height: 100,
210
342
  textAlignVertical: 'top',
211
343
  fontSize: 16,
212
344
  color: '#000',
213
- marginBottom: 20,
345
+ marginBottom: 15,
346
+ },
347
+ actionsRow: {
348
+ flexDirection: 'row',
349
+ justifyContent: 'space-between',
350
+ marginBottom: 20,
351
+ },
352
+ actionBtn: {
353
+ flex: 1,
354
+ backgroundColor: '#f0f0f0',
355
+ padding: 12,
356
+ borderRadius: 8,
357
+ alignItems: 'center',
358
+ marginHorizontal: 5,
359
+ },
360
+ recordingBtn: {
361
+ backgroundColor: '#ff4444',
362
+ },
363
+ hasAudioBtn: {
364
+ backgroundColor: '#4CAF50',
365
+ },
366
+ actionBtnText: {
367
+ fontSize: 14,
368
+ fontWeight: '600',
369
+ color: '#333',
214
370
  },
215
371
  submitBtn: {
216
- backgroundColor: '#007AFF',
217
- padding: 15,
372
+ backgroundColor: '#000',
218
373
  borderRadius: 12,
374
+ padding: 16,
219
375
  alignItems: 'center',
376
+ marginTop: 10,
220
377
  },
221
378
  disabledBtn: {
222
- backgroundColor: '#ccc',
379
+ opacity: 0.7,
223
380
  },
224
381
  submitText: {
225
382
  color: '#FFF',
package/src/index.tsx CHANGED
@@ -1,8 +1,9 @@
1
1
  import React, { useEffect, useState, useRef } from 'react';
2
- import { View, StyleSheet } from 'react-native';
2
+ import { View, StyleSheet, TouchableOpacity, Text, SafeAreaView, Platform } from 'react-native';
3
3
  import { captureScreen } from 'react-native-view-shot';
4
4
  import { ShakeDetector } from './ShakeDetector';
5
5
  import { FeedbackModal } from './FeedbackModal';
6
+ import RecordScreen from 'react-native-record-screen';
6
7
 
7
8
  interface IInstallProps {
8
9
  apiKey: string;
@@ -19,21 +20,22 @@ export const IInstall: React.FC<IInstallProps> = ({
19
20
  }) => {
20
21
  const [modalVisible, setModalVisible] = useState(false);
21
22
  const [screenshotUri, setScreenshotUri] = useState<string | null>(null);
23
+ const [videoUri, setVideoUri] = useState<string | null>(null);
24
+ const [isRecording, setIsRecording] = useState(false);
25
+
22
26
  const shakeDetectorRef = useRef<ShakeDetector | null>(null);
27
+
28
+ // Refs for stable access in shake callback
29
+ const isRecordingRef = useRef(isRecording);
30
+ const modalVisibleRef = useRef(modalVisible);
23
31
 
24
32
  useEffect(() => {
25
- if (!enabled) return;
26
-
27
- shakeDetectorRef.current = new ShakeDetector(handleShake);
28
- shakeDetectorRef.current.start();
29
-
30
- return () => {
31
- shakeDetectorRef.current?.stop();
32
- };
33
- }, [enabled]);
33
+ isRecordingRef.current = isRecording;
34
+ modalVisibleRef.current = modalVisible;
35
+ }, [isRecording, modalVisible]);
34
36
 
35
37
  const handleShake = async () => {
36
- if (modalVisible) return; // Already open
38
+ if (modalVisibleRef.current || isRecordingRef.current) return;
37
39
 
38
40
  try {
39
41
  // Capture screenshot
@@ -45,19 +47,84 @@ export const IInstall: React.FC<IInstallProps> = ({
45
47
  setModalVisible(true);
46
48
  } catch (error) {
47
49
  console.error('IInstall: Failed to capture screenshot', error);
48
- // Still show modal without screenshot
49
50
  setScreenshotUri(null);
50
51
  setModalVisible(true);
51
52
  }
52
53
  };
54
+
55
+ // Keep handleShake stable for the detector
56
+ const handleShakeCallback = useRef(handleShake);
57
+ useEffect(() => {
58
+ handleShakeCallback.current = handleShake;
59
+ });
60
+
61
+ useEffect(() => {
62
+ if (!enabled) return;
63
+
64
+ // Use a wrapper to call the current handleShake
65
+ shakeDetectorRef.current = new ShakeDetector(() => handleShakeCallback.current());
66
+ shakeDetectorRef.current.start();
67
+
68
+ return () => {
69
+ shakeDetectorRef.current?.stop();
70
+ };
71
+ }, [enabled]);
72
+
73
+ const handleStartRecording = async () => {
74
+ setModalVisible(false);
75
+ setIsRecording(true);
76
+ try {
77
+ await RecordScreen.startRecording({ mic: true }).catch((error: any) => console.error(error));
78
+ } catch (e) {
79
+ console.error('Failed to start recording', e);
80
+ setIsRecording(false);
81
+ setModalVisible(true); // Re-open if failed
82
+ }
83
+ };
84
+
85
+ const handleStopRecording = async () => {
86
+ try {
87
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
88
+ const res: any = await RecordScreen.stopRecording().catch((error: any) => console.error(error));
89
+ setIsRecording(false);
90
+ if (res) {
91
+ setVideoUri(res.result.outputURL);
92
+ setModalVisible(true);
93
+ }
94
+ } catch (e) {
95
+ console.error('Failed to stop recording', e);
96
+ setIsRecording(false);
97
+ }
98
+ };
53
99
 
54
100
  return (
55
- <View style={StyleSheet.absoluteFill}>
56
- {children}
101
+ <View style={StyleSheet.absoluteFill} pointerEvents="box-none">
102
+ <View style={StyleSheet.absoluteFill}>
103
+ {children}
104
+ </View>
105
+
106
+ {isRecording && (
107
+ <SafeAreaView style={styles.recordingOverlay} pointerEvents="box-none">
108
+ <View style={styles.recordingContainer}>
109
+ <View style={styles.recordingDot} />
110
+ <Text style={styles.recordingText}>Recording Screen...</Text>
111
+ <TouchableOpacity onPress={handleStopRecording} style={styles.stopButton}>
112
+ <Text style={styles.stopButtonText}>Stop</Text>
113
+ </TouchableOpacity>
114
+ </View>
115
+ </SafeAreaView>
116
+ )}
117
+
57
118
  <FeedbackModal
58
119
  visible={modalVisible}
59
- onClose={() => setModalVisible(false)}
120
+ onClose={() => {
121
+ setModalVisible(false);
122
+ setVideoUri(null); // Clear video on close
123
+ setScreenshotUri(null);
124
+ }}
60
125
  screenshotUri={screenshotUri}
126
+ videoUri={videoUri}
127
+ onStartRecording={handleStartRecording}
61
128
  apiKey={apiKey}
62
129
  apiEndpoint={apiEndpoint}
63
130
  />
@@ -65,4 +132,44 @@ export const IInstall: React.FC<IInstallProps> = ({
65
132
  );
66
133
  };
67
134
 
135
+ const styles = StyleSheet.create({
136
+ recordingOverlay: {
137
+ position: 'absolute',
138
+ top: 50,
139
+ alignSelf: 'center',
140
+ zIndex: 9999,
141
+ },
142
+ recordingContainer: {
143
+ flexDirection: 'row',
144
+ alignItems: 'center',
145
+ backgroundColor: 'rgba(0,0,0,0.8)',
146
+ padding: 10,
147
+ borderRadius: 30,
148
+ paddingHorizontal: 20,
149
+ },
150
+ recordingDot: {
151
+ width: 10,
152
+ height: 10,
153
+ borderRadius: 5,
154
+ backgroundColor: '#ff4444',
155
+ marginRight: 10,
156
+ },
157
+ recordingText: {
158
+ color: '#FFF',
159
+ fontWeight: '600',
160
+ marginRight: 15,
161
+ },
162
+ stopButton: {
163
+ backgroundColor: '#FFF',
164
+ paddingVertical: 5,
165
+ paddingHorizontal: 15,
166
+ borderRadius: 15,
167
+ },
168
+ stopButtonText: {
169
+ color: '#000',
170
+ fontWeight: 'bold',
171
+ fontSize: 12,
172
+ }
173
+ });
174
+
68
175
  export default IInstall;