yaver-feedback-react-native 0.3.0 → 0.5.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,196 @@
1
+ import React, { useCallback, useEffect, useState } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TouchableOpacity,
6
+ StyleSheet,
7
+ ActivityIndicator,
8
+ SafeAreaView,
9
+ ScrollView,
10
+ RefreshControl,
11
+ } from 'react-native';
12
+ import {
13
+ DeviceList,
14
+ RemoteDevice,
15
+ listReachableDevices,
16
+ saveSelectedDeviceId,
17
+ } from './auth';
18
+
19
+ export interface YaverMachinePickerProps {
20
+ token: string;
21
+ /** Currently-selected deviceId (from config / cache) — highlighted. */
22
+ currentDeviceId?: string;
23
+ onPick: (device: RemoteDevice) => void;
24
+ onCancel?: () => void;
25
+ }
26
+
27
+ /**
28
+ * List of remote dev machines the signed-in user can reach. Split into
29
+ * - Owned machines (user is the host)
30
+ * - Shared machines (host invited them as a guest)
31
+ *
32
+ * Tapping a device persists it to AsyncStorage and invokes `onPick`. The
33
+ * SDK then uses that device's deviceId for agent discovery (LAN probe +
34
+ * relay fallback through Convex).
35
+ */
36
+ export const YaverMachinePickerScreen: React.FC<YaverMachinePickerProps> = ({
37
+ token,
38
+ currentDeviceId,
39
+ onPick,
40
+ onCancel,
41
+ }) => {
42
+ const [loading, setLoading] = useState(true);
43
+ const [refreshing, setRefreshing] = useState(false);
44
+ const [error, setError] = useState<string | null>(null);
45
+ const [list, setList] = useState<DeviceList>({ owned: [], shared: [] });
46
+
47
+ const load = useCallback(async (silent = false) => {
48
+ if (!silent) setLoading(true);
49
+ setError(null);
50
+ try {
51
+ const result = await listReachableDevices(token);
52
+ setList(result);
53
+ if (result.owned.length === 0 && result.shared.length === 0) {
54
+ setError('Hiç makine bulunamadı — önce bir makinede `yaver auth` + `yaver serve` çalıştır.');
55
+ }
56
+ } catch (err) {
57
+ setError(err instanceof Error ? err.message : String(err));
58
+ } finally {
59
+ setLoading(false);
60
+ setRefreshing(false);
61
+ }
62
+ }, [token]);
63
+
64
+ useEffect(() => {
65
+ void load();
66
+ }, [load]);
67
+
68
+ const handlePick = async (device: RemoteDevice) => {
69
+ await saveSelectedDeviceId(device.deviceId);
70
+ onPick(device);
71
+ };
72
+
73
+ const renderDevice = (device: RemoteDevice) => {
74
+ const selected = device.deviceId === currentDeviceId;
75
+ const stale = Date.now() - device.lastHeartbeat > 60_000;
76
+ const healthColor = !device.isOnline
77
+ ? '#ef4444'
78
+ : device.needsAuth || device.runnerDown || stale
79
+ ? '#f59e0b'
80
+ : '#22c55e';
81
+ return (
82
+ <TouchableOpacity
83
+ key={device.deviceId}
84
+ style={[styles.deviceRow, selected && styles.deviceSelected]}
85
+ onPress={() => handlePick(device)}
86
+ >
87
+ <View style={[styles.health, { backgroundColor: healthColor }]} />
88
+ <View style={{ flex: 1 }}>
89
+ <Text style={styles.deviceName}>{device.name || device.deviceId}</Text>
90
+ <Text style={styles.deviceMeta}>
91
+ {device.platform}
92
+ {device.isGuest && device.hostEmail ? ` • ${device.hostEmail}` : ''}
93
+ {device.accessScope === 'shared-scoped' ? ' • paylaşılan' : ''}
94
+ </Text>
95
+ </View>
96
+ {selected && <Text style={styles.selectedBadge}>seçili</Text>}
97
+ </TouchableOpacity>
98
+ );
99
+ };
100
+
101
+ return (
102
+ <SafeAreaView style={styles.container}>
103
+ <View style={styles.header}>
104
+ <Text style={styles.title}>Makine Seç</Text>
105
+ {onCancel && (
106
+ <TouchableOpacity onPress={onCancel} style={styles.cancel}>
107
+ <Text style={styles.cancelText}>Kapat</Text>
108
+ </TouchableOpacity>
109
+ )}
110
+ </View>
111
+
112
+ <ScrollView
113
+ contentContainerStyle={styles.content}
114
+ refreshControl={
115
+ <RefreshControl
116
+ refreshing={refreshing}
117
+ onRefresh={() => {
118
+ setRefreshing(true);
119
+ void load(true);
120
+ }}
121
+ tintColor="#6366f1"
122
+ />
123
+ }
124
+ >
125
+ {loading ? (
126
+ <ActivityIndicator color="#6366f1" style={{ marginTop: 60 }} />
127
+ ) : (
128
+ <>
129
+ {list.owned.length > 0 && (
130
+ <View style={styles.section}>
131
+ <Text style={styles.sectionTitle}>Kendi makinelerim</Text>
132
+ {list.owned.map(renderDevice)}
133
+ </View>
134
+ )}
135
+ {list.shared.length > 0 && (
136
+ <View style={styles.section}>
137
+ <Text style={styles.sectionTitle}>Paylaşılan (guest)</Text>
138
+ {list.shared.map(renderDevice)}
139
+ </View>
140
+ )}
141
+ {error && <Text style={styles.error}>{error}</Text>}
142
+ </>
143
+ )}
144
+ </ScrollView>
145
+ </SafeAreaView>
146
+ );
147
+ };
148
+
149
+ const styles = StyleSheet.create({
150
+ container: { flex: 1, backgroundColor: '#1a1a2e' },
151
+ header: {
152
+ flexDirection: 'row',
153
+ alignItems: 'center',
154
+ justifyContent: 'space-between',
155
+ paddingHorizontal: 20,
156
+ paddingVertical: 16,
157
+ },
158
+ title: { color: '#e0e0e0', fontSize: 20, fontWeight: '700' },
159
+ cancel: { padding: 8 },
160
+ cancelText: { color: '#9ca3af', fontSize: 14 },
161
+ content: { padding: 20, paddingTop: 0 },
162
+ section: { marginBottom: 24 },
163
+ sectionTitle: {
164
+ color: '#9ca3af',
165
+ fontSize: 12,
166
+ fontWeight: '600',
167
+ textTransform: 'uppercase',
168
+ letterSpacing: 1,
169
+ marginBottom: 10,
170
+ },
171
+ deviceRow: {
172
+ flexDirection: 'row',
173
+ alignItems: 'center',
174
+ gap: 12,
175
+ padding: 14,
176
+ backgroundColor: 'rgba(255,255,255,0.05)',
177
+ borderRadius: 12,
178
+ marginBottom: 8,
179
+ borderWidth: 1,
180
+ borderColor: 'transparent',
181
+ },
182
+ deviceSelected: {
183
+ borderColor: 'rgba(99,102,241,0.5)',
184
+ backgroundColor: 'rgba(99,102,241,0.15)',
185
+ },
186
+ health: { width: 10, height: 10, borderRadius: 5 },
187
+ deviceName: { color: '#e0e0e0', fontSize: 15, fontWeight: '600' },
188
+ deviceMeta: { color: '#9ca3af', fontSize: 12, marginTop: 2 },
189
+ selectedBadge: {
190
+ color: '#a5b4fc',
191
+ fontSize: 11,
192
+ fontWeight: '700',
193
+ textTransform: 'uppercase',
194
+ },
195
+ error: { color: '#ef4444', fontSize: 13, marginTop: 16, textAlign: 'center' },
196
+ });
package/src/P2PClient.ts CHANGED
@@ -308,6 +308,116 @@ export class P2PClient {
308
308
  return { token: result.token, expiresAt: result.expiresAt };
309
309
  }
310
310
 
311
+ // ─── Feature flags (F1) ──────────────────────────────────────────
312
+
313
+ /**
314
+ * Evaluate every flag for a userId. Hits /flags/eval which uses
315
+ * SHA256 bucketing against rolloutPercent — stable per user per
316
+ * flag. Results are the dev's source of truth; the SDK caches
317
+ * for 30s in getFlagsCached().
318
+ */
319
+ async flagsEvaluate(userId: string = 'anonymous'): Promise<Record<string, unknown>> {
320
+ const res = await fetch(
321
+ `${this.baseUrl}/flags/eval?userId=${encodeURIComponent(userId)}`,
322
+ { headers: { Authorization: `Bearer ${this.authToken}` } },
323
+ );
324
+ if (!res.ok) return {};
325
+ const data = await res.json();
326
+ return data.flags ?? {};
327
+ }
328
+
329
+ /** Evaluate a single flag by key — shortcut when you only need one. */
330
+ async flagsEvaluateOne<T = unknown>(
331
+ key: string,
332
+ userId: string = 'anonymous',
333
+ ): Promise<T | undefined> {
334
+ const res = await fetch(
335
+ `${this.baseUrl}/flags/eval?userId=${encodeURIComponent(userId)}&flag=${encodeURIComponent(key)}`,
336
+ { headers: { Authorization: `Bearer ${this.authToken}` } },
337
+ );
338
+ if (!res.ok) return undefined;
339
+ const data = await res.json();
340
+ return data.value as T;
341
+ }
342
+
343
+ // ─── Releases (R1) ───────────────────────────────────────────────
344
+
345
+ /**
346
+ * Ask what bundle this device should run. Returns the latest
347
+ * release in the channel plus a rollout gate. The mobile app
348
+ * uses this on cold start to decide whether to download a new
349
+ * bundle from /releases/bundle.
350
+ */
351
+ async releasesLatest(
352
+ channel: string = 'production',
353
+ deviceId?: string,
354
+ ): Promise<{
355
+ ok: boolean;
356
+ channel: string;
357
+ semver?: string;
358
+ size?: number;
359
+ md5?: string;
360
+ hermesBcVersion?: number;
361
+ bundleUrl?: string;
362
+ rolloutPercent: number;
363
+ inRollout: boolean;
364
+ reason?: string;
365
+ } | null> {
366
+ const params = new URLSearchParams({ channel });
367
+ if (deviceId) params.set('device', deviceId);
368
+ const res = await fetch(`${this.baseUrl}/releases/latest?${params.toString()}`, {
369
+ headers: { Authorization: `Bearer ${this.authToken}` },
370
+ });
371
+ if (!res.ok) return null;
372
+ return res.json();
373
+ }
374
+
375
+ /** Download a specific bundle as raw bytes. */
376
+ async releasesDownload(
377
+ channel: string,
378
+ semver: string,
379
+ ): Promise<ArrayBuffer | null> {
380
+ const params = new URLSearchParams({ channel, semver });
381
+ const res = await fetch(`${this.baseUrl}/releases/bundle?${params.toString()}`, {
382
+ headers: { Authorization: `Bearer ${this.authToken}` },
383
+ });
384
+ if (!res.ok) return null;
385
+ return res.arrayBuffer();
386
+ }
387
+
388
+ // ─── Analytics ingest (A1 — direct POST path) ───────────────────
389
+
390
+ /**
391
+ * Fire-and-forget track event. Most callers should use
392
+ * `BlackBox.track()` which fans through the streaming channel;
393
+ * this method is the fallback for surfaces without a live SSE.
394
+ */
395
+ async analyticsIngest(
396
+ name: string,
397
+ props?: Record<string, string>,
398
+ opts?: { deviceId?: string; route?: string; timestamp?: number },
399
+ ): Promise<boolean> {
400
+ try {
401
+ const res = await fetch(`${this.baseUrl}/analytics/ingest`, {
402
+ method: 'POST',
403
+ headers: {
404
+ Authorization: `Bearer ${this.authToken}`,
405
+ 'Content-Type': 'application/json',
406
+ },
407
+ body: JSON.stringify({
408
+ name,
409
+ props,
410
+ deviceId: opts?.deviceId,
411
+ route: opts?.route,
412
+ timestamp: opts?.timestamp ?? Date.now(),
413
+ }),
414
+ });
415
+ return res.ok;
416
+ } catch {
417
+ return false;
418
+ }
419
+ }
420
+
311
421
  /** Internal helper for authenticated GET/POST requests. */
312
422
  private async request(method: string, path: string): Promise<Response> {
313
423
  const response = await fetch(`${this.baseUrl}${path}`, {