yaver-feedback-react-native 0.9.7 → 0.9.9

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.
@@ -15,6 +15,7 @@ import {
15
15
  getToken,
16
16
  getSelectedDeviceId,
17
17
  getDogfoodAccountAccess,
18
+ setDogfoodControlPreference,
18
19
  listReachableDevices,
19
20
  clearToken,
20
21
  clearSelectedDeviceId,
@@ -25,6 +26,11 @@ import {
25
26
  getQuickIconColorPreset,
26
27
  setQuickIconDisabled,
27
28
  setQuickIconColorPreset,
29
+ getDogfoodControlPresentation,
30
+ setDogfoodControlPresentation as cacheDogfoodControlPresentation,
31
+ getDogfoodControlOnboardingSeen,
32
+ setDogfoodControlOnboardingSeen,
33
+ type DogfoodControlPresentation,
28
34
  QuickIconColorPreset,
29
35
  } from './preferences';
30
36
  import { resolveSDKDogfood, type SDKDogfoodStatus, type DogfoodAccessSnapshot } from './dogfoodPolicy';
@@ -44,6 +50,27 @@ export interface DogfoodFlowState {
44
50
  error?: string;
45
51
  }
46
52
 
53
+ export interface DogfoodControlTriggerState {
54
+ configured: boolean;
55
+ authorized: boolean;
56
+ appId?: string;
57
+ installationId?: string;
58
+ gestureSupported: boolean;
59
+ gestureEnabled: boolean;
60
+ fallbackVisible: boolean;
61
+ presentation: DogfoodControlPresentation;
62
+ onboardingSeen: boolean;
63
+ reason: string;
64
+ platform?: string;
65
+ }
66
+
67
+ interface DogfoodControlSyncOptions {
68
+ presentation?: DogfoodControlPresentation;
69
+ onboardingSeen?: boolean;
70
+ /** User-initiated changes fail visibly when Convex cannot persist them. */
71
+ requirePersistence?: boolean;
72
+ }
73
+
47
74
  function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
48
75
  const maybeNodeTimer = timer as unknown as { unref?: () => void };
49
76
  if (typeof maybeNodeTimer.unref === 'function') {
@@ -194,6 +221,11 @@ let dogfoodActivationSubscription: { remove: () => void } | null = null;
194
221
  let dogfoodShortcutLaunchInFlight = false;
195
222
  let lastDogfoodActivationUrl = '';
196
223
 
224
+ function dogfoodControlPreferenceScope(appId?: string, installationId?: string): string | undefined {
225
+ if (!appId || !installationId) return undefined;
226
+ return `${appId}:${installationId}`;
227
+ }
228
+
197
229
  function publishDogfoodFlow(state: DogfoodFlowState): void {
198
230
  dogfoodFlowState = state;
199
231
  try { config?.dogfood?.onStateChange?.(state); } catch { /* host callback */ }
@@ -490,19 +522,29 @@ export class YaverFeedback {
490
522
  void YaverFeedback.hydrateSession();
491
523
  }
492
524
 
493
- if (config.dogfood?.appShortcut) {
494
- void YaverFeedback.syncDogfoodAppShortcut();
495
- void consumeNativeDogfoodShortcut();
525
+ if (config.dogfood?.appShortcut || config.dogfood?.controlGesture) {
526
+ if (config.dogfood?.appShortcut) {
527
+ void YaverFeedback.syncDogfoodAppShortcut();
528
+ void consumeNativeDogfoodShortcut();
529
+ }
530
+ if (config.dogfood?.controlGesture) {
531
+ void YaverFeedback.syncDogfoodControlGesture();
532
+ }
496
533
  try {
497
534
  const { AppState } = require('react-native');
498
535
  dogfoodShortcutAppStateSubscription?.remove();
499
536
  dogfoodShortcutAppStateSubscription = AppState.addEventListener('change', (state: string) => {
500
537
  if (state === 'active') {
501
- void YaverFeedback.syncDogfoodAppShortcut();
502
- void consumeNativeDogfoodShortcut();
538
+ if (config?.dogfood?.appShortcut) {
539
+ void YaverFeedback.syncDogfoodAppShortcut();
540
+ void consumeNativeDogfoodShortcut();
541
+ }
542
+ if (config?.dogfood?.controlGesture) {
543
+ void YaverFeedback.syncDogfoodControlGesture();
544
+ }
503
545
  }
504
546
  });
505
- } catch { /* native shortcut unavailable on web/test runtimes */ }
547
+ } catch { /* native quick controls unavailable on web/test runtimes */ }
506
548
  }
507
549
  if (config.dogfood) {
508
550
  try {
@@ -805,6 +847,7 @@ export class YaverFeedback {
805
847
  YaverFeedback.scheduleBlackBoxAutoStart();
806
848
  }
807
849
  await YaverFeedback.syncDogfoodAppShortcut().catch(() => false);
850
+ await YaverFeedback.syncDogfoodControlGesture().catch(() => undefined);
808
851
  }
809
852
 
810
853
  /** Returns true once the SDK has a session token it can use. */
@@ -963,16 +1006,21 @@ export class YaverFeedback {
963
1006
  const token = config?.authToken || await getToken();
964
1007
  const account = token
965
1008
  ? await getDogfoodAccountAccess(appId, token, installationId)
966
- : { authenticated: false, ownerAuthorized: false, installationAuthorized: false };
1009
+ : { authenticated: false, ownerAuthorized: false, accountAuthorized: false, installationAuthorized: false };
967
1010
  const yaverAuthenticated = account.authenticated;
968
1011
  const ownerAuthorized = account.ownerAuthorized;
969
1012
  return {
970
1013
  appId,
971
1014
  yaverAuthenticated,
972
1015
  ownerAuthorized,
1016
+ accountAuthorized: account.accountAuthorized,
973
1017
  installationId,
974
1018
  deviceState,
975
1019
  authorized: yaverAuthenticated && account.installationAuthorized && deviceState === 'active',
1020
+ controlPresentation: account.controlPresentation,
1021
+ gestureSupported: account.gestureSupported,
1022
+ gestureCapabilityReason: account.gestureCapabilityReason,
1023
+ controlOnboardingSeen: account.controlOnboardingSeen,
976
1024
  };
977
1025
  }
978
1026
 
@@ -1002,6 +1050,254 @@ export class YaverFeedback {
1002
1050
  return visible;
1003
1051
  }
1004
1052
 
1053
+ /** Capability-gated in-app Dogfood controls. Every new exact installation
1054
+ * starts with Y until Convex records onboarding. A supported standalone app
1055
+ * can then select a passive three-finger hold and no persistent pixels;
1056
+ * missing native support or accessibility touch exploration keeps Y. Yaver's
1057
+ * own container suppresses both because its split preview/chat UI owns them. */
1058
+ static async syncDogfoodControlGesture(
1059
+ syncOptions: DogfoodControlSyncOptions = {},
1060
+ ): Promise<DogfoodControlTriggerState> {
1061
+ const configured = config?.dogfood?.controlGesture;
1062
+ const native = (NativeModules as any)?.YaverDogfoodGesture;
1063
+ const base: DogfoodControlTriggerState = {
1064
+ configured: Boolean(configured),
1065
+ authorized: false,
1066
+ gestureSupported: false,
1067
+ gestureEnabled: false,
1068
+ fallbackVisible: false,
1069
+ presentation: syncOptions.presentation || 'auto',
1070
+ onboardingSeen: syncOptions.onboardingSeen === true,
1071
+ reason: configured ? 'native-module-unavailable' : 'not-configured',
1072
+ platform: Platform.OS,
1073
+ };
1074
+ if (!configured) {
1075
+ if (typeof native?.setEnabled === 'function') {
1076
+ await native.setEnabled(false, 900).catch(() => undefined);
1077
+ }
1078
+ return base;
1079
+ }
1080
+ // Backend approval makes the installation eligible; it does not mean the
1081
+ // tester is currently in Dogfood. Keep all in-app controls absent until
1082
+ // enableDeviceDogfood() has minted a live scoped session, and make Exit
1083
+ // Dogfood actually remove both the gesture and fallback Y.
1084
+ if (!YaverFeedback.getDogfoodStatus().active) {
1085
+ if (typeof native?.setEnabled === 'function') {
1086
+ await native.setEnabled(false, 900).catch(() => undefined);
1087
+ }
1088
+ return { ...base, reason: 'dogfood-session-inactive' };
1089
+ }
1090
+ if (IS_HOST_MODE || isRunningInsideYaverHost()) {
1091
+ if (typeof native?.setEnabled === 'function') {
1092
+ await native.setEnabled(false, 900).catch(() => undefined);
1093
+ }
1094
+ return { ...base, reason: 'yaver-host-owns-controls' };
1095
+ }
1096
+
1097
+ const access = await YaverFeedback.getDogfoodAccess();
1098
+ let authorized = access.authorized;
1099
+ if (authorized && config?.dogfood?.canShow) {
1100
+ try {
1101
+ authorized = await config.dogfood.canShow(access);
1102
+ } catch {
1103
+ authorized = false;
1104
+ }
1105
+ }
1106
+ const options = typeof configured === 'object' ? configured : {};
1107
+ const durationMs = Math.min(2000, Math.max(650, options.durationMs ?? 900));
1108
+ const allowFallback = options.fallback !== 'none';
1109
+ if (!authorized) {
1110
+ if (typeof native?.setEnabled === 'function') {
1111
+ await native.setEnabled(false, durationMs).catch(() => undefined);
1112
+ }
1113
+ return { ...base, appId: access.appId, installationId: access.installationId, reason: 'installation-not-authorized' };
1114
+ }
1115
+
1116
+ const preferenceScope = dogfoodControlPreferenceScope(access.appId, access.installationId);
1117
+ const cachedPresentation = await getDogfoodControlPresentation(preferenceScope);
1118
+ const cachedOnboardingSeen = await getDogfoodControlOnboardingSeen(preferenceScope);
1119
+ const onboardingSeen = syncOptions.onboardingSeen
1120
+ ?? access.controlOnboardingSeen
1121
+ ?? cachedOnboardingSeen;
1122
+ // A newly authorized tester must never be left staring at an invisible
1123
+ // feature. Until the exact installation has completed onboarding in
1124
+ // Convex, the edge-docked Y wins even when the gesture is supported.
1125
+ const preferredPresentation = syncOptions.presentation
1126
+ || access.controlPresentation
1127
+ || cachedPresentation
1128
+ || options.defaultPresentation
1129
+ || 'auto';
1130
+ const presentation: DogfoodControlPresentation = onboardingSeen
1131
+ ? preferredPresentation
1132
+ : 'minimized-y';
1133
+
1134
+ if (typeof native?.getCapability !== 'function' || typeof native?.setEnabled !== 'function') {
1135
+ const result: DogfoodControlTriggerState = {
1136
+ ...base,
1137
+ authorized: true,
1138
+ appId: access.appId,
1139
+ installationId: access.installationId,
1140
+ presentation: 'minimized-y',
1141
+ onboardingSeen,
1142
+ fallbackVisible: allowFallback,
1143
+ };
1144
+ if (syncOptions.requirePersistence) {
1145
+ const token = config?.authToken || await getToken();
1146
+ if (!token || !access.installationId) {
1147
+ throw new Error('A full Yaver session is required to save Dogfood settings.');
1148
+ }
1149
+ const persisted = await setDogfoodControlPreference({
1150
+ appId: access.appId,
1151
+ installationId: access.installationId,
1152
+ token,
1153
+ presentation: 'minimized-y',
1154
+ gestureSupported: false,
1155
+ gestureCapabilityReason: 'native-module-unavailable',
1156
+ gesturePlatform: Platform.OS,
1157
+ controlOnboardingSeen: onboardingSeen,
1158
+ });
1159
+ if (!persisted) throw new Error('Could not save this Dogfood control preference to Yaver.');
1160
+ await cacheDogfoodControlPresentation('minimized-y', preferenceScope);
1161
+ if (onboardingSeen) await setDogfoodControlOnboardingSeen(true, preferenceScope);
1162
+ }
1163
+ return result;
1164
+ }
1165
+ try {
1166
+ const capability = await native.getCapability();
1167
+ const gestureSupported = capability?.supported === true;
1168
+ const shouldEnableGesture = gestureSupported && onboardingSeen && presentation === 'auto';
1169
+ const applied = await native.setEnabled(shouldEnableGesture, durationMs);
1170
+ const gestureEnabled = shouldEnableGesture && applied?.enabled === true;
1171
+ const gestureEnableFailed = shouldEnableGesture && !gestureEnabled;
1172
+ const result: DogfoodControlTriggerState = {
1173
+ configured: true,
1174
+ authorized: true,
1175
+ appId: access.appId,
1176
+ installationId: access.installationId,
1177
+ gestureSupported,
1178
+ gestureEnabled,
1179
+ fallbackVisible: allowFallback && (!gestureSupported || presentation === 'minimized-y' || gestureEnableFailed),
1180
+ presentation,
1181
+ onboardingSeen,
1182
+ reason: gestureEnableFailed
1183
+ ? 'gesture-enable-failed'
1184
+ : String(applied?.reason || capability?.reason || (gestureSupported ? 'supported' : 'unsupported')),
1185
+ platform: String(applied?.platform || capability?.platform || Platform.OS),
1186
+ };
1187
+ const token = config?.authToken || await getToken();
1188
+ if (syncOptions.requirePersistence && (!token || !access.installationId)) {
1189
+ await native.setEnabled(false, durationMs).catch(() => undefined);
1190
+ throw new Error('A full Yaver session is required to save Dogfood settings.');
1191
+ }
1192
+ if (token && access.installationId) {
1193
+ const needsPersistence =
1194
+ access.controlPresentation !== presentation
1195
+ || access.gestureSupported !== gestureSupported
1196
+ || access.gestureCapabilityReason !== result.reason
1197
+ || (syncOptions.onboardingSeen === true && access.controlOnboardingSeen !== true);
1198
+ if (needsPersistence) {
1199
+ const persisted = await setDogfoodControlPreference({
1200
+ appId: access.appId,
1201
+ installationId: access.installationId,
1202
+ token,
1203
+ presentation,
1204
+ gestureSupported,
1205
+ gestureCapabilityReason: result.reason,
1206
+ gesturePlatform: result.platform || Platform.OS,
1207
+ controlOnboardingSeen: onboardingSeen,
1208
+ });
1209
+ if (!persisted && syncOptions.requirePersistence) {
1210
+ await native.setEnabled(false, durationMs).catch(() => undefined);
1211
+ throw new Error('Could not save this Dogfood control preference to Yaver.');
1212
+ }
1213
+ if (persisted) {
1214
+ await cacheDogfoodControlPresentation(presentation, preferenceScope);
1215
+ if (onboardingSeen) await setDogfoodControlOnboardingSeen(true, preferenceScope);
1216
+ }
1217
+ } else {
1218
+ await cacheDogfoodControlPresentation(presentation, preferenceScope);
1219
+ if (onboardingSeen) await setDogfoodControlOnboardingSeen(true, preferenceScope);
1220
+ }
1221
+ }
1222
+ return result;
1223
+ } catch (error) {
1224
+ await native.setEnabled(false, durationMs).catch(() => undefined);
1225
+ if (syncOptions.requirePersistence) {
1226
+ throw error instanceof Error
1227
+ ? error
1228
+ : new Error('Could not save this Dogfood control preference to Yaver.');
1229
+ }
1230
+ return {
1231
+ ...base,
1232
+ authorized: true,
1233
+ appId: access.appId,
1234
+ installationId: access.installationId,
1235
+ presentation: 'minimized-y',
1236
+ onboardingSeen,
1237
+ fallbackVisible: allowFallback,
1238
+ reason: 'native-capability-check-failed',
1239
+ };
1240
+ }
1241
+ }
1242
+
1243
+ /** Complete first-run onboarding or change the later control preference.
1244
+ * Convex is authoritative and keyed by full Yaver user + app + installation;
1245
+ * the local value is only a cold-start cache. */
1246
+ static async setDogfoodControlPresentation(
1247
+ presentation: DogfoodControlPresentation,
1248
+ ): Promise<DogfoodControlTriggerState> {
1249
+ if (presentation !== 'auto' && presentation !== 'minimized-y') {
1250
+ throw new Error('Invalid Dogfood control presentation.');
1251
+ }
1252
+ return YaverFeedback.syncDogfoodControlGesture({
1253
+ presentation,
1254
+ onboardingSeen: true,
1255
+ requirePersistence: true,
1256
+ });
1257
+ }
1258
+
1259
+ /** One-tap fast reload for the compact Dogfood card. It preserves the same
1260
+ * selected render machine and bearer-authenticated P2P route as the full
1261
+ * Feedback modal, and names missing auth/machine state instead of no-oping. */
1262
+ static async requestDogfoodFastReload(): Promise<string> {
1263
+ await YaverFeedback.hydrateSession();
1264
+ if (!config?.authToken) {
1265
+ YaverFeedback.showLogin();
1266
+ throw new Error('Sign in to Yaver before requesting Fast Reload.');
1267
+ }
1268
+ if (!config.preferredDeviceId) {
1269
+ YaverFeedback.showMachinePicker();
1270
+ throw new Error('Choose a render machine before requesting Fast Reload.');
1271
+ }
1272
+ const selected = await YaverFeedback.getSelectedRemoteDevice();
1273
+ if (!selected) {
1274
+ YaverFeedback.showMachinePicker();
1275
+ throw new Error('The selected render machine is no longer available.');
1276
+ }
1277
+ if (selected.needsAuth) {
1278
+ YaverFeedback.showMachinePicker();
1279
+ throw new Error('The selected render machine needs pairing again.');
1280
+ }
1281
+ if (!selected.isOnline) {
1282
+ throw new Error('The selected render machine is offline.');
1283
+ }
1284
+ let client = YaverFeedback.getRenderP2PClient();
1285
+ if (!client && await YaverFeedback.reconnect()) {
1286
+ client = YaverFeedback.getRenderP2PClient();
1287
+ }
1288
+ if (!client) throw new Error('The render machine is not connected yet.');
1289
+ try {
1290
+ const ack = await client.reloadWithMode('fast');
1291
+ return ack.message;
1292
+ } catch (firstError) {
1293
+ if (await YaverFeedback.reconnect()) {
1294
+ const retry = YaverFeedback.getRenderP2PClient();
1295
+ if (retry) return (await retry.reloadWithMode('fast')).message;
1296
+ }
1297
+ throw firstError;
1298
+ }
1299
+ }
1300
+
1005
1301
  /** Backwards-compatible entry point for existing integrations. */
1006
1302
  static async beginDogfoodOnboarding(options: DogfoodOnboardingOptions): Promise<DogfoodFlowState> {
1007
1303
  YaverFeedback.configureDogfood(options);
@@ -1025,11 +1321,28 @@ export class YaverFeedback {
1025
1321
  publishDogfoodFlow(state);
1026
1322
  return state;
1027
1323
  }
1028
- await YaverFeedback.hydrateSession();
1029
- const token = config?.authToken || await getToken();
1030
- const account = token
1031
- ? await getDogfoodAccountAccess(appId, token)
1032
- : { authenticated: false, ownerAuthorized: false, installationAuthorized: false };
1324
+ let token: string | null;
1325
+ let account: Awaited<ReturnType<typeof getDogfoodAccountAccess>> | {
1326
+ authenticated: false;
1327
+ ownerAuthorized: false;
1328
+ accountAuthorized: false;
1329
+ installationAuthorized: false;
1330
+ };
1331
+ try {
1332
+ await YaverFeedback.hydrateSession();
1333
+ token = config?.authToken || await getToken();
1334
+ account = token
1335
+ ? await getDogfoodAccountAccess(appId, token)
1336
+ : { authenticated: false, ownerAuthorized: false, accountAuthorized: false, installationAuthorized: false };
1337
+ } catch (cause) {
1338
+ const state: DogfoodFlowState = {
1339
+ phase: 'error',
1340
+ appId,
1341
+ error: cause instanceof Error ? cause.message : 'Yaver could not verify Dogfood access.',
1342
+ };
1343
+ publishDogfoodFlow(state);
1344
+ return state;
1345
+ }
1033
1346
  // A device key is a second factor for this installation, never a
1034
1347
  // replacement for a real Yaver account. Narrow installation sessions and
1035
1348
  // stale/invalid cached tokens both return authenticated=false here.
@@ -1039,6 +1352,15 @@ export class YaverFeedback {
1039
1352
  YaverFeedback.showLogin();
1040
1353
  return state;
1041
1354
  }
1355
+ if (!account.accountAuthorized) {
1356
+ const state: DogfoodFlowState = {
1357
+ phase: 'denied',
1358
+ appId,
1359
+ error: 'The app owner has not enabled Dogfood for this Yaver account.',
1360
+ };
1361
+ publishDogfoodFlow(state);
1362
+ return state;
1363
+ }
1042
1364
  if (!config?.preferredDeviceId) {
1043
1365
  const state: DogfoodFlowState = { phase: 'machine-required', appId };
1044
1366
  publishDogfoodFlow(state);
@@ -1162,6 +1484,7 @@ export class YaverFeedback {
1162
1484
  renderP2PClient = null;
1163
1485
  p2pAuthToken = null;
1164
1486
  await YaverFeedback.syncDogfoodAppShortcut().catch(() => false);
1487
+ await YaverFeedback.syncDogfoodControlGesture().catch(() => undefined);
1165
1488
  }
1166
1489
 
1167
1490
  /**
@@ -1461,6 +1784,7 @@ export class YaverFeedback {
1461
1784
  DeviceEventEmitter.emit('yaverFeedback:dogfoodChanged', { active: !!session, status });
1462
1785
  } catch { /* noop */ }
1463
1786
  await YaverFeedback.syncDogfoodAppShortcut().catch(() => false);
1787
+ await YaverFeedback.syncDogfoodControlGesture().catch(() => undefined);
1464
1788
  return { status, installationId: info.installationId, session };
1465
1789
  }
1466
1790
 
@@ -1475,6 +1799,7 @@ export class YaverFeedback {
1475
1799
  const { DeviceEventEmitter } = require('react-native');
1476
1800
  DeviceEventEmitter.emit('yaverFeedback:dogfoodChanged', { active: false, exited: true });
1477
1801
  } catch { /* noop */ }
1802
+ await YaverFeedback.syncDogfoodControlGesture().catch(() => undefined);
1478
1803
  }
1479
1804
 
1480
1805
  /** Returns the resolved relay password the SDK is currently using.
@@ -2019,6 +2344,12 @@ export class YaverFeedback {
2019
2344
  dogfoodShortcutAppStateSubscription = null;
2020
2345
  dogfoodActivationSubscription?.remove();
2021
2346
  dogfoodActivationSubscription = null;
2347
+ try {
2348
+ const native = (NativeModules as any)?.YaverDogfoodGesture;
2349
+ if (typeof native?.setEnabled === 'function') {
2350
+ void native.setEnabled(false, 900).catch(() => undefined);
2351
+ }
2352
+ } catch { /* native trigger teardown is best-effort */ }
2022
2353
  lastDogfoodActivationUrl = '';
2023
2354
  // Before `enabled = false` / `config = null` below, so an in-flight retry
2024
2355
  // can't fire against a torn-down config.
@@ -56,4 +56,19 @@ final class TalosSceneDelegate: UIResponder, UIWindowSceneDelegate {
56
56
  expect(android).toContain('addDynamicShortcuts');
57
57
  expect(android).toContain('consumeDogfoodShortcut');
58
58
  });
59
+
60
+ it('observes a three-finger hold without consuming host app touches', () => {
61
+ const ios = readFileSync(join(__dirname, '../../ios/YaverDogfoodGesture.swift'), 'utf8');
62
+ const android = readFileSync(join(__dirname, '../../android/src/main/java/io/yaver/feedback/YaverDogfoodGestureModule.java'), 'utf8');
63
+ const controls = readFileSync(join(__dirname, '../DogfoodQuickControls.tsx'), 'utf8');
64
+ expect(ios).toContain('numberOfTouchesRequired = 3');
65
+ expect(ios).toContain('cancelsTouchesInView = false');
66
+ expect(ios).toContain('isVoiceOverRunning');
67
+ expect(android).toContain('event.getPointerCount() >= 3');
68
+ expect(android).toContain('method.invoke(callback, args)');
69
+ expect(android).toContain('isTouchExplorationEnabled');
70
+ expect(controls).toContain('yaver-dogfood-fast-reload');
71
+ expect(controls).toContain('yaver-dogfood-chat');
72
+ expect(controls).toContain('yaver-dogfood-minimized-control');
73
+ });
59
74
  });