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.
- package/LICENSE +201 -0
- package/README.md +29 -11
- package/android/src/main/java/io/yaver/feedback/YaverHotReloadModule.java +188 -0
- package/android/src/main/java/io/yaver/feedback/YaverHotReloadPackage.java +33 -0
- package/app.plugin.js +263 -5
- package/ios/YaverHotReload.m +16 -0
- package/ios/YaverHotReload.swift +112 -0
- package/package.json +24 -7
- package/src/AuthOverlay.tsx +97 -0
- package/src/BlackBox.ts +41 -2
- package/src/Discovery.ts +141 -8
- package/src/FeedbackModal.tsx +9 -2
- package/src/FloatingButton.tsx +19 -7
- package/src/LoginScreen.tsx +395 -0
- package/src/MachinePickerScreen.tsx +196 -0
- package/src/P2PClient.ts +110 -0
- package/src/YaverFeedback.ts +363 -14
- package/src/YaverUpdates.ts +334 -0
- package/src/__tests__/Discovery.test.ts +8 -2
- package/src/__tests__/YaverFeedback.test.ts +4 -1
- package/src/auth.ts +338 -0
- package/src/index.ts +36 -0
- package/src/types.ts +21 -2
|
@@ -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}`, {
|