yaver-feedback-react-native 0.8.1 → 0.8.3

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.
@@ -9,13 +9,18 @@ import {
9
9
  setStrictNativeAuth,
10
10
  getToken,
11
11
  getSelectedDeviceId,
12
+ listReachableDevices,
13
+ mintGuestSdkToken,
12
14
  clearToken,
13
15
  clearSelectedDeviceId,
14
16
  DEFAULT_CONVEX_SITE_URL,
15
17
  } from './auth';
16
18
  import {
17
19
  getQuickIconDisabled,
20
+ getQuickIconColorPreset,
18
21
  setQuickIconDisabled,
22
+ setQuickIconColorPreset,
23
+ QuickIconColorPreset,
19
24
  } from './preferences';
20
25
 
21
26
  // Is this JS runtime the Yaver mobile app's super-host bridge? The
@@ -45,6 +50,7 @@ let config: FeedbackConfig | null = null;
45
50
  let enabled = false;
46
51
  let p2pClient: P2PClient | null = null;
47
52
  let shakeDetector: ShakeDetector | null = null;
53
+ let p2pAuthToken: string | null = null;
48
54
 
49
55
  /** Ring buffer of captured errors. */
50
56
  let errorBuffer: CapturedError[] = [];
@@ -73,6 +79,44 @@ const flagCache: Map<string, { value: unknown; at: number }> = new Map();
73
79
  * Call `YaverFeedback.init()` once at app startup.
74
80
  */
75
81
  export class YaverFeedback {
82
+ private static async resolveP2PAuthToken(): Promise<string | null> {
83
+ if (!config?.authToken) return null;
84
+ if (!config.preferredDeviceId) return config.authToken;
85
+ const devices = await listReachableDevices(config.authToken);
86
+ const all = [...devices.owned, ...devices.shared];
87
+ const selected = all.find((device) => device.deviceId === config?.preferredDeviceId);
88
+ if (!selected || !selected.isGuest || selected.accessScope !== 'shared-scoped') {
89
+ return config.authToken;
90
+ }
91
+ if (!selected.hostUserId) {
92
+ return config.authToken;
93
+ }
94
+ const delegated = await mintGuestSdkToken(
95
+ config.authToken,
96
+ selected.hostUserId,
97
+ selected.deviceId,
98
+ );
99
+ return delegated.token;
100
+ }
101
+
102
+ private static async rebuildP2PClient(agentUrl?: string): Promise<void> {
103
+ if (!config) return;
104
+ const effectiveUrl = agentUrl ?? config.agentUrl;
105
+ if (!effectiveUrl) {
106
+ p2pClient = null;
107
+ p2pAuthToken = null;
108
+ return;
109
+ }
110
+ const token = await YaverFeedback.resolveP2PAuthToken();
111
+ if (!token) {
112
+ p2pClient = null;
113
+ p2pAuthToken = null;
114
+ return;
115
+ }
116
+ p2pAuthToken = token;
117
+ p2pClient = new P2PClient(effectiveUrl, token);
118
+ }
119
+
76
120
  /**
77
121
  * Initialize the feedback SDK with the given configuration.
78
122
  * Typically called in your app's root component or entry file.
@@ -126,7 +170,11 @@ export class YaverFeedback {
126
170
 
127
171
  // Create P2P client if we have a URL
128
172
  if (config.agentUrl) {
173
+ p2pAuthToken = config.authToken ?? null;
129
174
  p2pClient = new P2PClient(config.agentUrl, config.authToken ?? '');
175
+ if (config.authToken) {
176
+ void YaverFeedback.rebuildP2PClient(config.agentUrl);
177
+ }
130
178
  } else {
131
179
  p2pClient = null;
132
180
  // Auto-discover agent in the background when convexUrl or LAN is available
@@ -247,7 +295,7 @@ export class YaverFeedback {
247
295
  });
248
296
  if (result && config) {
249
297
  config.agentUrl = result.url;
250
- p2pClient = new P2PClient(result.url, config.authToken ?? '');
298
+ await YaverFeedback.rebuildP2PClient(result.url);
251
299
  }
252
300
  } catch {
253
301
  // Discovery failed — FloatingButton will show disconnected, user can retry
@@ -274,7 +322,7 @@ export class YaverFeedback {
274
322
  });
275
323
  if (!result) return false;
276
324
  config.agentUrl = result.url;
277
- p2pClient = new P2PClient(result.url, config.authToken ?? '');
325
+ await YaverFeedback.rebuildP2PClient(result.url);
278
326
  return true;
279
327
  } catch {
280
328
  return false;
@@ -318,7 +366,7 @@ export class YaverFeedback {
318
366
  if (!config) return;
319
367
  config.authToken = token;
320
368
  if (config.agentUrl) {
321
- p2pClient = new P2PClient(config.agentUrl, token);
369
+ await YaverFeedback.rebuildP2PClient(config.agentUrl);
322
370
  } else {
323
371
  await YaverFeedback.discoverAgent();
324
372
  }
@@ -359,9 +407,19 @@ export class YaverFeedback {
359
407
  config.preferredDeviceId = deviceId;
360
408
  config.agentUrl = undefined;
361
409
  p2pClient = null;
410
+ p2pAuthToken = null;
362
411
  await YaverFeedback.discoverAgent();
363
412
  }
364
413
 
414
+ /** Resolve the currently selected remote machine from the authenticated device list. */
415
+ static async getSelectedRemoteDevice() {
416
+ if (!config?.authToken || !config.preferredDeviceId) return null;
417
+ const preferredDeviceId = config.preferredDeviceId;
418
+ const devices = await listReachableDevices(config.authToken);
419
+ const all = [...devices.owned, ...devices.shared];
420
+ return all.find((device) => device.deviceId === preferredDeviceId) ?? null;
421
+ }
422
+
365
423
  /**
366
424
  * Sign out: clear cached token + device, tear down the P2P client. The
367
425
  * SDK stays enabled; the next feedback trigger will re-prompt for login.
@@ -375,6 +433,7 @@ export class YaverFeedback {
375
433
  config.agentUrl = undefined;
376
434
  }
377
435
  p2pClient = null;
436
+ p2pAuthToken = null;
378
437
  }
379
438
 
380
439
  /**
@@ -414,7 +473,7 @@ export class YaverFeedback {
414
473
  });
415
474
  if (result) {
416
475
  config.agentUrl = result.url;
417
- p2pClient = new P2PClient(result.url, config.authToken ?? '');
476
+ await YaverFeedback.rebuildP2PClient(result.url);
418
477
  } else if (config.autoLogin !== false && !config.preferredDeviceId) {
419
478
  // No agent discovered and no device picked yet — prompt the user
420
479
  // to pick one of their machines (handles the non-LAN case where
@@ -859,6 +918,22 @@ export class YaverFeedback {
859
918
  return getQuickIconDisabled();
860
919
  }
861
920
 
921
+ static async setQuickIconColorPreset(
922
+ preset: QuickIconColorPreset | null,
923
+ ): Promise<void> {
924
+ await setQuickIconColorPreset(preset);
925
+ try {
926
+ const { DeviceEventEmitter } = require('react-native');
927
+ DeviceEventEmitter.emit('yaverFeedback:quickIconColorChange', { preset });
928
+ } catch {
929
+ // emitter unavailable — preference is still persisted
930
+ }
931
+ }
932
+
933
+ static async getQuickIconColorPreset(): Promise<QuickIconColorPreset | null> {
934
+ return getQuickIconColorPreset();
935
+ }
936
+
862
937
  /** Clear the persisted "user hid the icon" flag. */
863
938
  static async resetQuickIconPreference(): Promise<void> {
864
939
  await YaverFeedback.setQuickIconVisible(true);
@@ -215,4 +215,44 @@ describe('P2PClient', () => {
215
215
  expect(result).toEqual(builds);
216
216
  });
217
217
  });
218
+
219
+ describe('reloadApp()', () => {
220
+ it('returns an acknowledgement for dev reloads', async () => {
221
+ mockFetch.mockResolvedValue({
222
+ ok: true,
223
+ json: () => Promise.resolve({ ok: true, changeClass: 'js_only' }),
224
+ });
225
+
226
+ const client = new P2PClient('http://localhost:18080', 'tok');
227
+ const result = await client.reloadApp('dev');
228
+
229
+ expect(result).toEqual(
230
+ expect.objectContaining({
231
+ ok: true,
232
+ mode: 'dev',
233
+ acknowledged: true,
234
+ message: 'Hot reload request accepted.',
235
+ }),
236
+ );
237
+ });
238
+
239
+ it('returns an acknowledgement for bundle reloads', async () => {
240
+ mockFetch.mockResolvedValue({
241
+ ok: true,
242
+ json: () => Promise.resolve({ ok: true }),
243
+ });
244
+
245
+ const client = new P2PClient('http://localhost:18080', 'tok');
246
+ const result = await client.reloadApp('bundle');
247
+
248
+ expect(result).toEqual(
249
+ expect.objectContaining({
250
+ ok: true,
251
+ mode: 'bundle',
252
+ acknowledged: true,
253
+ message: 'Reload request acknowledged. Agent is rebuilding the bundle.',
254
+ }),
255
+ );
256
+ });
257
+ });
218
258
  });
@@ -17,6 +17,34 @@ jest.mock('../Discovery', () => ({
17
17
  },
18
18
  }));
19
19
 
20
+ jest.mock('../auth', () => ({
21
+ configureAuthEndpoints: jest.fn(),
22
+ setStrictNativeAuth: jest.fn(),
23
+ getToken: jest.fn(async () => null),
24
+ getSelectedDeviceId: jest.fn(async () => null),
25
+ clearToken: jest.fn(async () => {}),
26
+ clearSelectedDeviceId: jest.fn(async () => {}),
27
+ listReachableDevices: jest.fn(async () => ({
28
+ owned: [
29
+ {
30
+ deviceId: 'device-1',
31
+ name: 'Dev Mac',
32
+ platform: 'darwin',
33
+ isOnline: true,
34
+ needsAuth: false,
35
+ runnerDown: false,
36
+ lastHeartbeat: Date.now(),
37
+ isGuest: false,
38
+ accessScope: 'owner',
39
+ quicHost: '127.0.0.1',
40
+ quicPort: 18080,
41
+ },
42
+ ],
43
+ shared: [],
44
+ })),
45
+ DEFAULT_CONVEX_SITE_URL: 'https://example.convex.site',
46
+ }));
47
+
20
48
  // Reset module-level state between tests by re-requiring
21
49
  beforeEach(() => {
22
50
  // YaverFeedback uses module-level variables (config, enabled, p2pClient).
@@ -126,6 +154,20 @@ describe('YaverFeedback', () => {
126
154
  });
127
155
  });
128
156
 
157
+ describe('getSelectedRemoteDevice()', () => {
158
+ it('returns the selected device from the reachable device list', async () => {
159
+ YaverFeedback.init({
160
+ authToken: 'tok',
161
+ preferredDeviceId: 'device-1',
162
+ enabled: true,
163
+ });
164
+
165
+ const device = await YaverFeedback.getSelectedRemoteDevice();
166
+ expect(device?.deviceId).toBe('device-1');
167
+ expect(device?.name).toBe('Dev Mac');
168
+ });
169
+ });
170
+
129
171
  describe('startReport()', () => {
130
172
  it('does nothing when not enabled', async () => {
131
173
  YaverFeedback.init({ authToken: 'tok', enabled: false });
package/src/auth.ts CHANGED
@@ -410,8 +410,10 @@ export interface RemoteDevice {
410
410
  runnerDown: boolean;
411
411
  lastHeartbeat: number;
412
412
  isGuest: boolean;
413
+ hostUserId?: string;
413
414
  hostName?: string;
414
415
  hostEmail?: string;
416
+ hostUserIdString?: string;
415
417
  accessScope: 'owner' | 'shared-scoped' | 'shared-legacy';
416
418
  quicHost: string;
417
419
  quicPort: number;
@@ -433,6 +435,49 @@ export interface DeviceList {
433
435
  shared: RemoteDevice[];
434
436
  }
435
437
 
438
+ export interface GuestInvitation {
439
+ hostUserId: string;
440
+ hostName: string;
441
+ hostEmail: string;
442
+ hostUserIdString?: string;
443
+ createdAt: number;
444
+ expiresAt: number;
445
+ inviteCode?: string;
446
+ }
447
+
448
+ export interface ActiveGuestHost {
449
+ hostUserId: string;
450
+ hostName: string;
451
+ hostEmail: string;
452
+ grantedAt: number;
453
+ }
454
+
455
+ export interface GuestHostsResponse {
456
+ pending: GuestInvitation[];
457
+ active: ActiveGuestHost[];
458
+ }
459
+
460
+ export interface InvitationHostDevice {
461
+ deviceId: string;
462
+ name: string;
463
+ platform: string;
464
+ lastHeartbeat?: number;
465
+ proposed: boolean;
466
+ }
467
+
468
+ export interface InvitationPreview {
469
+ inviteCode: string;
470
+ hostUserId: string;
471
+ hostName: string;
472
+ hostEmail: string;
473
+ hostUserIdString?: string;
474
+ proposedDeviceIds?: string[];
475
+ hostDevices: InvitationHostDevice[];
476
+ invitedByUserId?: boolean;
477
+ expiresAt: number;
478
+ createdAt: number;
479
+ }
480
+
436
481
  /**
437
482
  * Fetch the set of remote dev machines this user can reach. Splits into
438
483
  * owned (user is the host) vs shared (host invited them as a guest).
@@ -463,8 +508,10 @@ export async function listReachableDevices(
463
508
  runnerDown: !!d.runnerDown,
464
509
  lastHeartbeat: d.lastHeartbeat ?? 0,
465
510
  isGuest: !!d.isGuest,
511
+ hostUserId: d.hostUserId,
466
512
  hostName: d.hostName,
467
513
  hostEmail: d.hostEmail,
514
+ hostUserIdString: d.hostUserIdString,
468
515
  accessScope: d.accessScope ?? 'owner',
469
516
  quicHost: d.quicHost ?? d.host ?? '',
470
517
  quicPort: d.quicPort ?? 0,
@@ -488,3 +535,93 @@ export async function listReachableDevices(
488
535
  return { owned: [], shared: [] };
489
536
  }
490
537
  }
538
+
539
+ export async function mintGuestSdkToken(
540
+ token: string,
541
+ hostUserId: string,
542
+ targetDeviceId: string,
543
+ ): Promise<{ token: string; expiresAt: number; allowedProjects?: string[] }> {
544
+ const res = await fetch(`${convexSiteUrl}/guests/sdk-token`, {
545
+ method: 'POST',
546
+ headers: {
547
+ Authorization: `Bearer ${token}`,
548
+ 'Content-Type': 'application/json',
549
+ },
550
+ body: JSON.stringify({ hostUserId, targetDeviceId }),
551
+ });
552
+ if (!res.ok) {
553
+ const data = await res.json().catch(() => ({}));
554
+ throw new Error(data.error || 'Failed to mint delegated SDK token');
555
+ }
556
+ return res.json();
557
+ }
558
+
559
+ export async function fetchGuestHosts(token: string): Promise<GuestHostsResponse> {
560
+ const res = await fetch(`${convexSiteUrl}/guests/hosts`, {
561
+ headers: { Authorization: `Bearer ${token}` },
562
+ });
563
+ if (!res.ok) {
564
+ const data = await res.json().catch(() => ({}));
565
+ throw new Error(data.error || 'Failed to fetch guest hosts');
566
+ }
567
+ return res.json();
568
+ }
569
+
570
+ export async function findInviteByCode(
571
+ token: string,
572
+ code: string,
573
+ ): Promise<InvitationPreview | null> {
574
+ const cleaned = code.toUpperCase().trim();
575
+ const res = await fetch(
576
+ `${convexSiteUrl}/guests/find-by-code?code=${encodeURIComponent(cleaned)}`,
577
+ { headers: { Authorization: `Bearer ${token}` } },
578
+ );
579
+ if (res.status === 404) return null;
580
+ if (!res.ok) {
581
+ const data = await res.json().catch(() => ({}));
582
+ throw new Error(data.error || 'Failed to load invite');
583
+ }
584
+ return res.json();
585
+ }
586
+
587
+ export async function acceptGuestByCode(
588
+ token: string,
589
+ code: string,
590
+ approvedDeviceIds?: string[],
591
+ ): Promise<{ hostName: string; hostEmail: string }> {
592
+ const res = await fetch(`${convexSiteUrl}/guests/accept-code`, {
593
+ method: 'POST',
594
+ headers: {
595
+ Authorization: `Bearer ${token}`,
596
+ 'Content-Type': 'application/json',
597
+ },
598
+ body: JSON.stringify({
599
+ code: code.toUpperCase().trim(),
600
+ approvedDeviceIds,
601
+ }),
602
+ });
603
+ if (!res.ok) {
604
+ const data = await res.json().catch(() => ({}));
605
+ throw new Error(data.error || 'Invalid invite code');
606
+ }
607
+ return res.json();
608
+ }
609
+
610
+ export async function acceptGuestInvitation(
611
+ token: string,
612
+ hostUserId: string,
613
+ approvedDeviceIds?: string[],
614
+ ): Promise<void> {
615
+ const res = await fetch(`${convexSiteUrl}/guests/accept`, {
616
+ method: 'POST',
617
+ headers: {
618
+ Authorization: `Bearer ${token}`,
619
+ 'Content-Type': 'application/json',
620
+ },
621
+ body: JSON.stringify({ hostUserId, approvedDeviceIds }),
622
+ });
623
+ if (!res.ok) {
624
+ const data = await res.json().catch(() => ({}));
625
+ throw new Error(data.error || 'Failed to accept invitation');
626
+ }
627
+ }
package/src/index.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  /**
2
2
  * yaver-feedback-react-native — Visual feedback SDK for Yaver.
3
3
  *
4
- * Shake-to-report surface with four core actions:
4
+ * Shake-to-report surface with three launch actions:
5
5
  * 1. Hot Reload — instant JS reload
6
6
  * 2. Vibing — open a vibing session on the agent
7
- * 3. Screenshot / Upload — capture the screen under the modal or
8
- * upload existing media
9
- * 4. Screen Recording — start, then stop + upload
7
+ * 3. Screenshot & Fix — capture the current screen and trigger
8
+ * the fix loop
10
9
  *
11
10
  * The small quick-access icon stays hidden until the first shake by
12
11
  * default on mobile, then remains available unless the user hides it.
@@ -41,6 +40,8 @@ export { YaverLoginScreen } from './LoginScreen';
41
40
  export type { YaverLoginScreenProps } from './LoginScreen';
42
41
  export { YaverMachinePickerScreen } from './MachinePickerScreen';
43
42
  export type { YaverMachinePickerProps } from './MachinePickerScreen';
43
+ export { YaverGuestOnboardingScreen } from './GuestOnboardingScreen';
44
+ export type { YaverGuestOnboardingScreenProps } from './GuestOnboardingScreen';
44
45
  export { PairDeviceModal } from './PairDeviceModal';
45
46
  export type { PairDeviceModalProps } from './PairDeviceModal';
46
47
  export { AuthOverlay } from './AuthOverlay';
@@ -73,6 +74,10 @@ export {
73
74
  signupWithEmail,
74
75
  loginWithEmail,
75
76
  listReachableDevices,
77
+ fetchGuestHosts,
78
+ findInviteByCode,
79
+ acceptGuestByCode,
80
+ acceptGuestInvitation,
76
81
  DEFAULT_CONVEX_SITE_URL,
77
82
  DEFAULT_WEB_BASE_URL,
78
83
  DEFAULT_OAUTH_REDIRECT,
@@ -82,6 +87,11 @@ export type {
82
87
  User,
83
88
  RemoteDevice,
84
89
  DeviceList,
90
+ GuestInvitation,
91
+ ActiveGuestHost,
92
+ GuestHostsResponse,
93
+ InvitationHostDevice,
94
+ InvitationPreview,
85
95
  } from './auth';
86
96
  export {
87
97
  captureScreenshot,
@@ -25,6 +25,69 @@ try {
25
25
  }
26
26
 
27
27
  const QUICK_ICON_DISABLED_KEY = 'yaver_feedback_quickicon_disabled';
28
+ const QUICK_ICON_COLOR_KEY = 'yaver_feedback_quickicon_color';
29
+
30
+ export type QuickIconColorPreset =
31
+ | 'orange'
32
+ | 'lime'
33
+ | 'cyan'
34
+ | 'pink'
35
+ | 'yellow'
36
+ | 'slate';
37
+
38
+ export const QUICK_ICON_COLOR_PRESETS: Record<
39
+ QuickIconColorPreset,
40
+ {
41
+ label: string;
42
+ backgroundColor: string;
43
+ foregroundColor: string;
44
+ borderColor: string;
45
+ shadowColor: string;
46
+ }
47
+ > = {
48
+ orange: {
49
+ label: 'Orange',
50
+ backgroundColor: '#ff6b2c',
51
+ foregroundColor: '#111111',
52
+ borderColor: 'rgba(255,255,255,0.92)',
53
+ shadowColor: '#000000',
54
+ },
55
+ lime: {
56
+ label: 'Lime',
57
+ backgroundColor: '#a3e635',
58
+ foregroundColor: '#111111',
59
+ borderColor: 'rgba(255,255,255,0.85)',
60
+ shadowColor: '#365314',
61
+ },
62
+ cyan: {
63
+ label: 'Cyan',
64
+ backgroundColor: '#22d3ee',
65
+ foregroundColor: '#082f49',
66
+ borderColor: 'rgba(255,255,255,0.82)',
67
+ shadowColor: '#083344',
68
+ },
69
+ pink: {
70
+ label: 'Pink',
71
+ backgroundColor: '#fb7185',
72
+ foregroundColor: '#fff1f2',
73
+ borderColor: 'rgba(255,255,255,0.78)',
74
+ shadowColor: '#4c0519',
75
+ },
76
+ yellow: {
77
+ label: 'Yellow',
78
+ backgroundColor: '#facc15',
79
+ foregroundColor: '#1c1917',
80
+ borderColor: 'rgba(255,255,255,0.88)',
81
+ shadowColor: '#713f12',
82
+ },
83
+ slate: {
84
+ label: 'Slate',
85
+ backgroundColor: '#475569',
86
+ foregroundColor: '#f8fafc',
87
+ borderColor: 'rgba(255,255,255,0.68)',
88
+ shadowColor: '#020617',
89
+ },
90
+ };
28
91
 
29
92
  /** True if the user has long-pressed the icon and chosen "Hide". */
30
93
  export async function getQuickIconDisabled(): Promise<boolean> {
@@ -53,3 +116,36 @@ export async function setQuickIconDisabled(disabled: boolean): Promise<void> {
53
116
  export async function clearQuickIconDisabled(): Promise<void> {
54
117
  await setQuickIconDisabled(false);
55
118
  }
119
+
120
+ export async function getQuickIconColorPreset(): Promise<QuickIconColorPreset | null> {
121
+ if (!AsyncStorage) return null;
122
+ try {
123
+ const v = await AsyncStorage.getItem(QUICK_ICON_COLOR_KEY);
124
+ if (!v) return null;
125
+ if (Object.prototype.hasOwnProperty.call(QUICK_ICON_COLOR_PRESETS, v)) {
126
+ return v as QuickIconColorPreset;
127
+ }
128
+ return null;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ export async function setQuickIconColorPreset(
135
+ preset: QuickIconColorPreset | null,
136
+ ): Promise<void> {
137
+ if (!AsyncStorage) return;
138
+ try {
139
+ if (!preset) {
140
+ await AsyncStorage.removeItem(QUICK_ICON_COLOR_KEY);
141
+ return;
142
+ }
143
+ await AsyncStorage.setItem(QUICK_ICON_COLOR_KEY, preset);
144
+ } catch {
145
+ // best-effort
146
+ }
147
+ }
148
+
149
+ export async function clearQuickIconColorPreset(): Promise<void> {
150
+ await setQuickIconColorPreset(null);
151
+ }
package/src/types.ts CHANGED
@@ -206,6 +206,13 @@ export interface FeedbackConfig {
206
206
  * Default: false (preserve historical behavior).
207
207
  */
208
208
  strictNativeAuth?: boolean;
209
+ /**
210
+ * Optional host invite code to prefill into the in-SDK guest onboarding
211
+ * flow. Useful when your app receives the code from a deep link, QR flow,
212
+ * or an out-of-band host handoff and you want the user to redeem it
213
+ * without typing.
214
+ */
215
+ guestInviteCode?: string;
209
216
  }
210
217
 
211
218
  export interface FeedbackBundle {