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.
- package/README.md +39 -7
- package/android/src/main/java/io/yaver/feedback/YaverDogfoodGestureModule.java +241 -0
- package/android/src/main/java/io/yaver/feedback/YaverHotReloadPackage.java +1 -0
- package/app.plugin.js +12 -2
- package/dist/AuthOverlay.js +13 -2
- package/dist/DogfoodQuickControls.d.ts +8 -0
- package/dist/DogfoodQuickControls.js +401 -0
- package/dist/DogfoodSessionUi.d.ts +50 -0
- package/dist/DogfoodSessionUi.js +135 -0
- package/dist/FeedbackModal.js +50 -22
- package/dist/YaverFeedback.d.ts +35 -1
- package/dist/YaverFeedback.js +312 -12
- package/dist/__tests__/NativeDogfoodShortcut.test.js +14 -0
- package/dist/__tests__/YaverFeedback.test.js +173 -0
- package/dist/auth.d.ts +15 -0
- package/dist/auth.js +41 -2
- package/dist/dogfoodPolicy.d.ts +22 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +8 -2
- package/dist/preferences.d.ts +13 -0
- package/dist/preferences.js +84 -0
- package/ios/YaverDogfoodGesture.m +14 -0
- package/ios/YaverDogfoodGesture.swift +175 -0
- package/package.json +1 -1
- package/src/AuthOverlay.tsx +20 -3
- package/src/DogfoodQuickControls.tsx +461 -0
- package/src/DogfoodSessionUi.tsx +213 -0
- package/src/FeedbackModal.tsx +62 -34
- package/src/YaverFeedback.ts +343 -12
- package/src/__tests__/NativeDogfoodShortcut.test.ts +15 -0
- package/src/__tests__/YaverFeedback.test.ts +183 -0
- package/src/auth.ts +53 -2
- package/src/dogfoodPolicy.ts +22 -1
- package/src/index.ts +13 -1
- package/src/preferences.ts +89 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import React
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Passive, capability-gated three-finger hold for Dogfood quick controls.
|
|
7
|
+
*
|
|
8
|
+
* The recognizer is attached to the app window with cancelsTouchesInView=false
|
|
9
|
+
* and simultaneous recognition enabled, so observing the gesture does not
|
|
10
|
+
* install a transparent React overlay or take ownership of ordinary app taps.
|
|
11
|
+
* VoiceOver and Switch Control disable it because accessibility owns complex
|
|
12
|
+
* multi-touch gestures; JS then renders the minimized draggable Y fallback.
|
|
13
|
+
*/
|
|
14
|
+
@objc(YaverDogfoodGesture)
|
|
15
|
+
class YaverDogfoodGesture: RCTEventEmitter, UIGestureRecognizerDelegate {
|
|
16
|
+
private static let triggerEvent = "yaverDogfoodControlGesture"
|
|
17
|
+
private static let capabilityEvent = "yaverDogfoodControlCapability"
|
|
18
|
+
|
|
19
|
+
private weak var attachedWindow: UIWindow?
|
|
20
|
+
private var recognizer: UILongPressGestureRecognizer?
|
|
21
|
+
private var requestedEnabled = false
|
|
22
|
+
private var durationMs: Double = 900
|
|
23
|
+
private var hasJSListeners = false
|
|
24
|
+
private var observers: [NSObjectProtocol] = []
|
|
25
|
+
|
|
26
|
+
override static func requiresMainQueueSetup() -> Bool { true }
|
|
27
|
+
|
|
28
|
+
override func supportedEvents() -> [String]! {
|
|
29
|
+
[Self.triggerEvent, Self.capabilityEvent]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
override func startObserving() {
|
|
33
|
+
hasJSListeners = true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
override func stopObserving() {
|
|
37
|
+
hasJSListeners = false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
override init() {
|
|
41
|
+
super.init()
|
|
42
|
+
let center = NotificationCenter.default
|
|
43
|
+
observers.append(center.addObserver(
|
|
44
|
+
forName: UIApplication.didBecomeActiveNotification,
|
|
45
|
+
object: nil,
|
|
46
|
+
queue: .main
|
|
47
|
+
) { [weak self] _ in self?.reconcile() })
|
|
48
|
+
observers.append(center.addObserver(
|
|
49
|
+
forName: UIAccessibility.voiceOverStatusDidChangeNotification,
|
|
50
|
+
object: nil,
|
|
51
|
+
queue: .main
|
|
52
|
+
) { [weak self] _ in self?.reconcileAndEmit() })
|
|
53
|
+
observers.append(center.addObserver(
|
|
54
|
+
forName: UIAccessibility.switchControlStatusDidChangeNotification,
|
|
55
|
+
object: nil,
|
|
56
|
+
queue: .main
|
|
57
|
+
) { [weak self] _ in self?.reconcileAndEmit() })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
deinit {
|
|
61
|
+
observers.forEach(NotificationCenter.default.removeObserver)
|
|
62
|
+
detach()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@objc func getCapability(_ resolve: @escaping RCTPromiseResolveBlock,
|
|
66
|
+
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
|
67
|
+
DispatchQueue.main.async { resolve(self.status()) }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@objc func setEnabled(_ enabled: Bool,
|
|
71
|
+
durationMs: Double,
|
|
72
|
+
resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
73
|
+
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
|
74
|
+
DispatchQueue.main.async {
|
|
75
|
+
self.requestedEnabled = enabled
|
|
76
|
+
self.durationMs = min(max(durationMs, 650), 2000)
|
|
77
|
+
self.reconcile()
|
|
78
|
+
resolve(self.status())
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private func accessibilityConflict() -> Bool {
|
|
83
|
+
UIAccessibility.isVoiceOverRunning || UIAccessibility.isSwitchControlRunning
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private func simulatorInputConflict() -> Bool {
|
|
87
|
+
#if targetEnvironment(simulator)
|
|
88
|
+
// Simulator's Option-key multi-touch synthesizes two pointers only.
|
|
89
|
+
// Treat it as unsupported so the SDK exposes the tappable Y fallback.
|
|
90
|
+
return true
|
|
91
|
+
#else
|
|
92
|
+
return false
|
|
93
|
+
#endif
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private func keyWindow() -> UIWindow? {
|
|
97
|
+
if #available(iOS 13.0, *) {
|
|
98
|
+
return UIApplication.shared.connectedScenes
|
|
99
|
+
.compactMap { $0 as? UIWindowScene }
|
|
100
|
+
.flatMap { $0.windows }
|
|
101
|
+
.first(where: { $0.isKeyWindow })
|
|
102
|
+
}
|
|
103
|
+
return UIApplication.shared.keyWindow
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private func status() -> [String: Any] {
|
|
107
|
+
let accessibility = accessibilityConflict()
|
|
108
|
+
let simulator = simulatorInputConflict()
|
|
109
|
+
let windowAvailable = keyWindow() != nil
|
|
110
|
+
return [
|
|
111
|
+
"supported": !accessibility && !simulator,
|
|
112
|
+
"enabled": requestedEnabled && !accessibility && !simulator && recognizer != nil,
|
|
113
|
+
"reason": accessibility
|
|
114
|
+
? "accessibility-touch-exploration"
|
|
115
|
+
: (simulator ? "simulator-three-finger-input-unavailable" : (windowAvailable ? "supported" : "window-unavailable")),
|
|
116
|
+
"platform": "ios",
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private func reconcileAndEmit() {
|
|
121
|
+
reconcile()
|
|
122
|
+
if hasJSListeners {
|
|
123
|
+
sendEvent(withName: Self.capabilityEvent, body: status())
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private func reconcile() {
|
|
128
|
+
guard requestedEnabled, !accessibilityConflict(), !simulatorInputConflict(), let window = keyWindow() else {
|
|
129
|
+
detach()
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
if attachedWindow === window, let recognizer = recognizer {
|
|
133
|
+
recognizer.minimumPressDuration = durationMs / 1000
|
|
134
|
+
recognizer.isEnabled = true
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
detach()
|
|
138
|
+
let hold = UILongPressGestureRecognizer(target: self, action: #selector(handleHold(_:)))
|
|
139
|
+
hold.minimumPressDuration = durationMs / 1000
|
|
140
|
+
hold.numberOfTouchesRequired = 3
|
|
141
|
+
hold.cancelsTouchesInView = false
|
|
142
|
+
hold.delaysTouchesBegan = false
|
|
143
|
+
hold.delaysTouchesEnded = false
|
|
144
|
+
hold.delegate = self
|
|
145
|
+
window.addGestureRecognizer(hold)
|
|
146
|
+
attachedWindow = window
|
|
147
|
+
recognizer = hold
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private func detach() {
|
|
151
|
+
if let recognizer = recognizer {
|
|
152
|
+
attachedWindow?.removeGestureRecognizer(recognizer)
|
|
153
|
+
}
|
|
154
|
+
recognizer = nil
|
|
155
|
+
attachedWindow = nil
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
@objc private func handleHold(_ sender: UILongPressGestureRecognizer) {
|
|
159
|
+
guard sender.state == .began, requestedEnabled, !accessibilityConflict() else { return }
|
|
160
|
+
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
|
161
|
+
if hasJSListeners {
|
|
162
|
+
sendEvent(withName: Self.triggerEvent, body: ["source": "three-finger-hold"])
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
|
|
167
|
+
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
168
|
+
true
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
|
|
172
|
+
shouldReceive touch: UITouch) -> Bool {
|
|
173
|
+
!accessibilityConflict()
|
|
174
|
+
}
|
|
175
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yaver-feedback-react-native",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.9",
|
|
4
4
|
"description": "Visual feedback SDK for Yaver — bug reports, screen recording, voice vibe coding, and local-first developer workflows",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
package/src/AuthOverlay.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { DeviceEventEmitter, Modal } from 'react-native';
|
|
2
|
+
import { Alert, DeviceEventEmitter, Modal } from 'react-native';
|
|
3
3
|
import { YaverLoginScreen } from './LoginScreen';
|
|
4
4
|
import { YaverMachinePickerScreen } from './MachinePickerScreen';
|
|
5
5
|
import { YaverFeedback } from './YaverFeedback';
|
|
@@ -30,6 +30,23 @@ export const AuthOverlay: React.FC = () => {
|
|
|
30
30
|
setPickerVisible(false);
|
|
31
31
|
}, []);
|
|
32
32
|
|
|
33
|
+
const continueDogfood = useCallback(async () => {
|
|
34
|
+
try {
|
|
35
|
+
const state = await YaverFeedback.continueDogfoodOnboarding();
|
|
36
|
+
if (state.phase === 'denied' || state.phase === 'error') {
|
|
37
|
+
Alert.alert(
|
|
38
|
+
'Dogfood unavailable',
|
|
39
|
+
state.error || 'This Yaver account or device is not enabled for this app.',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
} catch (cause) {
|
|
43
|
+
Alert.alert(
|
|
44
|
+
'Dogfood unavailable',
|
|
45
|
+
cause instanceof Error ? cause.message : 'Yaver could not continue Dogfood setup.',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
33
50
|
useEffect(() => {
|
|
34
51
|
let mounted = true;
|
|
35
52
|
void getToken().then((cached) => {
|
|
@@ -58,7 +75,7 @@ export const AuthOverlay: React.FC = () => {
|
|
|
58
75
|
await YaverFeedback.setAuthToken(newToken);
|
|
59
76
|
if (YaverFeedback.getDogfoodOnboarding()) {
|
|
60
77
|
closeAll();
|
|
61
|
-
await
|
|
78
|
+
await continueDogfood();
|
|
62
79
|
} else {
|
|
63
80
|
openPicker();
|
|
64
81
|
}
|
|
@@ -68,7 +85,7 @@ export const AuthOverlay: React.FC = () => {
|
|
|
68
85
|
await YaverFeedback.setPreferredDevice(device.deviceId);
|
|
69
86
|
closeAll();
|
|
70
87
|
if (YaverFeedback.getDogfoodOnboarding()) {
|
|
71
|
-
await
|
|
88
|
+
await continueDogfood();
|
|
72
89
|
} else {
|
|
73
90
|
DeviceEventEmitter.emit('yaverFeedback:startReport');
|
|
74
91
|
}
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Animated,
|
|
4
|
+
DeviceEventEmitter,
|
|
5
|
+
Keyboard,
|
|
6
|
+
Modal,
|
|
7
|
+
PanResponder,
|
|
8
|
+
Pressable,
|
|
9
|
+
StyleSheet,
|
|
10
|
+
Text,
|
|
11
|
+
View,
|
|
12
|
+
useWindowDimensions,
|
|
13
|
+
} from 'react-native';
|
|
14
|
+
import { YaverFeedback, type DogfoodControlTriggerState } from './YaverFeedback';
|
|
15
|
+
import {
|
|
16
|
+
getDogfoodControlPosition,
|
|
17
|
+
setDogfoodControlPosition,
|
|
18
|
+
type DogfoodControlEdge,
|
|
19
|
+
type DogfoodControlPresentation,
|
|
20
|
+
} from './preferences';
|
|
21
|
+
|
|
22
|
+
const FALLBACK_SIZE = 36;
|
|
23
|
+
const DOCK_VISIBLE = 21;
|
|
24
|
+
const SAFE_TOP = 64;
|
|
25
|
+
const SAFE_BOTTOM = 92;
|
|
26
|
+
const EMPTY_STATE: DogfoodControlTriggerState = {
|
|
27
|
+
configured: false,
|
|
28
|
+
authorized: false,
|
|
29
|
+
gestureSupported: false,
|
|
30
|
+
gestureEnabled: false,
|
|
31
|
+
fallbackVisible: false,
|
|
32
|
+
presentation: 'auto',
|
|
33
|
+
onboardingSeen: false,
|
|
34
|
+
reason: 'not-configured',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function preferenceScope(state: DogfoodControlTriggerState): string | undefined {
|
|
38
|
+
return state.appId && state.installationId ? `${state.appId}:${state.installationId}` : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The standalone SDK's only persistent chrome. A newly authorized tester sees
|
|
43
|
+
* this edge-docked Y until first-run onboarding is completed in Convex. After
|
|
44
|
+
* that, capable devices default to a passive three-finger hold; unsupported
|
|
45
|
+
* devices keep the Y. Both entry points open exactly the same compact card.
|
|
46
|
+
*/
|
|
47
|
+
export const DogfoodQuickControls: React.FC = () => {
|
|
48
|
+
const { width, height } = useWindowDimensions();
|
|
49
|
+
const orientation = width > height ? 'landscape' : 'portrait';
|
|
50
|
+
const defaultPosition = useMemo(() => ({
|
|
51
|
+
x: Math.max(width - DOCK_VISIBLE, 0),
|
|
52
|
+
y: Math.max(Math.round(height * 0.45), SAFE_TOP),
|
|
53
|
+
}), [height, width]);
|
|
54
|
+
const pan = useRef(new Animated.ValueXY(defaultPosition)).current;
|
|
55
|
+
const opacity = useRef(new Animated.Value(0.72)).current;
|
|
56
|
+
const lastPosition = useRef(defaultPosition);
|
|
57
|
+
const dragStart = useRef(defaultPosition);
|
|
58
|
+
const dragged = useRef(false);
|
|
59
|
+
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
60
|
+
const [dockEdge, setDockEdge] = useState<DogfoodControlEdge>('right');
|
|
61
|
+
const [keyboardVisible, setKeyboardVisible] = useState(false);
|
|
62
|
+
const [state, setState] = useState<DogfoodControlTriggerState>(EMPTY_STATE);
|
|
63
|
+
const [open, setOpen] = useState(false);
|
|
64
|
+
const [showControlSettings, setShowControlSettings] = useState(false);
|
|
65
|
+
const [busy, setBusy] = useState<'reload' | 'chat' | 'preference' | null>(null);
|
|
66
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
67
|
+
|
|
68
|
+
const scheduleFade = useCallback(() => {
|
|
69
|
+
if (fadeTimer.current) clearTimeout(fadeTimer.current);
|
|
70
|
+
fadeTimer.current = setTimeout(() => {
|
|
71
|
+
Animated.timing(opacity, { toValue: 0.42, duration: 260, useNativeDriver: true }).start();
|
|
72
|
+
}, 1800);
|
|
73
|
+
}, [opacity]);
|
|
74
|
+
|
|
75
|
+
const wakeControl = useCallback(() => {
|
|
76
|
+
if (fadeTimer.current) clearTimeout(fadeTimer.current);
|
|
77
|
+
Animated.timing(opacity, { toValue: 1, duration: 100, useNativeDriver: true }).start();
|
|
78
|
+
}, [opacity]);
|
|
79
|
+
|
|
80
|
+
const refresh = useCallback(async () => {
|
|
81
|
+
try {
|
|
82
|
+
setState(await YaverFeedback.syncDogfoodControlGesture());
|
|
83
|
+
} catch {
|
|
84
|
+
setState(EMPTY_STATE);
|
|
85
|
+
}
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
void refresh();
|
|
90
|
+
const trigger = DeviceEventEmitter.addListener('yaverDogfoodControlGesture', () => {
|
|
91
|
+
setMessage(null);
|
|
92
|
+
setShowControlSettings(false);
|
|
93
|
+
setOpen(true);
|
|
94
|
+
});
|
|
95
|
+
const capability = DeviceEventEmitter.addListener('yaverDogfoodControlCapability', () => {
|
|
96
|
+
void refresh();
|
|
97
|
+
});
|
|
98
|
+
// Enrollment approval and Exit Dogfood both change SDK mode while this
|
|
99
|
+
// component remains mounted. Refresh React state immediately; native
|
|
100
|
+
// setEnabled() alone cannot make the fallback Y appear or disappear.
|
|
101
|
+
const mode = DeviceEventEmitter.addListener('yaverFeedback:dogfoodChanged', () => {
|
|
102
|
+
void refresh();
|
|
103
|
+
});
|
|
104
|
+
return () => {
|
|
105
|
+
trigger.remove();
|
|
106
|
+
capability.remove();
|
|
107
|
+
mode.remove();
|
|
108
|
+
};
|
|
109
|
+
}, [refresh]);
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardVisible(true));
|
|
113
|
+
const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardVisible(false));
|
|
114
|
+
return () => { show.remove(); hide.remove(); };
|
|
115
|
+
}, []);
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
let cancelled = false;
|
|
119
|
+
void (async () => {
|
|
120
|
+
const saved = await getDogfoodControlPosition(orientation, preferenceScope(state));
|
|
121
|
+
if (cancelled) return;
|
|
122
|
+
const edge = saved?.edge || 'right';
|
|
123
|
+
const minY = SAFE_TOP;
|
|
124
|
+
const maxY = Math.max(minY, height - FALLBACK_SIZE - SAFE_BOTTOM);
|
|
125
|
+
const y = saved
|
|
126
|
+
? minY + (maxY - minY) * saved.yRatio
|
|
127
|
+
: Math.max(minY, Math.min(maxY, Math.round(height * 0.45)));
|
|
128
|
+
const next = { x: edge === 'left' ? -FALLBACK_SIZE + DOCK_VISIBLE : width - DOCK_VISIBLE, y };
|
|
129
|
+
setDockEdge(edge);
|
|
130
|
+
lastPosition.current = next;
|
|
131
|
+
pan.setValue(next);
|
|
132
|
+
scheduleFade();
|
|
133
|
+
})();
|
|
134
|
+
return () => { cancelled = true; };
|
|
135
|
+
}, [height, orientation, pan, scheduleFade, state.appId, state.installationId, width]);
|
|
136
|
+
|
|
137
|
+
useEffect(() => () => {
|
|
138
|
+
if (fadeTimer.current) clearTimeout(fadeTimer.current);
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
const panResponder = useMemo(() => PanResponder.create({
|
|
142
|
+
onStartShouldSetPanResponder: () => true,
|
|
143
|
+
onMoveShouldSetPanResponder: (_, gesture) => Math.abs(gesture.dx) > 3 || Math.abs(gesture.dy) > 3,
|
|
144
|
+
onPanResponderGrant: () => {
|
|
145
|
+
wakeControl();
|
|
146
|
+
dragged.current = false;
|
|
147
|
+
dragStart.current = lastPosition.current;
|
|
148
|
+
pan.setOffset(lastPosition.current);
|
|
149
|
+
pan.setValue({ x: 0, y: 0 });
|
|
150
|
+
},
|
|
151
|
+
onPanResponderMove: (_, gesture) => {
|
|
152
|
+
if (Math.abs(gesture.dx) > 3 || Math.abs(gesture.dy) > 3) dragged.current = true;
|
|
153
|
+
Animated.event([null, { dx: pan.x, dy: pan.y }], { useNativeDriver: false })(_, gesture);
|
|
154
|
+
},
|
|
155
|
+
onPanResponderRelease: (_, gesture) => {
|
|
156
|
+
pan.flattenOffset();
|
|
157
|
+
const minY = SAFE_TOP;
|
|
158
|
+
const maxY = Math.max(minY, height - FALLBACK_SIZE - SAFE_BOTTOM);
|
|
159
|
+
const rawX = dragStart.current.x + gesture.dx;
|
|
160
|
+
const y = Math.max(minY, Math.min(maxY, dragStart.current.y + gesture.dy));
|
|
161
|
+
const edge: DogfoodControlEdge = rawX + FALLBACK_SIZE / 2 < width / 2 ? 'left' : 'right';
|
|
162
|
+
const x = edge === 'left' ? -FALLBACK_SIZE + DOCK_VISIBLE : width - DOCK_VISIBLE;
|
|
163
|
+
const next = { x, y };
|
|
164
|
+
const yRatio = maxY === minY ? 0.5 : (y - minY) / (maxY - minY);
|
|
165
|
+
setDockEdge(edge);
|
|
166
|
+
lastPosition.current = next;
|
|
167
|
+
Animated.spring(pan, { toValue: next, useNativeDriver: false, friction: 7 }).start(scheduleFade);
|
|
168
|
+
void setDogfoodControlPosition(orientation, { edge, yRatio }, preferenceScope(state));
|
|
169
|
+
},
|
|
170
|
+
onPanResponderTerminate: scheduleFade,
|
|
171
|
+
}), [height, orientation, pan, scheduleFade, state.appId, state.installationId, wakeControl, width]);
|
|
172
|
+
|
|
173
|
+
const choosePresentation = useCallback(async (presentation: DogfoodControlPresentation) => {
|
|
174
|
+
if (busy) return;
|
|
175
|
+
setBusy('preference');
|
|
176
|
+
setMessage(null);
|
|
177
|
+
try {
|
|
178
|
+
const next = await YaverFeedback.setDogfoodControlPresentation(presentation);
|
|
179
|
+
setState(next);
|
|
180
|
+
setShowControlSettings(false);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
setMessage(error instanceof Error ? error.message : String(error));
|
|
183
|
+
} finally {
|
|
184
|
+
setBusy(null);
|
|
185
|
+
}
|
|
186
|
+
}, [busy]);
|
|
187
|
+
|
|
188
|
+
const fastReload = useCallback(async () => {
|
|
189
|
+
if (busy) return;
|
|
190
|
+
setBusy('reload');
|
|
191
|
+
setMessage(null);
|
|
192
|
+
try {
|
|
193
|
+
const ack = await YaverFeedback.requestDogfoodFastReload();
|
|
194
|
+
setMessage(ack || 'Fast Reload requested.');
|
|
195
|
+
setTimeout(() => setOpen(false), 650);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
setMessage(error instanceof Error ? error.message : String(error));
|
|
198
|
+
} finally {
|
|
199
|
+
setBusy(null);
|
|
200
|
+
}
|
|
201
|
+
}, [busy]);
|
|
202
|
+
|
|
203
|
+
const openChat = useCallback(async () => {
|
|
204
|
+
if (busy) return;
|
|
205
|
+
setBusy('chat');
|
|
206
|
+
setMessage(null);
|
|
207
|
+
setOpen(false);
|
|
208
|
+
const result = await YaverFeedback.openDogfood();
|
|
209
|
+
if (result.phase === 'denied' || result.phase === 'error') {
|
|
210
|
+
setMessage(result.error || 'Dogfood access is not available on this installation.');
|
|
211
|
+
setOpen(true);
|
|
212
|
+
}
|
|
213
|
+
setBusy(null);
|
|
214
|
+
}, [busy]);
|
|
215
|
+
|
|
216
|
+
const openSessionSetup = useCallback(async () => {
|
|
217
|
+
if (busy) return;
|
|
218
|
+
setOpen(false);
|
|
219
|
+
setShowControlSettings(false);
|
|
220
|
+
const result = await YaverFeedback.openDogfood();
|
|
221
|
+
if (result.phase === 'denied' || result.phase === 'error') {
|
|
222
|
+
setMessage(result.error || 'Dogfood session settings are not available on this installation.');
|
|
223
|
+
setOpen(true);
|
|
224
|
+
setShowControlSettings(true);
|
|
225
|
+
}
|
|
226
|
+
}, [busy]);
|
|
227
|
+
|
|
228
|
+
if (!state.configured || !state.authorized) return null;
|
|
229
|
+
const onboarding = !state.onboardingSeen;
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<>
|
|
233
|
+
{state.fallbackVisible && !keyboardVisible && !open ? (
|
|
234
|
+
<Animated.View pointerEvents="box-none" style={[StyleSheet.absoluteFill, styles.layer]}>
|
|
235
|
+
<Animated.View
|
|
236
|
+
{...panResponder.panHandlers}
|
|
237
|
+
style={[
|
|
238
|
+
styles.fallbackPosition,
|
|
239
|
+
{ opacity, transform: [{ translateX: pan.x }, { translateY: pan.y }] },
|
|
240
|
+
]}
|
|
241
|
+
>
|
|
242
|
+
<Pressable
|
|
243
|
+
testID="yaver-dogfood-minimized-control"
|
|
244
|
+
accessibilityRole="button"
|
|
245
|
+
accessibilityLabel="Open Dogfood controls"
|
|
246
|
+
hitSlop={10}
|
|
247
|
+
onPressIn={wakeControl}
|
|
248
|
+
onPress={() => {
|
|
249
|
+
if (dragged.current) {
|
|
250
|
+
dragged.current = false;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
setMessage(null);
|
|
254
|
+
setShowControlSettings(false);
|
|
255
|
+
setOpen(true);
|
|
256
|
+
}}
|
|
257
|
+
style={({ pressed }) => [styles.fallback, pressed && styles.pressed]}
|
|
258
|
+
>
|
|
259
|
+
<Text style={[
|
|
260
|
+
styles.fallbackText,
|
|
261
|
+
dockEdge === 'right' ? styles.rightDockText : styles.leftDockText,
|
|
262
|
+
]}>y</Text>
|
|
263
|
+
</Pressable>
|
|
264
|
+
</Animated.View>
|
|
265
|
+
</Animated.View>
|
|
266
|
+
) : null}
|
|
267
|
+
|
|
268
|
+
<Modal visible={open} transparent animationType="fade" onRequestClose={() => setOpen(false)}>
|
|
269
|
+
<Pressable style={styles.backdrop} onPress={() => setOpen(false)}>
|
|
270
|
+
<Pressable style={styles.card} onPress={(event) => event.stopPropagation()}>
|
|
271
|
+
{onboarding ? (
|
|
272
|
+
<>
|
|
273
|
+
<Text style={styles.title}>Dogfood ready</Text>
|
|
274
|
+
<Text style={styles.explanation}>
|
|
275
|
+
{state.gestureSupported
|
|
276
|
+
? 'Dogfood starts with the edge Y so you always know how to return. You can switch to a three-finger hold later from Controls.'
|
|
277
|
+
: 'This device cannot reliably use the three-finger hold, so the edge Y stays available.'}
|
|
278
|
+
</Text>
|
|
279
|
+
<ModeButton
|
|
280
|
+
title={busy === 'preference' ? 'Saving…' : 'Continue with Y'}
|
|
281
|
+
hint="Open Fast Reload and Chat from the edge"
|
|
282
|
+
disabled={busy !== null}
|
|
283
|
+
onPress={() => void choosePresentation('minimized-y')}
|
|
284
|
+
/>
|
|
285
|
+
</>
|
|
286
|
+
) : showControlSettings ? (
|
|
287
|
+
<>
|
|
288
|
+
<Text style={styles.title}>Dogfood settings</Text>
|
|
289
|
+
{state.gestureSupported ? (
|
|
290
|
+
<Text style={styles.supported}>Three-finger hold supported on this device</Text>
|
|
291
|
+
) : null}
|
|
292
|
+
<View style={styles.stackedActions}>
|
|
293
|
+
{state.gestureSupported ? (
|
|
294
|
+
<>
|
|
295
|
+
<ModeButton
|
|
296
|
+
title="Three-finger hold"
|
|
297
|
+
hint="No persistent Y over the app"
|
|
298
|
+
selected={state.presentation === 'auto'}
|
|
299
|
+
disabled={busy !== null}
|
|
300
|
+
onPress={() => void choosePresentation('auto')}
|
|
301
|
+
/>
|
|
302
|
+
<ModeButton
|
|
303
|
+
title="Always show Y"
|
|
304
|
+
hint="Keep the draggable edge control"
|
|
305
|
+
selected={state.presentation === 'minimized-y'}
|
|
306
|
+
disabled={busy !== null}
|
|
307
|
+
onPress={() => void choosePresentation('minimized-y')}
|
|
308
|
+
/>
|
|
309
|
+
</>
|
|
310
|
+
) : null}
|
|
311
|
+
<ModeButton
|
|
312
|
+
title="Session setup"
|
|
313
|
+
hint="Change machine, coding agent, model, or runtime lane"
|
|
314
|
+
disabled={busy !== null}
|
|
315
|
+
onPress={() => void openSessionSetup()}
|
|
316
|
+
/>
|
|
317
|
+
</View>
|
|
318
|
+
</>
|
|
319
|
+
) : (
|
|
320
|
+
<>
|
|
321
|
+
<View style={styles.titleRow}>
|
|
322
|
+
<Text style={styles.title}>Dogfood</Text>
|
|
323
|
+
<Pressable
|
|
324
|
+
accessibilityRole="button"
|
|
325
|
+
accessibilityLabel="Dogfood settings"
|
|
326
|
+
onPress={() => setShowControlSettings(true)}
|
|
327
|
+
style={styles.settingsButton}
|
|
328
|
+
>
|
|
329
|
+
<Text style={styles.settingsText}>Settings</Text>
|
|
330
|
+
</Pressable>
|
|
331
|
+
</View>
|
|
332
|
+
<View style={styles.actions}>
|
|
333
|
+
<Pressable
|
|
334
|
+
testID="yaver-dogfood-fast-reload"
|
|
335
|
+
accessibilityRole="button"
|
|
336
|
+
disabled={busy !== null}
|
|
337
|
+
onPress={() => void fastReload()}
|
|
338
|
+
style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}
|
|
339
|
+
>
|
|
340
|
+
<Text style={styles.actionTitle}>{busy === 'reload' ? 'Reloading…' : 'Fast Reload'}</Text>
|
|
341
|
+
<Text style={styles.actionHint}>Refresh the selected render target</Text>
|
|
342
|
+
</Pressable>
|
|
343
|
+
<Pressable
|
|
344
|
+
testID="yaver-dogfood-chat"
|
|
345
|
+
accessibilityRole="button"
|
|
346
|
+
disabled={busy !== null}
|
|
347
|
+
onPress={() => void openChat()}
|
|
348
|
+
style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}
|
|
349
|
+
>
|
|
350
|
+
<Text style={styles.actionTitle}>{busy === 'chat' ? 'Opening…' : 'Chat'}</Text>
|
|
351
|
+
<Text style={styles.actionHint}>Open the current Yaver vibing session</Text>
|
|
352
|
+
</Pressable>
|
|
353
|
+
</View>
|
|
354
|
+
</>
|
|
355
|
+
)}
|
|
356
|
+
{message ? <Text style={styles.message}>{message}</Text> : null}
|
|
357
|
+
</Pressable>
|
|
358
|
+
</Pressable>
|
|
359
|
+
</Modal>
|
|
360
|
+
</>
|
|
361
|
+
);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const ModeButton: React.FC<{
|
|
365
|
+
title: string;
|
|
366
|
+
hint: string;
|
|
367
|
+
selected?: boolean;
|
|
368
|
+
disabled?: boolean;
|
|
369
|
+
onPress: () => void;
|
|
370
|
+
}> = ({ title, hint, selected, disabled, onPress }) => (
|
|
371
|
+
<Pressable
|
|
372
|
+
accessibilityRole="button"
|
|
373
|
+
disabled={disabled}
|
|
374
|
+
onPress={onPress}
|
|
375
|
+
style={({ pressed }) => [styles.modeAction, selected && styles.modeSelected, pressed && styles.actionPressed]}
|
|
376
|
+
>
|
|
377
|
+
{typeof selected === 'boolean' ? <View style={[styles.radio, selected && styles.radioSelected]} /> : null}
|
|
378
|
+
<View style={styles.modeCopy}>
|
|
379
|
+
<Text style={styles.actionTitle}>{title}</Text>
|
|
380
|
+
<Text style={styles.actionHint}>{hint}</Text>
|
|
381
|
+
</View>
|
|
382
|
+
</Pressable>
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const styles = StyleSheet.create({
|
|
386
|
+
layer: { zIndex: 9997 },
|
|
387
|
+
fallbackPosition: { position: 'absolute', left: 0, top: 0 },
|
|
388
|
+
fallback: {
|
|
389
|
+
width: FALLBACK_SIZE,
|
|
390
|
+
height: FALLBACK_SIZE,
|
|
391
|
+
borderRadius: FALLBACK_SIZE / 2,
|
|
392
|
+
backgroundColor: '#f97316',
|
|
393
|
+
borderWidth: 1.5,
|
|
394
|
+
borderColor: 'rgba(255,255,255,0.92)',
|
|
395
|
+
shadowColor: '#000',
|
|
396
|
+
shadowOpacity: 0.28,
|
|
397
|
+
shadowRadius: 5,
|
|
398
|
+
shadowOffset: { width: 0, height: 2 },
|
|
399
|
+
elevation: 6,
|
|
400
|
+
overflow: 'hidden',
|
|
401
|
+
},
|
|
402
|
+
fallbackText: { position: 'absolute', top: 7, color: '#111827', fontSize: 17, lineHeight: 20, fontWeight: '800' },
|
|
403
|
+
rightDockText: { left: 6 },
|
|
404
|
+
leftDockText: { right: 6 },
|
|
405
|
+
pressed: { opacity: 0.78 },
|
|
406
|
+
backdrop: {
|
|
407
|
+
flex: 1,
|
|
408
|
+
justifyContent: 'center',
|
|
409
|
+
alignItems: 'center',
|
|
410
|
+
padding: 24,
|
|
411
|
+
backgroundColor: 'rgba(2,6,23,0.28)',
|
|
412
|
+
},
|
|
413
|
+
card: {
|
|
414
|
+
width: '100%',
|
|
415
|
+
maxWidth: 360,
|
|
416
|
+
borderRadius: 20,
|
|
417
|
+
padding: 16,
|
|
418
|
+
backgroundColor: '#111827',
|
|
419
|
+
shadowColor: '#000',
|
|
420
|
+
shadowOpacity: 0.3,
|
|
421
|
+
shadowRadius: 16,
|
|
422
|
+
shadowOffset: { width: 0, height: 8 },
|
|
423
|
+
elevation: 12,
|
|
424
|
+
},
|
|
425
|
+
titleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 },
|
|
426
|
+
title: { color: '#f9fafb', fontSize: 16, fontWeight: '700', marginBottom: 12 },
|
|
427
|
+
explanation: { color: '#cbd5e1', fontSize: 13, lineHeight: 19, marginBottom: 14 },
|
|
428
|
+
supported: { color: '#9ca3af', fontSize: 12, lineHeight: 17, marginBottom: 14 },
|
|
429
|
+
settingsButton: { paddingVertical: 5, paddingHorizontal: 8, marginTop: -6 },
|
|
430
|
+
settingsText: { color: '#fdba74', fontSize: 12, fontWeight: '700' },
|
|
431
|
+
actions: { flexDirection: 'row', gap: 10 },
|
|
432
|
+
stackedActions: { gap: 9 },
|
|
433
|
+
action: {
|
|
434
|
+
flex: 1,
|
|
435
|
+
minHeight: 92,
|
|
436
|
+
borderRadius: 14,
|
|
437
|
+
padding: 12,
|
|
438
|
+
justifyContent: 'space-between',
|
|
439
|
+
backgroundColor: '#1f2937',
|
|
440
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
441
|
+
borderColor: '#4b5563',
|
|
442
|
+
},
|
|
443
|
+
modeAction: {
|
|
444
|
+
minHeight: 62,
|
|
445
|
+
borderRadius: 14,
|
|
446
|
+
padding: 12,
|
|
447
|
+
flexDirection: 'row',
|
|
448
|
+
alignItems: 'center',
|
|
449
|
+
backgroundColor: '#1f2937',
|
|
450
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
451
|
+
borderColor: '#4b5563',
|
|
452
|
+
},
|
|
453
|
+
modeSelected: { borderColor: '#f97316', backgroundColor: '#292524' },
|
|
454
|
+
radio: { width: 15, height: 15, borderRadius: 8, borderWidth: 1.5, borderColor: '#64748b', marginRight: 11 },
|
|
455
|
+
radioSelected: { borderWidth: 4, borderColor: '#f97316', backgroundColor: '#111827' },
|
|
456
|
+
modeCopy: { flex: 1, gap: 3 },
|
|
457
|
+
actionPressed: { backgroundColor: '#374151' },
|
|
458
|
+
actionTitle: { color: '#f9fafb', fontSize: 15, fontWeight: '700' },
|
|
459
|
+
actionHint: { color: '#9ca3af', fontSize: 11, lineHeight: 15 },
|
|
460
|
+
message: { color: '#fdba74', fontSize: 12, lineHeight: 17, marginTop: 12 },
|
|
461
|
+
});
|