yaver-feedback-react-native 0.9.6 → 0.9.8

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.
@@ -1,5 +1,6 @@
1
1
  import { DeviceEventEmitter, NativeModules } from 'react-native';
2
2
  import { YaverFeedback } from '../YaverFeedback';
3
+ import { getDogfoodAccountAccess, setDogfoodControlPreference } from '../auth';
3
4
 
4
5
  // Mock react-native: DeviceEventEmitter for event dispatch + Platform so
5
6
  // ShakeDetector.start() can branch on iOS without hitting a real RN runtime.
@@ -14,6 +15,10 @@ jest.mock('react-native', () => ({
14
15
  setDogfoodShortcut: jest.fn(async () => true),
15
16
  consumeDogfoodShortcut: jest.fn(async () => false),
16
17
  },
18
+ YaverDogfoodGesture: {
19
+ getCapability: jest.fn(async () => ({ supported: true, enabled: false, reason: 'supported', platform: 'ios' })),
20
+ setEnabled: jest.fn(async (next: boolean) => ({ supported: true, enabled: next, reason: 'supported', platform: 'ios' })),
21
+ },
17
22
  },
18
23
  AppState: { addEventListener: jest.fn(() => ({ remove: jest.fn() })) },
19
24
  }));
@@ -33,8 +38,10 @@ jest.mock('../auth', () => ({
33
38
  getDogfoodAccountAccess: jest.fn(async (_appId: string, token: string) => ({
34
39
  authenticated: token === 'owner-token',
35
40
  ownerAuthorized: token === 'owner-token',
41
+ accountAuthorized: token === 'owner-token',
36
42
  installationAuthorized: token === 'owner-token',
37
43
  })),
44
+ setDogfoodControlPreference: jest.fn(async () => true),
38
45
  saveSelectedDeviceId: jest.fn(async () => {}),
39
46
  clearToken: jest.fn(async () => {}),
40
47
  clearSelectedDeviceId: jest.fn(async () => {}),
@@ -103,6 +110,18 @@ describe('YaverFeedback', () => {
103
110
  expect(states[states.length - 1]).toBe('auth-required');
104
111
  });
105
112
 
113
+ it('returns a visible structured error when access verification fails', async () => {
114
+ YaverFeedback.init({ enabled: true, authToken: 'owner-token' });
115
+ (getDogfoodAccountAccess as jest.Mock).mockRejectedValueOnce(new Error('Access service unavailable'));
116
+ const state = await YaverFeedback.beginDogfoodOnboarding({ appId: 'io.example.app' });
117
+ expect(state).toEqual({
118
+ phase: 'error',
119
+ appId: 'io.example.app',
120
+ error: 'Access service unavailable',
121
+ });
122
+ expect(DeviceEventEmitter.emit).not.toHaveBeenCalledWith('yaverFeedback:startReport');
123
+ });
124
+
106
125
  it('lets a host ACL hide its affordance without opening auth UI', async () => {
107
126
  YaverFeedback.init({
108
127
  enabled: true,
@@ -121,6 +140,7 @@ describe('YaverFeedback', () => {
121
140
  appId: 'io.example.app',
122
141
  yaverAuthenticated: true,
123
142
  ownerAuthorized: false,
143
+ accountAuthorized: true,
124
144
  installationId: 'phone-1',
125
145
  deviceState: 'active',
126
146
  authorized: true,
@@ -137,6 +157,7 @@ describe('YaverFeedback', () => {
137
157
  appId: 'io.example.app',
138
158
  yaverAuthenticated: false,
139
159
  ownerAuthorized: false,
160
+ accountAuthorized: true,
140
161
  installationId: 'phone-1',
141
162
  deviceState: 'active',
142
163
  authorized: false,
@@ -155,6 +176,7 @@ describe('YaverFeedback', () => {
155
176
  appId: 'io.example.app',
156
177
  yaverAuthenticated: true,
157
178
  ownerAuthorized: false,
179
+ accountAuthorized: true,
158
180
  installationId: 'phone-1',
159
181
  deviceState: 'active',
160
182
  authorized: true,
@@ -163,6 +185,139 @@ describe('YaverFeedback', () => {
163
185
  expect((NativeModules as any).YaverHotReload.setDogfoodShortcut)
164
186
  .toHaveBeenCalledWith(false, 'Dogfood');
165
187
  });
188
+
189
+ it('keeps the Y visible until the authorized phone has completed onboarding', async () => {
190
+ YaverFeedback.init({ bundleId: 'io.example.app', dogfood: {} });
191
+ YaverFeedback.getConfig()!.dogfood!.controlGesture = true;
192
+ jest.spyOn(YaverFeedback, 'getDogfoodAccess').mockResolvedValueOnce({
193
+ appId: 'io.example.app',
194
+ yaverAuthenticated: true,
195
+ ownerAuthorized: false,
196
+ accountAuthorized: true,
197
+ installationId: 'phone-1',
198
+ deviceState: 'active',
199
+ authorized: true,
200
+ });
201
+ const state = await YaverFeedback.syncDogfoodControlGesture();
202
+ expect(state).toMatchObject({
203
+ onboardingSeen: false,
204
+ presentation: 'minimized-y',
205
+ gestureSupported: true,
206
+ gestureEnabled: false,
207
+ fallbackVisible: true,
208
+ });
209
+ expect((NativeModules as any).YaverDogfoodGesture.setEnabled).toHaveBeenCalledWith(false, 900);
210
+ });
211
+
212
+ it('uses the invisible three-finger hold only after onboarding selects it', async () => {
213
+ YaverFeedback.init({ bundleId: 'io.example.app', dogfood: {} });
214
+ YaverFeedback.getConfig()!.dogfood!.controlGesture = true;
215
+ jest.spyOn(YaverFeedback, 'getDogfoodAccess').mockResolvedValueOnce({
216
+ appId: 'io.example.app',
217
+ yaverAuthenticated: true,
218
+ ownerAuthorized: false,
219
+ accountAuthorized: true,
220
+ installationId: 'phone-1',
221
+ deviceState: 'active',
222
+ authorized: true,
223
+ controlPresentation: 'auto',
224
+ controlOnboardingSeen: true,
225
+ });
226
+ const state = await YaverFeedback.syncDogfoodControlGesture();
227
+ expect(state).toMatchObject({
228
+ onboardingSeen: true,
229
+ presentation: 'auto',
230
+ gestureSupported: true,
231
+ gestureEnabled: true,
232
+ fallbackVisible: false,
233
+ });
234
+ expect((NativeModules as any).YaverDogfoodGesture.setEnabled).toHaveBeenCalledWith(true, 900);
235
+ });
236
+
237
+ it('keeps the Y when native capability reports support but enabling the gesture fails', async () => {
238
+ YaverFeedback.init({ bundleId: 'io.example.app', dogfood: {} });
239
+ YaverFeedback.getConfig()!.dogfood!.controlGesture = true;
240
+ jest.spyOn(YaverFeedback, 'getDogfoodAccess').mockResolvedValueOnce({
241
+ appId: 'io.example.app',
242
+ yaverAuthenticated: true,
243
+ ownerAuthorized: false,
244
+ accountAuthorized: true,
245
+ installationId: 'phone-1',
246
+ deviceState: 'active',
247
+ authorized: true,
248
+ controlPresentation: 'auto',
249
+ controlOnboardingSeen: true,
250
+ });
251
+ (NativeModules as any).YaverDogfoodGesture.setEnabled.mockResolvedValueOnce({
252
+ supported: true,
253
+ enabled: false,
254
+ reason: 'supported',
255
+ platform: 'ios',
256
+ });
257
+ const state = await YaverFeedback.syncDogfoodControlGesture();
258
+ expect(state).toMatchObject({
259
+ gestureSupported: true,
260
+ gestureEnabled: false,
261
+ fallbackVisible: true,
262
+ reason: 'gesture-enable-failed',
263
+ });
264
+ });
265
+
266
+ it('persists first-run completion for the exact account app installation', async () => {
267
+ YaverFeedback.init({ authToken: 'owner-token', bundleId: 'io.example.app', dogfood: {} });
268
+ YaverFeedback.getConfig()!.dogfood!.controlGesture = true;
269
+ jest.spyOn(YaverFeedback, 'getDogfoodAccess').mockResolvedValueOnce({
270
+ appId: 'io.example.app',
271
+ yaverAuthenticated: true,
272
+ ownerAuthorized: true,
273
+ accountAuthorized: true,
274
+ installationId: 'phone-1',
275
+ deviceState: 'active',
276
+ authorized: true,
277
+ controlOnboardingSeen: false,
278
+ });
279
+ const state = await YaverFeedback.setDogfoodControlPresentation('minimized-y');
280
+ expect(state).toMatchObject({ onboardingSeen: true, presentation: 'minimized-y', fallbackVisible: true });
281
+ expect(setDogfoodControlPreference).toHaveBeenCalledWith(expect.objectContaining({
282
+ appId: 'io.example.app',
283
+ installationId: 'phone-1',
284
+ token: 'owner-token',
285
+ presentation: 'minimized-y',
286
+ controlOnboardingSeen: true,
287
+ }));
288
+ });
289
+
290
+ it('falls back to the minimized Y when accessibility owns multi-touch', async () => {
291
+ YaverFeedback.init({ bundleId: 'io.example.app', dogfood: {} });
292
+ YaverFeedback.getConfig()!.dogfood!.controlGesture = { durationMs: 1200 };
293
+ jest.spyOn(YaverFeedback, 'getDogfoodAccess').mockResolvedValueOnce({
294
+ appId: 'io.example.app',
295
+ yaverAuthenticated: true,
296
+ ownerAuthorized: false,
297
+ accountAuthorized: true,
298
+ installationId: 'phone-1',
299
+ deviceState: 'active',
300
+ authorized: true,
301
+ });
302
+ (NativeModules as any).YaverDogfoodGesture.getCapability.mockResolvedValueOnce({
303
+ supported: false,
304
+ enabled: false,
305
+ reason: 'accessibility-touch-exploration',
306
+ platform: 'ios',
307
+ });
308
+ const state = await YaverFeedback.syncDogfoodControlGesture();
309
+ expect(state).toMatchObject({ gestureSupported: false, gestureEnabled: false, fallbackVisible: true });
310
+ expect((NativeModules as any).YaverDogfoodGesture.setEnabled).toHaveBeenCalledWith(false, 1200);
311
+ });
312
+
313
+ it('suppresses guest controls inside the Yaver split-view container', async () => {
314
+ YaverFeedback.init({ bundleId: 'io.example.app', dogfood: {} });
315
+ YaverFeedback.getConfig()!.dogfood!.controlGesture = true;
316
+ (NativeModules as any).YaverInfo = { isYaver: true };
317
+ const state = await YaverFeedback.syncDogfoodControlGesture();
318
+ delete (NativeModules as any).YaverInfo;
319
+ expect(state).toMatchObject({ gestureEnabled: false, fallbackVisible: false, reason: 'yaver-host-owns-controls' });
320
+ });
166
321
  });
167
322
 
168
323
  describe('init()', () => {
package/src/auth.ts CHANGED
@@ -207,7 +207,12 @@ export async function clearSelectedDeviceId(): Promise<void> {
207
207
  export async function getDogfoodAccountAccess(appId: string, token: string, installationId?: string): Promise<{
208
208
  authenticated: boolean;
209
209
  ownerAuthorized: boolean;
210
+ accountAuthorized: boolean;
210
211
  installationAuthorized: boolean;
212
+ controlPresentation?: 'auto' | 'minimized-y';
213
+ gestureSupported?: boolean;
214
+ gestureCapabilityReason?: string;
215
+ controlOnboardingSeen?: boolean;
211
216
  }> {
212
217
  try {
213
218
  const controller = new AbortController();
@@ -219,15 +224,61 @@ export async function getDogfoodAccountAccess(appId: string, token: string, inst
219
224
  signal: controller.signal,
220
225
  });
221
226
  clearTimeout(timeout);
222
- if (!response.ok) return { authenticated: false, ownerAuthorized: false, installationAuthorized: false };
227
+ if (!response.ok) return { authenticated: false, ownerAuthorized: false, accountAuthorized: false, installationAuthorized: false };
223
228
  const result = await response.json();
224
229
  return {
225
230
  authenticated: result?.authenticated === true,
226
231
  ownerAuthorized: result?.ownerAuthorized === true,
232
+ accountAuthorized: result?.accountAuthorized === true,
227
233
  installationAuthorized: result?.installationAuthorized === true,
234
+ controlPresentation: result?.controlPresentation === 'minimized-y' ? 'minimized-y'
235
+ : result?.controlPresentation === 'auto' ? 'auto' : undefined,
236
+ gestureSupported: typeof result?.gestureSupported === 'boolean' ? result.gestureSupported : undefined,
237
+ gestureCapabilityReason: typeof result?.gestureCapabilityReason === 'string'
238
+ ? result.gestureCapabilityReason
239
+ : undefined,
240
+ controlOnboardingSeen: result?.controlOnboardingSeen === true,
228
241
  };
229
242
  } catch {
230
- return { authenticated: false, ownerAuthorized: false, installationAuthorized: false };
243
+ return { authenticated: false, ownerAuthorized: false, accountAuthorized: false, installationAuthorized: false };
244
+ }
245
+ }
246
+
247
+ export async function setDogfoodControlPreference(input: {
248
+ appId: string;
249
+ installationId: string;
250
+ token: string;
251
+ presentation: 'auto' | 'minimized-y';
252
+ gestureSupported: boolean;
253
+ gestureCapabilityReason: string;
254
+ gesturePlatform: string;
255
+ controlOnboardingSeen?: boolean;
256
+ }): Promise<boolean> {
257
+ const controller = new AbortController();
258
+ const timeout = setTimeout(() => controller.abort(), 5_000);
259
+ try {
260
+ const response = await fetch(`${convexSiteUrl}/dogfood/control-preference`, {
261
+ method: 'POST',
262
+ headers: {
263
+ Authorization: `Bearer ${input.token}`,
264
+ 'Content-Type': 'application/json',
265
+ },
266
+ body: JSON.stringify({
267
+ appId: input.appId,
268
+ installationId: input.installationId,
269
+ presentation: input.presentation,
270
+ gestureSupported: input.gestureSupported,
271
+ gestureCapabilityReason: input.gestureCapabilityReason,
272
+ gesturePlatform: input.gesturePlatform,
273
+ controlOnboardingSeen: input.controlOnboardingSeen === true,
274
+ }),
275
+ signal: controller.signal,
276
+ });
277
+ return response.ok;
278
+ } catch {
279
+ return false;
280
+ } finally {
281
+ clearTimeout(timeout);
231
282
  }
232
283
  }
233
284
 
@@ -4,10 +4,18 @@ export interface DogfoodAccessSnapshot {
4
4
  yaverAuthenticated: boolean;
5
5
  /** Backend-confirmed owner/maintainer of this exact appId. */
6
6
  ownerAuthorized: boolean;
7
+ /** Backend-confirmed app assignment for this Yaver account. App owners are
8
+ * assigned implicitly; other accounts require an active owner grant. */
9
+ accountAuthorized: boolean;
7
10
  installationId?: string;
8
11
  deviceState: 'unknown' | 'unregistered' | 'pending' | 'active' | 'cancelled' | 'revoked' | 'superseded';
9
- /** True only for a backend-approved device key or an authenticated owner. */
12
+ /** True only for an assigned account and this backend-approved device key. */
10
13
  authorized: boolean;
14
+ /** OAuth-backed preference scoped to this user + app + installation. */
15
+ controlPresentation?: 'auto' | 'minimized-y';
16
+ gestureSupported?: boolean;
17
+ gestureCapabilityReason?: string;
18
+ controlOnboardingSeen?: boolean;
11
19
  }
12
20
 
13
21
  export interface DogfoodFlowSnapshot {
@@ -44,6 +52,19 @@ export interface SDKDogfoodConfig {
44
52
  /** ACL-backed Home Screen/App Shortcut. Dynamic and absent until Yaver
45
53
  * owner auth or this installation's approved key authorizes it. */
46
54
  appShortcut?: boolean | { label?: string };
55
+ /** Smart in-app quick controls. On supported standalone iOS/Android builds,
56
+ * a three-finger hold opens a two-action card with no persistent overlay.
57
+ * Unsupported/accessibility-conflicted devices may show a minimized,
58
+ * draggable Y fallback. Yaver host/container mode suppresses both. */
59
+ controlGesture?: boolean | {
60
+ /** Hold duration, clamped to 650–2000 ms. Default 900 ms. */
61
+ durationMs?: number;
62
+ /** Default `minimized-y`; use `none` when Settings is the only fallback. */
63
+ fallback?: 'minimized-y' | 'none';
64
+ /** Initial user-facing presentation before their persisted SDK preference
65
+ * exists. `auto` keeps pixels clear when the gesture works. */
66
+ defaultPresentation?: 'auto' | 'minimized-y';
67
+ };
47
68
  }
48
69
 
49
70
  export interface SDKDogfoodStatus {
package/src/index.ts CHANGED
@@ -29,7 +29,12 @@
29
29
  */
30
30
 
31
31
  export { YaverFeedback } from './YaverFeedback';
32
- export type { DogfoodOnboardingOptions, DogfoodFlowPhase, DogfoodFlowState } from './YaverFeedback';
32
+ export type {
33
+ DogfoodOnboardingOptions,
34
+ DogfoodFlowPhase,
35
+ DogfoodFlowState,
36
+ DogfoodControlTriggerState,
37
+ } from './YaverFeedback';
33
38
  export { captureStoreScreenshots } from './storeShots';
34
39
  export type {
35
40
  CaptureStoreScreenshotsOptions,
@@ -106,7 +111,14 @@ export type {
106
111
  DogfoodRunContext,
107
112
  DogfoodSnapshot,
108
113
  } from './DogfoodRuntime';
114
+ export { DogfoodLanePicker, DogfoodLiveConsole, DogfoodStatusRail } from './DogfoodSessionUi';
115
+ export type {
116
+ DogfoodStatusStep,
117
+ DogfoodStatusTone,
118
+ DogfoodUiColors,
119
+ } from './DogfoodSessionUi';
109
120
  export { FeedbackModal } from './FeedbackModal';
121
+ export { DogfoodQuickControls } from './DogfoodQuickControls';
110
122
  export { QuickActionIcon } from './QuickActionIcon';
111
123
  export type { QuickActionIconProps } from './QuickActionIcon';
112
124
  export { FixReport } from './FixReport';
@@ -26,6 +26,95 @@ try {
26
26
 
27
27
  const QUICK_ICON_DISABLED_KEY = 'yaver_feedback_quickicon_disabled';
28
28
  const QUICK_ICON_COLOR_KEY = 'yaver_feedback_quickicon_color';
29
+ const DOGFOOD_CONTROL_PRESENTATION_KEY = 'yaver_dogfood_control_presentation';
30
+ const DOGFOOD_CONTROL_POSITION_PREFIX = 'yaver_dogfood_control_position_';
31
+ const DOGFOOD_CONTROL_ONBOARDING_PREFIX = 'yaver_dogfood_control_onboarding_';
32
+
33
+ export type DogfoodControlPresentation = 'auto' | 'minimized-y';
34
+ export type DogfoodControlEdge = 'left' | 'right';
35
+ export interface DogfoodControlPosition {
36
+ edge: DogfoodControlEdge;
37
+ /** 0–1 within the safe vertical travel area. */
38
+ yRatio: number;
39
+ }
40
+
41
+ function dogfoodPreferenceKey(base: string, scope?: string): string {
42
+ const normalized = String(scope || 'legacy').trim() || 'legacy';
43
+ return `${base}_${encodeURIComponent(normalized)}`;
44
+ }
45
+
46
+ export async function getDogfoodControlPresentation(scope?: string): Promise<DogfoodControlPresentation | null> {
47
+ if (!AsyncStorage) return null;
48
+ try {
49
+ const value = await AsyncStorage.getItem(dogfoodPreferenceKey(DOGFOOD_CONTROL_PRESENTATION_KEY, scope));
50
+ return value === 'auto' || value === 'minimized-y' ? value : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export async function setDogfoodControlPresentation(value: DogfoodControlPresentation, scope?: string): Promise<void> {
57
+ if (!AsyncStorage) return;
58
+ try {
59
+ await AsyncStorage.setItem(dogfoodPreferenceKey(DOGFOOD_CONTROL_PRESENTATION_KEY, scope), value);
60
+ } catch {
61
+ // best-effort
62
+ }
63
+ }
64
+
65
+ export async function getDogfoodControlPosition(
66
+ orientation: 'portrait' | 'landscape',
67
+ scope?: string,
68
+ ): Promise<DogfoodControlPosition | null> {
69
+ if (!AsyncStorage) return null;
70
+ try {
71
+ const raw = await AsyncStorage.getItem(
72
+ dogfoodPreferenceKey(`${DOGFOOD_CONTROL_POSITION_PREFIX}${orientation}`, scope),
73
+ );
74
+ if (!raw) return null;
75
+ const parsed = JSON.parse(raw) as Partial<DogfoodControlPosition>;
76
+ if ((parsed.edge !== 'left' && parsed.edge !== 'right') || typeof parsed.yRatio !== 'number') return null;
77
+ return { edge: parsed.edge, yRatio: Math.max(0, Math.min(1, parsed.yRatio)) };
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ export async function setDogfoodControlPosition(
84
+ orientation: 'portrait' | 'landscape',
85
+ position: DogfoodControlPosition,
86
+ scope?: string,
87
+ ): Promise<void> {
88
+ if (!AsyncStorage) return;
89
+ try {
90
+ await AsyncStorage.setItem(
91
+ dogfoodPreferenceKey(`${DOGFOOD_CONTROL_POSITION_PREFIX}${orientation}`, scope),
92
+ JSON.stringify(position),
93
+ );
94
+ } catch {
95
+ // best-effort
96
+ }
97
+ }
98
+
99
+ export async function getDogfoodControlOnboardingSeen(scope?: string): Promise<boolean> {
100
+ if (!AsyncStorage) return false;
101
+ try {
102
+ return await AsyncStorage.getItem(dogfoodPreferenceKey(DOGFOOD_CONTROL_ONBOARDING_PREFIX, scope)) === '1';
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ export async function setDogfoodControlOnboardingSeen(seen: boolean, scope?: string): Promise<void> {
109
+ if (!AsyncStorage) return;
110
+ try {
111
+ const key = dogfoodPreferenceKey(DOGFOOD_CONTROL_ONBOARDING_PREFIX, scope);
112
+ if (seen) await AsyncStorage.setItem(key, '1');
113
+ else await AsyncStorage.removeItem(key);
114
+ } catch {
115
+ // best-effort startup cache; Convex remains authoritative.
116
+ }
117
+ }
29
118
 
30
119
  export type QuickIconColorPreset =
31
120
  | 'orange'