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,400 @@
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TextInput,
6
+ TouchableOpacity,
7
+ StyleSheet,
8
+ ActivityIndicator,
9
+ SafeAreaView,
10
+ ScrollView,
11
+ } from 'react-native';
12
+ import { YaverDiscovery, DiscoveryResult } from './Discovery';
13
+ import { YaverFeedback } from './YaverFeedback';
14
+
15
+ /**
16
+ * Full-screen connection UI for discovering and connecting to a Yaver agent.
17
+ *
18
+ * Shows connection status, auto-discovery, manual URL entry, and a
19
+ * Start/Stop testing toggle with recording timer.
20
+ *
21
+ * Usage:
22
+ * ```tsx
23
+ * {__DEV__ && <YaverConnectionScreen />}
24
+ * ```
25
+ */
26
+ export const YaverConnectionScreen: React.FC = () => {
27
+ const [url, setUrl] = useState('');
28
+ const [token, setToken] = useState('');
29
+ const [connected, setConnected] = useState(false);
30
+ const [hostname, setHostname] = useState('');
31
+ const [version, setVersion] = useState('');
32
+ const [latency, setLatency] = useState<number | null>(null);
33
+ const [discovering, setDiscovering] = useState(false);
34
+ const [connecting, setConnecting] = useState(false);
35
+ const [recording, setRecording] = useState(false);
36
+ const [recordingTime, setRecordingTime] = useState(0);
37
+ const [error, setError] = useState<string | null>(null);
38
+
39
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
40
+
41
+ // Auto-discover on mount
42
+ useEffect(() => {
43
+ handleDiscover();
44
+ return () => {
45
+ if (timerRef.current) {
46
+ clearInterval(timerRef.current);
47
+ }
48
+ };
49
+ }, []);
50
+
51
+ // Recording timer
52
+ useEffect(() => {
53
+ if (recording) {
54
+ setRecordingTime(0);
55
+ timerRef.current = setInterval(() => {
56
+ setRecordingTime((prev) => prev + 1);
57
+ }, 1000);
58
+ } else {
59
+ if (timerRef.current) {
60
+ clearInterval(timerRef.current);
61
+ timerRef.current = null;
62
+ }
63
+ setRecordingTime(0);
64
+ }
65
+ }, [recording]);
66
+
67
+ const applyResult = (result: DiscoveryResult) => {
68
+ setUrl(result.url);
69
+ setHostname(result.hostname);
70
+ setVersion(result.version);
71
+ setLatency(result.latency);
72
+ setConnected(true);
73
+ setError(null);
74
+
75
+ // Update YaverFeedback config if initialized
76
+ const config = YaverFeedback.getConfig();
77
+ if (config) {
78
+ YaverFeedback.init({ ...config, agentUrl: result.url });
79
+ }
80
+ };
81
+
82
+ const handleDiscover = async () => {
83
+ setDiscovering(true);
84
+ setError(null);
85
+
86
+ try {
87
+ const result = await YaverDiscovery.discover();
88
+ if (result) {
89
+ applyResult(result);
90
+ } else {
91
+ setConnected(false);
92
+ setError('No agent found on the local network.');
93
+ }
94
+ } catch (err) {
95
+ setConnected(false);
96
+ setError(`Discovery failed: ${String(err)}`);
97
+ } finally {
98
+ setDiscovering(false);
99
+ }
100
+ };
101
+
102
+ const handleConnect = async () => {
103
+ if (!url.trim()) {
104
+ setError('Enter an agent URL.');
105
+ return;
106
+ }
107
+
108
+ setConnecting(true);
109
+ setError(null);
110
+
111
+ try {
112
+ const result = await YaverDiscovery.connect(url.trim());
113
+ if (result) {
114
+ applyResult(result);
115
+ } else {
116
+ setConnected(false);
117
+ setError('Could not connect to agent at that URL.');
118
+ }
119
+ } catch (err) {
120
+ setConnected(false);
121
+ setError(`Connection failed: ${String(err)}`);
122
+ } finally {
123
+ setConnecting(false);
124
+ }
125
+ };
126
+
127
+ const handleToggleRecording = () => {
128
+ if (!connected) {
129
+ setError('Connect to an agent first.');
130
+ return;
131
+ }
132
+
133
+ if (recording) {
134
+ // Stop & send
135
+ setRecording(false);
136
+ YaverFeedback.startReport();
137
+ } else {
138
+ // Start testing
139
+ setRecording(true);
140
+
141
+ // Initialize SDK with current URL/token if not already done
142
+ if (!YaverFeedback.isInitialized()) {
143
+ YaverFeedback.init({
144
+ agentUrl: url,
145
+ authToken: token,
146
+ trigger: 'manual',
147
+ });
148
+ }
149
+ }
150
+ };
151
+
152
+ const formatTime = (seconds: number): string => {
153
+ const mins = Math.floor(seconds / 60);
154
+ const secs = seconds % 60;
155
+ return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
156
+ };
157
+
158
+ return (
159
+ <SafeAreaView style={styles.container}>
160
+ <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
161
+ {/* Header */}
162
+ <Text style={styles.title}>Yaver Agent</Text>
163
+
164
+ {/* Connection status */}
165
+ <View style={styles.statusRow}>
166
+ <View style={[styles.statusDot, connected ? styles.statusConnected : styles.statusDisconnected]} />
167
+ <Text style={styles.statusText}>
168
+ {connected
169
+ ? `Connected to ${hostname}`
170
+ : 'Not connected'}
171
+ </Text>
172
+ </View>
173
+
174
+ {connected && (
175
+ <View style={styles.infoRow}>
176
+ <Text style={styles.infoText}>v{version}</Text>
177
+ {latency !== null && (
178
+ <Text style={styles.infoText}>{latency}ms</Text>
179
+ )}
180
+ </View>
181
+ )}
182
+
183
+ {/* URL input */}
184
+ <Text style={styles.label}>Agent URL</Text>
185
+ <TextInput
186
+ style={styles.input}
187
+ value={url}
188
+ onChangeText={setUrl}
189
+ placeholder="http://192.168.1.10:18080"
190
+ placeholderTextColor="#666"
191
+ autoCapitalize="none"
192
+ autoCorrect={false}
193
+ keyboardType="url"
194
+ />
195
+
196
+ {/* Token input */}
197
+ <Text style={styles.label}>Auth Token</Text>
198
+ <TextInput
199
+ style={styles.input}
200
+ value={token}
201
+ onChangeText={setToken}
202
+ placeholder="your-auth-token"
203
+ placeholderTextColor="#666"
204
+ autoCapitalize="none"
205
+ autoCorrect={false}
206
+ secureTextEntry
207
+ />
208
+
209
+ {/* Action buttons */}
210
+ <View style={styles.buttonRow}>
211
+ <TouchableOpacity
212
+ style={[styles.button, styles.discoverButton]}
213
+ onPress={handleDiscover}
214
+ disabled={discovering}
215
+ >
216
+ {discovering ? (
217
+ <ActivityIndicator color="#e0e0e0" size="small" />
218
+ ) : (
219
+ <Text style={styles.buttonText}>Auto-discover</Text>
220
+ )}
221
+ </TouchableOpacity>
222
+
223
+ <TouchableOpacity
224
+ style={[styles.button, styles.connectButton]}
225
+ onPress={handleConnect}
226
+ disabled={connecting}
227
+ >
228
+ {connecting ? (
229
+ <ActivityIndicator color="#fff" size="small" />
230
+ ) : (
231
+ <Text style={styles.buttonText}>Connect</Text>
232
+ )}
233
+ </TouchableOpacity>
234
+ </View>
235
+
236
+ {/* Error */}
237
+ {error && <Text style={styles.error}>{error}</Text>}
238
+
239
+ {/* Recording indicator */}
240
+ {recording && (
241
+ <View style={styles.recordingIndicator}>
242
+ <View style={styles.recordingDot} />
243
+ <Text style={styles.recordingText}>
244
+ Recording {formatTime(recordingTime)}
245
+ </Text>
246
+ </View>
247
+ )}
248
+
249
+ {/* Start/Stop toggle */}
250
+ <TouchableOpacity
251
+ style={[
252
+ styles.toggleButton,
253
+ recording ? styles.toggleStop : styles.toggleStart,
254
+ !connected && styles.toggleDisabled,
255
+ ]}
256
+ onPress={handleToggleRecording}
257
+ disabled={!connected}
258
+ >
259
+ <Text style={styles.toggleText}>
260
+ {recording ? 'Stop & Send' : 'Start Testing'}
261
+ </Text>
262
+ </TouchableOpacity>
263
+ </ScrollView>
264
+ </SafeAreaView>
265
+ );
266
+ };
267
+
268
+ const styles = StyleSheet.create({
269
+ container: {
270
+ flex: 1,
271
+ backgroundColor: '#1a1a2e',
272
+ },
273
+ content: {
274
+ padding: 24,
275
+ paddingTop: 16,
276
+ },
277
+ title: {
278
+ fontSize: 24,
279
+ fontWeight: '700',
280
+ color: '#e0e0e0',
281
+ marginBottom: 20,
282
+ },
283
+ statusRow: {
284
+ flexDirection: 'row',
285
+ alignItems: 'center',
286
+ marginBottom: 8,
287
+ },
288
+ statusDot: {
289
+ width: 10,
290
+ height: 10,
291
+ borderRadius: 5,
292
+ marginRight: 10,
293
+ },
294
+ statusConnected: {
295
+ backgroundColor: '#22c55e',
296
+ },
297
+ statusDisconnected: {
298
+ backgroundColor: '#ef4444',
299
+ },
300
+ statusText: {
301
+ color: '#e0e0e0',
302
+ fontSize: 15,
303
+ },
304
+ infoRow: {
305
+ flexDirection: 'row',
306
+ gap: 16,
307
+ marginBottom: 20,
308
+ paddingLeft: 20,
309
+ },
310
+ infoText: {
311
+ color: '#888',
312
+ fontSize: 13,
313
+ },
314
+ label: {
315
+ color: '#999',
316
+ fontSize: 13,
317
+ marginBottom: 6,
318
+ marginTop: 12,
319
+ },
320
+ input: {
321
+ backgroundColor: 'rgba(255,255,255,0.08)',
322
+ borderWidth: 1,
323
+ borderColor: 'rgba(255,255,255,0.15)',
324
+ borderRadius: 10,
325
+ paddingHorizontal: 14,
326
+ paddingVertical: 12,
327
+ color: '#e0e0e0',
328
+ fontSize: 15,
329
+ },
330
+ buttonRow: {
331
+ flexDirection: 'row',
332
+ gap: 12,
333
+ marginTop: 20,
334
+ },
335
+ button: {
336
+ flex: 1,
337
+ paddingVertical: 14,
338
+ borderRadius: 10,
339
+ alignItems: 'center',
340
+ justifyContent: 'center',
341
+ minHeight: 48,
342
+ },
343
+ discoverButton: {
344
+ backgroundColor: 'rgba(99,102,241,0.2)',
345
+ borderWidth: 1,
346
+ borderColor: 'rgba(99,102,241,0.4)',
347
+ },
348
+ connectButton: {
349
+ backgroundColor: '#6366f1',
350
+ },
351
+ buttonText: {
352
+ color: '#e0e0e0',
353
+ fontSize: 15,
354
+ fontWeight: '600',
355
+ },
356
+ error: {
357
+ color: '#ef4444',
358
+ fontSize: 13,
359
+ marginTop: 12,
360
+ },
361
+ recordingIndicator: {
362
+ flexDirection: 'row',
363
+ alignItems: 'center',
364
+ justifyContent: 'center',
365
+ marginTop: 24,
366
+ },
367
+ recordingDot: {
368
+ width: 10,
369
+ height: 10,
370
+ borderRadius: 5,
371
+ backgroundColor: '#ef4444',
372
+ marginRight: 8,
373
+ },
374
+ recordingText: {
375
+ color: '#ef4444',
376
+ fontSize: 16,
377
+ fontWeight: '600',
378
+ fontVariant: ['tabular-nums'],
379
+ },
380
+ toggleButton: {
381
+ marginTop: 24,
382
+ paddingVertical: 18,
383
+ borderRadius: 14,
384
+ alignItems: 'center',
385
+ },
386
+ toggleStart: {
387
+ backgroundColor: '#22c55e',
388
+ },
389
+ toggleStop: {
390
+ backgroundColor: '#ef4444',
391
+ },
392
+ toggleDisabled: {
393
+ opacity: 0.4,
394
+ },
395
+ toggleText: {
396
+ color: '#fff',
397
+ fontSize: 18,
398
+ fontWeight: '700',
399
+ },
400
+ });
@@ -0,0 +1,223 @@
1
+ import AsyncStorage from '@react-native-async-storage/async-storage';
2
+
3
+ const STORAGE_KEY = 'yaver_feedback_agent';
4
+ const DEFAULT_PORT = 18080;
5
+ const TIMEOUT_MS = 2000;
6
+
7
+ export interface DiscoveryResult {
8
+ url: string;
9
+ hostname: string;
10
+ version: string;
11
+ latency: number;
12
+ }
13
+
14
+ // Common LAN subnets and host suffixes to scan
15
+ const SUBNETS = ['192.168.1', '192.168.0', '10.0.0', '10.0.1'];
16
+ const HOST_SUFFIXES = [1, 2, 50, 100, 101, 200];
17
+
18
+ /**
19
+ * Device discovery for finding Yaver agents on the local network or via Convex.
20
+ *
21
+ * Three discovery strategies (tried in order):
22
+ * 1. **Convex cloud** — fetch agent IP from Convex `/devices/list` (for cloud machines)
23
+ * 2. **Stored connection** — try cached URL from last successful connection
24
+ * 3. **LAN scan** — probe common LAN IPs via `/health` endpoint
25
+ */
26
+ export class YaverDiscovery {
27
+ /**
28
+ * Discover an agent. Tries Convex cloud first (if configured),
29
+ * then stored connection, then LAN scan.
30
+ */
31
+ static async discover(options?: {
32
+ convexUrl?: string;
33
+ authToken?: string;
34
+ preferredDeviceId?: string;
35
+ }): Promise<DiscoveryResult | null> {
36
+ // Strategy 1: Convex cloud discovery (for cloud machines)
37
+ if (options?.convexUrl && options?.authToken) {
38
+ const result = await YaverDiscovery.discoverFromConvex(
39
+ options.convexUrl,
40
+ options.authToken,
41
+ options.preferredDeviceId,
42
+ );
43
+ if (result) {
44
+ await YaverDiscovery.store(result);
45
+ return result;
46
+ }
47
+ }
48
+
49
+ // Strategy 2: Try stored connection
50
+ const stored = await YaverDiscovery.getStored();
51
+ if (stored) {
52
+ const result = await YaverDiscovery.probe(stored.url);
53
+ if (result) {
54
+ return result;
55
+ }
56
+ await YaverDiscovery.clear();
57
+ }
58
+
59
+ // Strategy 3: Scan common LAN IPs in parallel
60
+ const candidates: string[] = [];
61
+ for (const subnet of SUBNETS) {
62
+ for (const suffix of HOST_SUFFIXES) {
63
+ candidates.push(`http://${subnet}.${suffix}:${DEFAULT_PORT}`);
64
+ }
65
+ }
66
+
67
+ const results = await Promise.allSettled(
68
+ candidates.map((url) => YaverDiscovery.probe(url)),
69
+ );
70
+
71
+ for (const r of results) {
72
+ if (r.status === 'fulfilled' && r.value) {
73
+ await YaverDiscovery.store(r.value);
74
+ return r.value;
75
+ }
76
+ }
77
+
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Fetch the agent URL from Convex device list or cloud machines.
83
+ * No hardcoded IPs needed — Convex knows where the agent is.
84
+ */
85
+ static async discoverFromConvex(
86
+ convexUrl: string,
87
+ authToken: string,
88
+ preferredDeviceId?: string,
89
+ ): Promise<DiscoveryResult | null> {
90
+ const base = convexUrl.replace(/\/$/, '');
91
+
92
+ try {
93
+ // Try cloud machines first (CPU/GPU managed machines)
94
+ const machinesRes = await fetch(`${base}/machines`, {
95
+ headers: { Authorization: `Bearer ${authToken}` },
96
+ });
97
+
98
+ if (machinesRes.ok) {
99
+ const { machines } = await machinesRes.json();
100
+ const activeMachine = (machines ?? []).find(
101
+ (m: { status: string; serverIp?: string }) => m.status === 'active' && m.serverIp,
102
+ );
103
+ if (activeMachine?.serverIp) {
104
+ const url = `http://${activeMachine.serverIp}:${DEFAULT_PORT}`;
105
+ const probed = await YaverDiscovery.probe(url);
106
+ if (probed) return probed;
107
+ }
108
+ }
109
+
110
+ // Fall back to device list (personal machines registered with Convex)
111
+ const devicesRes = await fetch(`${base}/devices/list`, {
112
+ headers: { Authorization: `Bearer ${authToken}` },
113
+ });
114
+
115
+ if (!devicesRes.ok) return null;
116
+ const data = await devicesRes.json();
117
+ const devices = data.devices ?? data ?? [];
118
+
119
+ // Find preferred device or first online one
120
+ const target = preferredDeviceId
121
+ ? devices.find((d: { deviceId: string }) => d.deviceId === preferredDeviceId)
122
+ : devices.find((d: { isOnline: boolean }) => d.isOnline);
123
+
124
+ if (!target?.quicHost) return null;
125
+
126
+ const port = target.httpPort ?? DEFAULT_PORT;
127
+ const url = `http://${target.quicHost}:${port}`;
128
+ return await YaverDiscovery.probe(url);
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Probe a specific URL for a running Yaver agent.
136
+ * Hits the `/health` endpoint with a 2s timeout.
137
+ */
138
+ static async probe(url: string): Promise<DiscoveryResult | null> {
139
+ const base = url.replace(/\/$/, '');
140
+ const start = Date.now();
141
+
142
+ try {
143
+ const controller = new AbortController();
144
+ const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
145
+
146
+ const response = await fetch(`${base}/health`, {
147
+ method: 'GET',
148
+ signal: controller.signal,
149
+ });
150
+
151
+ clearTimeout(timeoutId);
152
+
153
+ if (!response.ok) {
154
+ return null;
155
+ }
156
+
157
+ const latency = Date.now() - start;
158
+
159
+ let hostname = 'Unknown';
160
+ let version = 'unknown';
161
+
162
+ try {
163
+ const data = await response.json();
164
+ hostname = data.hostname ?? data.name ?? 'Unknown';
165
+ version = data.version ?? 'unknown';
166
+ } catch {
167
+ // Health endpoint might return plain text — that's fine
168
+ }
169
+
170
+ return { url: base, hostname, version, latency };
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Manually connect to a specific agent URL.
178
+ * Probes the URL and stores the connection if successful.
179
+ */
180
+ static async connect(url: string): Promise<DiscoveryResult | null> {
181
+ const result = await YaverDiscovery.probe(url);
182
+ if (result) {
183
+ await YaverDiscovery.store(result);
184
+ }
185
+ return result;
186
+ }
187
+
188
+ /** Get the cached agent connection from AsyncStorage. */
189
+ static async getStored(): Promise<{ url: string; hostname: string } | null> {
190
+ try {
191
+ const raw = await AsyncStorage.getItem(STORAGE_KEY);
192
+ if (!raw) return null;
193
+ const parsed = JSON.parse(raw);
194
+ if (parsed && typeof parsed.url === 'string') {
195
+ return { url: parsed.url, hostname: parsed.hostname ?? 'Unknown' };
196
+ }
197
+ return null;
198
+ } catch {
199
+ return null;
200
+ }
201
+ }
202
+
203
+ /** Store a successful discovery result in AsyncStorage. */
204
+ static async store(result: DiscoveryResult): Promise<void> {
205
+ try {
206
+ await AsyncStorage.setItem(
207
+ STORAGE_KEY,
208
+ JSON.stringify({ url: result.url, hostname: result.hostname }),
209
+ );
210
+ } catch {
211
+ // Storage failure is non-fatal
212
+ }
213
+ }
214
+
215
+ /** Clear the stored agent connection. */
216
+ static async clear(): Promise<void> {
217
+ try {
218
+ await AsyncStorage.removeItem(STORAGE_KEY);
219
+ } catch {
220
+ // Storage failure is non-fatal
221
+ }
222
+ }
223
+ }