react-native-iinstall 0.1.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 +65 -0
- package/lib/FeedbackModal.d.ts +10 -0
- package/lib/FeedbackModal.js +209 -0
- package/lib/ShakeDetector.d.ts +8 -0
- package/lib/ShakeDetector.js +37 -0
- package/lib/index.d.ts +9 -0
- package/lib/index.js +80 -0
- package/package.json +45 -0
- package/src/FeedbackModal.tsx +229 -0
- package/src/ShakeDetector.ts +41 -0
- package/src/index.tsx +68 -0
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# IInstall SDK for React Native
|
|
2
|
+
|
|
3
|
+
The official React Native SDK for [IInstall](https://iinstall.app). Enable "Shake to Report" in your app to gather instant feedback from testers.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
1. Install the SDK:
|
|
8
|
+
```bash
|
|
9
|
+
npm install react-native-iinstall
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
**Note**: This package automatically installs required dependencies (`react-native-sensors`, `react-native-view-shot`, etc.).
|
|
13
|
+
|
|
14
|
+
2. Link native modules (iOS only, non-Expo):
|
|
15
|
+
```bash
|
|
16
|
+
cd ios && pod install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
Wrap your main app component with the `<IInstall>` provider.
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
import React from 'react';
|
|
25
|
+
import { IInstall } from 'react-native-iinstall';
|
|
26
|
+
import AppNavigation from './src/AppNavigation';
|
|
27
|
+
|
|
28
|
+
const App = () => {
|
|
29
|
+
return (
|
|
30
|
+
// Get your API Key from the IInstall Dashboard (Project Settings)
|
|
31
|
+
<IInstall
|
|
32
|
+
apiKey="YOUR_PROJECT_API_KEY"
|
|
33
|
+
apiEndpoint="https://iinstall.app" // Optional, defaults to production
|
|
34
|
+
enabled={__DEV__} // Optional: Only enable in dev/test builds
|
|
35
|
+
>
|
|
36
|
+
<AppNavigation />
|
|
37
|
+
</IInstall>
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export default App;
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- **Shake Detection**: Automatically detects when a user shakes the device.
|
|
47
|
+
- **Screenshot Capture**: Captures the current screen state instantly.
|
|
48
|
+
- **Metadata Collection**: Automatically gathers device model, OS version, and app build number.
|
|
49
|
+
- **Feedback Submission**: Uploads reports directly to your TesterFlow dashboard.
|
|
50
|
+
|
|
51
|
+
## Permissions
|
|
52
|
+
|
|
53
|
+
### Android
|
|
54
|
+
Add to `AndroidManifest.xml`:
|
|
55
|
+
```xml
|
|
56
|
+
<uses-permission android:name="android.permission.INTERNET" />
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### iOS
|
|
60
|
+
Add to `Info.plist` (if required by your specific usage, usually standard permissions suffice for screenshots/network).
|
|
61
|
+
|
|
62
|
+
## Troubleshooting
|
|
63
|
+
|
|
64
|
+
- **Shake not working?** Ensure you are testing on a real device or enabling "Shake" in the simulator menu.
|
|
65
|
+
- **Network errors?** Check if `apiEndpoint` is reachable and `apiKey` is correct.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
interface FeedbackModalProps {
|
|
3
|
+
visible: boolean;
|
|
4
|
+
onClose: () => void;
|
|
5
|
+
screenshotUri: string | null;
|
|
6
|
+
apiKey: string;
|
|
7
|
+
apiEndpoint: string;
|
|
8
|
+
}
|
|
9
|
+
export declare const FeedbackModal: React.FC<FeedbackModalProps>;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.FeedbackModal = void 0;
|
|
40
|
+
const react_1 = __importStar(require("react"));
|
|
41
|
+
const react_native_1 = require("react-native");
|
|
42
|
+
const react_native_device_info_1 = __importDefault(require("react-native-device-info"));
|
|
43
|
+
const FeedbackModal = ({ visible, onClose, screenshotUri, apiKey, apiEndpoint }) => {
|
|
44
|
+
const [description, setDescription] = (0, react_1.useState)('');
|
|
45
|
+
const [isSending, setIsSending] = (0, react_1.useState)(false);
|
|
46
|
+
const sendFeedback = async () => {
|
|
47
|
+
if (!description.trim()) {
|
|
48
|
+
react_native_1.Alert.alert('Please describe the issue');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
setIsSending(true);
|
|
52
|
+
try {
|
|
53
|
+
const formData = new FormData();
|
|
54
|
+
// 1. Screenshot
|
|
55
|
+
if (screenshotUri) {
|
|
56
|
+
const filename = screenshotUri.split('/').pop();
|
|
57
|
+
const match = /\.(\w+)$/.exec(filename || '');
|
|
58
|
+
const type = match ? `image/${match[1]}` : `image`;
|
|
59
|
+
formData.append('screenshot', {
|
|
60
|
+
uri: react_native_1.Platform.OS === 'ios' ? screenshotUri.replace('file://', '') : screenshotUri,
|
|
61
|
+
name: filename || 'screenshot.png',
|
|
62
|
+
type,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// 2. Metadata
|
|
66
|
+
const metadata = {
|
|
67
|
+
device: react_native_device_info_1.default.getModel(),
|
|
68
|
+
systemName: react_native_device_info_1.default.getSystemName(),
|
|
69
|
+
systemVersion: react_native_device_info_1.default.getSystemVersion(),
|
|
70
|
+
appVersion: react_native_device_info_1.default.getVersion(),
|
|
71
|
+
buildNumber: react_native_device_info_1.default.getBuildNumber(),
|
|
72
|
+
brand: react_native_device_info_1.default.getBrand(),
|
|
73
|
+
isEmulator: await react_native_device_info_1.default.isEmulator(),
|
|
74
|
+
};
|
|
75
|
+
formData.append('metadata', JSON.stringify(metadata));
|
|
76
|
+
// 3. Other fields
|
|
77
|
+
formData.append('description', description);
|
|
78
|
+
formData.append('apiKey', apiKey);
|
|
79
|
+
formData.append('udid', await react_native_device_info_1.default.getUniqueId()); // Best effort UDID/ID
|
|
80
|
+
// 4. Send
|
|
81
|
+
const response = await fetch(`${apiEndpoint}/api/feedback`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: {
|
|
84
|
+
'Content-Type': 'multipart/form-data',
|
|
85
|
+
},
|
|
86
|
+
body: formData,
|
|
87
|
+
});
|
|
88
|
+
const result = await response.json();
|
|
89
|
+
if (response.ok) {
|
|
90
|
+
react_native_1.Alert.alert('Success', 'Feedback sent successfully!');
|
|
91
|
+
setDescription('');
|
|
92
|
+
onClose();
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
throw new Error(result.error || 'Failed to send');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
console.error('TesterFlow Error:', error);
|
|
100
|
+
react_native_1.Alert.alert('Error', 'Failed to send feedback. Please try again.');
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
setIsSending(false);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
if (!visible)
|
|
107
|
+
return null;
|
|
108
|
+
return (<react_native_1.Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
|
109
|
+
<react_native_1.KeyboardAvoidingView behavior={react_native_1.Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.container}>
|
|
110
|
+
<react_native_1.View style={styles.card}>
|
|
111
|
+
<react_native_1.View style={styles.header}>
|
|
112
|
+
<react_native_1.Text style={styles.title}>Report an Issue</react_native_1.Text>
|
|
113
|
+
<react_native_1.TouchableOpacity onPress={onClose}>
|
|
114
|
+
<react_native_1.Text style={styles.closeBtn}>✕</react_native_1.Text>
|
|
115
|
+
</react_native_1.TouchableOpacity>
|
|
116
|
+
</react_native_1.View>
|
|
117
|
+
|
|
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>)}
|
|
122
|
+
|
|
123
|
+
<react_native_1.TextInput style={styles.input} placeholder="What happened? Describe the bug..." placeholderTextColor="#999" multiline value={description} onChangeText={setDescription} autoFocus/>
|
|
124
|
+
|
|
125
|
+
<react_native_1.TouchableOpacity style={[styles.submitBtn, isSending && styles.disabledBtn]} onPress={sendFeedback} disabled={isSending}>
|
|
126
|
+
{isSending ? (<react_native_1.ActivityIndicator color="#FFF"/>) : (<react_native_1.Text style={styles.submitText}>Send Feedback</react_native_1.Text>)}
|
|
127
|
+
</react_native_1.TouchableOpacity>
|
|
128
|
+
</react_native_1.View>
|
|
129
|
+
</react_native_1.KeyboardAvoidingView>
|
|
130
|
+
</react_native_1.Modal>);
|
|
131
|
+
};
|
|
132
|
+
exports.FeedbackModal = FeedbackModal;
|
|
133
|
+
const styles = react_native_1.StyleSheet.create({
|
|
134
|
+
container: {
|
|
135
|
+
flex: 1,
|
|
136
|
+
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
137
|
+
justifyContent: 'flex-end',
|
|
138
|
+
},
|
|
139
|
+
card: {
|
|
140
|
+
backgroundColor: '#FFF',
|
|
141
|
+
borderTopLeftRadius: 20,
|
|
142
|
+
borderTopRightRadius: 20,
|
|
143
|
+
padding: 20,
|
|
144
|
+
minHeight: 400,
|
|
145
|
+
},
|
|
146
|
+
header: {
|
|
147
|
+
flexDirection: 'row',
|
|
148
|
+
justifyContent: 'space-between',
|
|
149
|
+
alignItems: 'center',
|
|
150
|
+
marginBottom: 20,
|
|
151
|
+
},
|
|
152
|
+
title: {
|
|
153
|
+
fontSize: 20,
|
|
154
|
+
fontWeight: 'bold',
|
|
155
|
+
color: '#000',
|
|
156
|
+
},
|
|
157
|
+
closeBtn: {
|
|
158
|
+
fontSize: 24,
|
|
159
|
+
color: '#999',
|
|
160
|
+
padding: 5,
|
|
161
|
+
},
|
|
162
|
+
previewContainer: {
|
|
163
|
+
height: 150,
|
|
164
|
+
backgroundColor: '#f0f0f0',
|
|
165
|
+
borderRadius: 10,
|
|
166
|
+
marginBottom: 15,
|
|
167
|
+
justifyContent: 'center',
|
|
168
|
+
alignItems: 'center',
|
|
169
|
+
overflow: 'hidden',
|
|
170
|
+
},
|
|
171
|
+
preview: {
|
|
172
|
+
width: '100%',
|
|
173
|
+
height: '100%',
|
|
174
|
+
},
|
|
175
|
+
previewLabel: {
|
|
176
|
+
position: 'absolute',
|
|
177
|
+
bottom: 5,
|
|
178
|
+
right: 5,
|
|
179
|
+
backgroundColor: 'rgba(0,0,0,0.6)',
|
|
180
|
+
color: '#FFF',
|
|
181
|
+
fontSize: 10,
|
|
182
|
+
padding: 4,
|
|
183
|
+
borderRadius: 4,
|
|
184
|
+
},
|
|
185
|
+
input: {
|
|
186
|
+
backgroundColor: '#f9f9f9',
|
|
187
|
+
borderRadius: 10,
|
|
188
|
+
padding: 15,
|
|
189
|
+
height: 120,
|
|
190
|
+
textAlignVertical: 'top',
|
|
191
|
+
fontSize: 16,
|
|
192
|
+
color: '#000',
|
|
193
|
+
marginBottom: 20,
|
|
194
|
+
},
|
|
195
|
+
submitBtn: {
|
|
196
|
+
backgroundColor: '#007AFF',
|
|
197
|
+
padding: 15,
|
|
198
|
+
borderRadius: 12,
|
|
199
|
+
alignItems: 'center',
|
|
200
|
+
},
|
|
201
|
+
disabledBtn: {
|
|
202
|
+
backgroundColor: '#ccc',
|
|
203
|
+
},
|
|
204
|
+
submitText: {
|
|
205
|
+
color: '#FFF',
|
|
206
|
+
fontSize: 16,
|
|
207
|
+
fontWeight: 'bold',
|
|
208
|
+
},
|
|
209
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ShakeDetector = void 0;
|
|
4
|
+
const react_native_sensors_1 = require("react-native-sensors");
|
|
5
|
+
const operators_1 = require("rxjs/operators");
|
|
6
|
+
const SHAKE_THRESHOLD = 2.5; // g-force
|
|
7
|
+
const MIN_TIME_BETWEEN_SHAKES = 1000; // ms
|
|
8
|
+
class ShakeDetector {
|
|
9
|
+
subscription = null;
|
|
10
|
+
lastShakeTime = 0;
|
|
11
|
+
onShake;
|
|
12
|
+
constructor(onShake) {
|
|
13
|
+
this.onShake = onShake;
|
|
14
|
+
(0, react_native_sensors_1.setUpdateIntervalForType)(react_native_sensors_1.SensorTypes.accelerometer, 100); // 100ms interval
|
|
15
|
+
}
|
|
16
|
+
start() {
|
|
17
|
+
if (this.subscription)
|
|
18
|
+
return;
|
|
19
|
+
this.subscription = react_native_sensors_1.accelerometer
|
|
20
|
+
.pipe((0, operators_1.map)(({ x, y, z }) => Math.sqrt(x * x + y * y + z * z)), (0, operators_1.filter)(g => g > SHAKE_THRESHOLD))
|
|
21
|
+
.subscribe(() => {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
if (now - this.lastShakeTime > MIN_TIME_BETWEEN_SHAKES) {
|
|
24
|
+
this.lastShakeTime = now;
|
|
25
|
+
console.log('TesterFlow: Shake detected!');
|
|
26
|
+
this.onShake();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
stop() {
|
|
31
|
+
if (this.subscription) {
|
|
32
|
+
this.subscription.unsubscribe();
|
|
33
|
+
this.subscription = null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
exports.ShakeDetector = ShakeDetector;
|
package/lib/index.d.ts
ADDED
package/lib/index.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.IInstall = void 0;
|
|
37
|
+
const react_1 = __importStar(require("react"));
|
|
38
|
+
const react_native_1 = require("react-native");
|
|
39
|
+
const react_native_view_shot_1 = require("react-native-view-shot");
|
|
40
|
+
const ShakeDetector_1 = require("./ShakeDetector");
|
|
41
|
+
const FeedbackModal_1 = require("./FeedbackModal");
|
|
42
|
+
const IInstall = ({ apiKey, apiEndpoint = 'https://iinstall.app', children, enabled = true }) => {
|
|
43
|
+
const [modalVisible, setModalVisible] = (0, react_1.useState)(false);
|
|
44
|
+
const [screenshotUri, setScreenshotUri] = (0, react_1.useState)(null);
|
|
45
|
+
const shakeDetectorRef = (0, react_1.useRef)(null);
|
|
46
|
+
(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]);
|
|
55
|
+
const handleShake = async () => {
|
|
56
|
+
if (modalVisible)
|
|
57
|
+
return; // Already open
|
|
58
|
+
try {
|
|
59
|
+
// Capture screenshot
|
|
60
|
+
const uri = await (0, react_native_view_shot_1.captureScreen)({
|
|
61
|
+
format: 'png',
|
|
62
|
+
quality: 0.8,
|
|
63
|
+
});
|
|
64
|
+
setScreenshotUri(uri);
|
|
65
|
+
setModalVisible(true);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
console.error('IInstall: Failed to capture screenshot', error);
|
|
69
|
+
// Still show modal without screenshot
|
|
70
|
+
setScreenshotUri(null);
|
|
71
|
+
setModalVisible(true);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
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
|
+
</react_native_1.View>);
|
|
78
|
+
};
|
|
79
|
+
exports.IInstall = IInstall;
|
|
80
|
+
exports.default = exports.IInstall;
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-native-iinstall",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "IInstall SDK for React Native - Shake to Report",
|
|
5
|
+
"author": "TesterFlow Team",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/your-username/iinstall-sdk.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"react-native",
|
|
13
|
+
"feedback",
|
|
14
|
+
"bug-report",
|
|
15
|
+
"iinstall",
|
|
16
|
+
"shake"
|
|
17
|
+
],
|
|
18
|
+
"main": "lib/index.js",
|
|
19
|
+
"types": "lib/index.d.ts",
|
|
20
|
+
"files": [
|
|
21
|
+
"lib",
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc",
|
|
26
|
+
"prepare": "npm run build"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"react": ">=16.8.0",
|
|
30
|
+
"react-native": ">=0.60.0"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"react-native-sensors": "^7.3.0",
|
|
34
|
+
"react-native-view-shot": "^3.1.2",
|
|
35
|
+
"react-native-device-info": "^10.0.0",
|
|
36
|
+
"rxjs": "^7.0.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/react": "^18.0.0",
|
|
40
|
+
"@types/react-native": "^0.70.0",
|
|
41
|
+
"react": "18.2.0",
|
|
42
|
+
"react-native": "0.72.6",
|
|
43
|
+
"typescript": "^5.0.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Modal,
|
|
4
|
+
View,
|
|
5
|
+
Text,
|
|
6
|
+
TextInput,
|
|
7
|
+
TouchableOpacity,
|
|
8
|
+
StyleSheet,
|
|
9
|
+
ActivityIndicator,
|
|
10
|
+
Image,
|
|
11
|
+
Alert,
|
|
12
|
+
Platform,
|
|
13
|
+
KeyboardAvoidingView
|
|
14
|
+
} from 'react-native';
|
|
15
|
+
import DeviceInfo from 'react-native-device-info';
|
|
16
|
+
|
|
17
|
+
interface FeedbackModalProps {
|
|
18
|
+
visible: boolean;
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
screenshotUri: string | null;
|
|
21
|
+
apiKey: string;
|
|
22
|
+
apiEndpoint: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const FeedbackModal: React.FC<FeedbackModalProps> = ({
|
|
26
|
+
visible,
|
|
27
|
+
onClose,
|
|
28
|
+
screenshotUri,
|
|
29
|
+
apiKey,
|
|
30
|
+
apiEndpoint
|
|
31
|
+
}) => {
|
|
32
|
+
const [description, setDescription] = useState('');
|
|
33
|
+
const [isSending, setIsSending] = useState(false);
|
|
34
|
+
|
|
35
|
+
const sendFeedback = async () => {
|
|
36
|
+
if (!description.trim()) {
|
|
37
|
+
Alert.alert('Please describe the issue');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
setIsSending(true);
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const formData = new FormData();
|
|
45
|
+
|
|
46
|
+
// 1. Screenshot
|
|
47
|
+
if (screenshotUri) {
|
|
48
|
+
const filename = screenshotUri.split('/').pop();
|
|
49
|
+
const match = /\.(\w+)$/.exec(filename || '');
|
|
50
|
+
const type = match ? `image/${match[1]}` : `image`;
|
|
51
|
+
|
|
52
|
+
formData.append('screenshot', {
|
|
53
|
+
uri: Platform.OS === 'ios' ? screenshotUri.replace('file://', '') : screenshotUri,
|
|
54
|
+
name: filename || 'screenshot.png',
|
|
55
|
+
type,
|
|
56
|
+
} as any);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 2. Metadata
|
|
60
|
+
const metadata = {
|
|
61
|
+
device: DeviceInfo.getModel(),
|
|
62
|
+
systemName: DeviceInfo.getSystemName(),
|
|
63
|
+
systemVersion: DeviceInfo.getSystemVersion(),
|
|
64
|
+
appVersion: DeviceInfo.getVersion(),
|
|
65
|
+
buildNumber: DeviceInfo.getBuildNumber(),
|
|
66
|
+
brand: DeviceInfo.getBrand(),
|
|
67
|
+
isEmulator: await DeviceInfo.isEmulator(),
|
|
68
|
+
};
|
|
69
|
+
formData.append('metadata', JSON.stringify(metadata));
|
|
70
|
+
|
|
71
|
+
// 3. Other fields
|
|
72
|
+
formData.append('description', description);
|
|
73
|
+
formData.append('apiKey', apiKey);
|
|
74
|
+
formData.append('udid', await DeviceInfo.getUniqueId()); // Best effort UDID/ID
|
|
75
|
+
|
|
76
|
+
// 4. Send
|
|
77
|
+
const response = await fetch(`${apiEndpoint}/api/feedback`, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: {
|
|
80
|
+
'Content-Type': 'multipart/form-data',
|
|
81
|
+
},
|
|
82
|
+
body: formData,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const result = await response.json();
|
|
86
|
+
|
|
87
|
+
if (response.ok) {
|
|
88
|
+
Alert.alert('Success', 'Feedback sent successfully!');
|
|
89
|
+
setDescription('');
|
|
90
|
+
onClose();
|
|
91
|
+
} else {
|
|
92
|
+
throw new Error(result.error || 'Failed to send');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error('TesterFlow Error:', error);
|
|
97
|
+
Alert.alert('Error', 'Failed to send feedback. Please try again.');
|
|
98
|
+
} finally {
|
|
99
|
+
setIsSending(false);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
if (!visible) return null;
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
|
107
|
+
<KeyboardAvoidingView
|
|
108
|
+
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
109
|
+
style={styles.container}
|
|
110
|
+
>
|
|
111
|
+
<View style={styles.card}>
|
|
112
|
+
<View style={styles.header}>
|
|
113
|
+
<Text style={styles.title}>Report an Issue</Text>
|
|
114
|
+
<TouchableOpacity onPress={onClose}>
|
|
115
|
+
<Text style={styles.closeBtn}>✕</Text>
|
|
116
|
+
</TouchableOpacity>
|
|
117
|
+
</View>
|
|
118
|
+
|
|
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
|
+
)}
|
|
125
|
+
|
|
126
|
+
<TextInput
|
|
127
|
+
style={styles.input}
|
|
128
|
+
placeholder="What happened? Describe the bug..."
|
|
129
|
+
placeholderTextColor="#999"
|
|
130
|
+
multiline
|
|
131
|
+
value={description}
|
|
132
|
+
onChangeText={setDescription}
|
|
133
|
+
autoFocus
|
|
134
|
+
/>
|
|
135
|
+
|
|
136
|
+
<TouchableOpacity
|
|
137
|
+
style={[styles.submitBtn, isSending && styles.disabledBtn]}
|
|
138
|
+
onPress={sendFeedback}
|
|
139
|
+
disabled={isSending}
|
|
140
|
+
>
|
|
141
|
+
{isSending ? (
|
|
142
|
+
<ActivityIndicator color="#FFF" />
|
|
143
|
+
) : (
|
|
144
|
+
<Text style={styles.submitText}>Send Feedback</Text>
|
|
145
|
+
)}
|
|
146
|
+
</TouchableOpacity>
|
|
147
|
+
</View>
|
|
148
|
+
</KeyboardAvoidingView>
|
|
149
|
+
</Modal>
|
|
150
|
+
);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const styles = StyleSheet.create({
|
|
154
|
+
container: {
|
|
155
|
+
flex: 1,
|
|
156
|
+
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
157
|
+
justifyContent: 'flex-end',
|
|
158
|
+
},
|
|
159
|
+
card: {
|
|
160
|
+
backgroundColor: '#FFF',
|
|
161
|
+
borderTopLeftRadius: 20,
|
|
162
|
+
borderTopRightRadius: 20,
|
|
163
|
+
padding: 20,
|
|
164
|
+
minHeight: 400,
|
|
165
|
+
},
|
|
166
|
+
header: {
|
|
167
|
+
flexDirection: 'row',
|
|
168
|
+
justifyContent: 'space-between',
|
|
169
|
+
alignItems: 'center',
|
|
170
|
+
marginBottom: 20,
|
|
171
|
+
},
|
|
172
|
+
title: {
|
|
173
|
+
fontSize: 20,
|
|
174
|
+
fontWeight: 'bold',
|
|
175
|
+
color: '#000',
|
|
176
|
+
},
|
|
177
|
+
closeBtn: {
|
|
178
|
+
fontSize: 24,
|
|
179
|
+
color: '#999',
|
|
180
|
+
padding: 5,
|
|
181
|
+
},
|
|
182
|
+
previewContainer: {
|
|
183
|
+
height: 150,
|
|
184
|
+
backgroundColor: '#f0f0f0',
|
|
185
|
+
borderRadius: 10,
|
|
186
|
+
marginBottom: 15,
|
|
187
|
+
justifyContent: 'center',
|
|
188
|
+
alignItems: 'center',
|
|
189
|
+
overflow: 'hidden',
|
|
190
|
+
},
|
|
191
|
+
preview: {
|
|
192
|
+
width: '100%',
|
|
193
|
+
height: '100%',
|
|
194
|
+
},
|
|
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,
|
|
204
|
+
},
|
|
205
|
+
input: {
|
|
206
|
+
backgroundColor: '#f9f9f9',
|
|
207
|
+
borderRadius: 10,
|
|
208
|
+
padding: 15,
|
|
209
|
+
height: 120,
|
|
210
|
+
textAlignVertical: 'top',
|
|
211
|
+
fontSize: 16,
|
|
212
|
+
color: '#000',
|
|
213
|
+
marginBottom: 20,
|
|
214
|
+
},
|
|
215
|
+
submitBtn: {
|
|
216
|
+
backgroundColor: '#007AFF',
|
|
217
|
+
padding: 15,
|
|
218
|
+
borderRadius: 12,
|
|
219
|
+
alignItems: 'center',
|
|
220
|
+
},
|
|
221
|
+
disabledBtn: {
|
|
222
|
+
backgroundColor: '#ccc',
|
|
223
|
+
},
|
|
224
|
+
submitText: {
|
|
225
|
+
color: '#FFF',
|
|
226
|
+
fontSize: 16,
|
|
227
|
+
fontWeight: 'bold',
|
|
228
|
+
},
|
|
229
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { accelerometer, setUpdateIntervalForType, SensorTypes } from 'react-native-sensors';
|
|
2
|
+
import { map, filter } from 'rxjs/operators';
|
|
3
|
+
|
|
4
|
+
const SHAKE_THRESHOLD = 2.5; // g-force
|
|
5
|
+
const MIN_TIME_BETWEEN_SHAKES = 1000; // ms
|
|
6
|
+
|
|
7
|
+
export class ShakeDetector {
|
|
8
|
+
private subscription: any = null;
|
|
9
|
+
private lastShakeTime = 0;
|
|
10
|
+
private onShake: () => void;
|
|
11
|
+
|
|
12
|
+
constructor(onShake: () => void) {
|
|
13
|
+
this.onShake = onShake;
|
|
14
|
+
setUpdateIntervalForType(SensorTypes.accelerometer, 100); // 100ms interval
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
start() {
|
|
18
|
+
if (this.subscription) return;
|
|
19
|
+
|
|
20
|
+
this.subscription = accelerometer
|
|
21
|
+
.pipe(
|
|
22
|
+
map(({ x, y, z }) => Math.sqrt(x * x + y * y + z * z)),
|
|
23
|
+
filter(g => g > SHAKE_THRESHOLD)
|
|
24
|
+
)
|
|
25
|
+
.subscribe(() => {
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
if (now - this.lastShakeTime > MIN_TIME_BETWEEN_SHAKES) {
|
|
28
|
+
this.lastShakeTime = now;
|
|
29
|
+
console.log('TesterFlow: Shake detected!');
|
|
30
|
+
this.onShake();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
stop() {
|
|
36
|
+
if (this.subscription) {
|
|
37
|
+
this.subscription.unsubscribe();
|
|
38
|
+
this.subscription = null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React, { useEffect, useState, useRef } from 'react';
|
|
2
|
+
import { View, StyleSheet } from 'react-native';
|
|
3
|
+
import { captureScreen } from 'react-native-view-shot';
|
|
4
|
+
import { ShakeDetector } from './ShakeDetector';
|
|
5
|
+
import { FeedbackModal } from './FeedbackModal';
|
|
6
|
+
|
|
7
|
+
interface IInstallProps {
|
|
8
|
+
apiKey: string;
|
|
9
|
+
apiEndpoint?: string; // e.g. https://iinstall.app
|
|
10
|
+
children?: React.ReactNode;
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const IInstall: React.FC<IInstallProps> = ({
|
|
15
|
+
apiKey,
|
|
16
|
+
apiEndpoint = 'https://iinstall.app',
|
|
17
|
+
children,
|
|
18
|
+
enabled = true
|
|
19
|
+
}) => {
|
|
20
|
+
const [modalVisible, setModalVisible] = useState(false);
|
|
21
|
+
const [screenshotUri, setScreenshotUri] = useState<string | null>(null);
|
|
22
|
+
const shakeDetectorRef = useRef<ShakeDetector | null>(null);
|
|
23
|
+
|
|
24
|
+
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]);
|
|
34
|
+
|
|
35
|
+
const handleShake = async () => {
|
|
36
|
+
if (modalVisible) return; // Already open
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
// Capture screenshot
|
|
40
|
+
const uri = await captureScreen({
|
|
41
|
+
format: 'png',
|
|
42
|
+
quality: 0.8,
|
|
43
|
+
});
|
|
44
|
+
setScreenshotUri(uri);
|
|
45
|
+
setModalVisible(true);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error('IInstall: Failed to capture screenshot', error);
|
|
48
|
+
// Still show modal without screenshot
|
|
49
|
+
setScreenshotUri(null);
|
|
50
|
+
setModalVisible(true);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<View style={StyleSheet.absoluteFill}>
|
|
56
|
+
{children}
|
|
57
|
+
<FeedbackModal
|
|
58
|
+
visible={modalVisible}
|
|
59
|
+
onClose={() => setModalVisible(false)}
|
|
60
|
+
screenshotUri={screenshotUri}
|
|
61
|
+
apiKey={apiKey}
|
|
62
|
+
apiEndpoint={apiEndpoint}
|
|
63
|
+
/>
|
|
64
|
+
</View>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export default IInstall;
|