@vanira/sdk-react-native 0.0.2
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 +239 -0
- package/package.json +53 -0
- package/src/__tests__/WebRTCClient.integration.test.ts +396 -0
- package/src/__tests__/adapters.test.ts +475 -0
- package/src/__tests__/httpResponse.test.ts +25 -0
- package/src/__tests__/mocks/react-native-incall-manager.ts +8 -0
- package/src/__tests__/mocks/react-native-permissions.ts +15 -0
- package/src/__tests__/mocks/react-native-webrtc.ts +6 -0
- package/src/__tests__/mocks/react-native.ts +28 -0
- package/src/__tests__/preset.test.ts +239 -0
- package/src/__tests__/resolveRuntimeConfig.test.ts +90 -0
- package/src/__tests__/storage.test.ts +211 -0
- package/src/__tests__/webrtcSignaling.test.ts +42 -0
- package/src/adapters/PeerConnectionAdapter.ts +101 -0
- package/src/adapters/browser/BrowserAudioAdapter.ts +43 -0
- package/src/adapters/browser/BrowserDataChannelAdapter.ts +69 -0
- package/src/adapters/browser/BrowserMediaAdapter.ts +15 -0
- package/src/adapters/browser/BrowserPeerAdapter.ts +14 -0
- package/src/adapters/browser/index.ts +4 -0
- package/src/adapters/interfaces.ts +84 -0
- package/src/adapters/react-native/RNAudioAdapter.ts +42 -0
- package/src/adapters/react-native/RNDataChannelAdapter.ts +79 -0
- package/src/adapters/react-native/RNMediaAdapter.ts +46 -0
- package/src/adapters/react-native/RNPeerAdapter.ts +28 -0
- package/src/adapters/react-native/callAudioRouting.ts +115 -0
- package/src/adapters/react-native/decodeUtf8.ts +72 -0
- package/src/adapters/react-native/index.ts +4 -0
- package/src/adapters/react-native/rnUploadFile.ts +76 -0
- package/src/adapters/storage/BrowserDualStorageAdapter.ts +71 -0
- package/src/adapters/storage/MemoryStorageAdapter.ts +50 -0
- package/src/adapters/storage/StorageAdapter.ts +21 -0
- package/src/adapters/storage/createSyncStorageAdapter.ts +40 -0
- package/src/adapters/storage/index.ts +7 -0
- package/src/api/services/ChatService.ts +304 -0
- package/src/api/services/ConfigService.ts +33 -0
- package/src/assets/icons.js +35 -0
- package/src/cdn.ts +68 -0
- package/src/core/CallSessionStore.ts +137 -0
- package/src/core/DraggableController.ts +83 -0
- package/src/core/SessionManager.ts +322 -0
- package/src/core/VaniraAI.ts +464 -0
- package/src/core/WebRTCClient.ts +1012 -0
- package/src/core/httpResponse.ts +22 -0
- package/src/core/iceServers.ts +18 -0
- package/src/core/toolCallNormalize.ts +80 -0
- package/src/core/voice-client.js +236 -0
- package/src/core/webrtcSignaling.ts +72 -0
- package/src/index.js +34 -0
- package/src/index.ts +6 -0
- package/src/platforms/browser.ts +67 -0
- package/src/platforms/react-native.ts +105 -0
- package/src/presets/BookingCalendarModal.tsx +457 -0
- package/src/presets/CameraModal.tsx +576 -0
- package/src/presets/DynamicFormModal.tsx +378 -0
- package/src/presets/NativePresetRenderer.tsx +350 -0
- package/src/presets/NavigateHandler.tsx +75 -0
- package/src/presets/PresetHost.tsx +155 -0
- package/src/presets/PresetShellModal.tsx +97 -0
- package/src/presets/UploadModal.tsx +321 -0
- package/src/presets/calendar/calendarUtils.ts +386 -0
- package/src/presets/call/CallSpeakerToggle.tsx +59 -0
- package/src/presets/call/callAudioRouting.ts +2 -0
- package/src/presets/call/useCallSpeaker.ts +31 -0
- package/src/presets/camera/cameraPermissions.ts +18 -0
- package/src/presets/camera/cameraStream.ts +19 -0
- package/src/presets/camera/cameraUtils.ts +21 -0
- package/src/presets/camera/useLivenessFlow.ts +95 -0
- package/src/presets/chalkboard/ChalkboardOverlay.tsx +156 -0
- package/src/presets/chalkboard/EraseTextHandler.tsx +95 -0
- package/src/presets/chalkboard/TypeTextHandler.tsx +107 -0
- package/src/presets/chalkboard/boardAbort.ts +36 -0
- package/src/presets/chalkboard/boardQueue.ts +620 -0
- package/src/presets/chalkboard/chalkboardSession.ts +75 -0
- package/src/presets/chalkboard/drawUtils.ts +123 -0
- package/src/presets/chalkboard/textUtils.ts +109 -0
- package/src/presets/clipRegion/ClipRegionModal.tsx +261 -0
- package/src/presets/clipRegion/clipRegionBridge.ts +19 -0
- package/src/presets/form/formValidation.ts +104 -0
- package/src/presets/form/parseFormFields.ts +171 -0
- package/src/presets/host/HostElementPresetHandler.tsx +155 -0
- package/src/presets/host/hostPresetBridge.ts +71 -0
- package/src/presets/index.ts +63 -0
- package/src/presets/liveScreen/CloseLiveScreenHandler.tsx +36 -0
- package/src/presets/liveScreen/LiveScreenCaptureHost.tsx +312 -0
- package/src/presets/liveScreen/LiveScreenHandler.tsx +25 -0
- package/src/presets/liveScreen/LiveScreenPipOverlay.tsx +6 -0
- package/src/presets/liveScreen/liveScreenSession.ts +73 -0
- package/src/presets/liveVision/CloseLiveVisionHandler.tsx +29 -0
- package/src/presets/liveVision/LiveVisionCameraHost.tsx +317 -0
- package/src/presets/liveVision/LiveVisionHandler.tsx +26 -0
- package/src/presets/liveVision/LiveVisionPipOverlay.tsx +7 -0
- package/src/presets/liveVision/liveVisionFrameLoop.ts +38 -0
- package/src/presets/liveVision/liveVisionSession.ts +75 -0
- package/src/presets/liveVision/liveVisionUpload.ts +62 -0
- package/src/presets/navigation/internalRouteRegistry.ts +25 -0
- package/src/presets/navigation/navigationBridge.ts +76 -0
- package/src/presets/navigation/navigationTypes.ts +12 -0
- package/src/presets/parseToolCall.ts +60 -0
- package/src/presets/presetClientAdapter.ts +29 -0
- package/src/presets/presetCompletion.ts +91 -0
- package/src/presets/presetEventHelpers.ts +45 -0
- package/src/presets/registry.ts +128 -0
- package/src/presets/streaming/mediaFrameUpload.ts +93 -0
- package/src/presets/types.ts +74 -0
- package/src/presets/upload/pickUploadFile.ts +256 -0
- package/src/presets/upload/uploadFormats.ts +163 -0
- package/src/presets/upload/uploadUtils.ts +68 -0
- package/src/react/PresetRenderer.tsx +144 -0
- package/src/react/index.ts +1 -0
- package/src/runtime/browserRuntime.ts +54 -0
- package/src/runtime/platform.ts +17 -0
- package/src/runtime/reactNativeRuntime.ts +68 -0
- package/src/runtime/resolveRuntimeConfig.ts +75 -0
- package/src/runtime/runtimeBundles.ts +74 -0
- package/src/runtime/types.ts +135 -0
- package/src/types/react-native-incall-manager.d.ts +17 -0
- package/src/types/react-native-webrtc.d.ts +47 -0
- package/src/types.ts +133 -0
- package/src/ui/VaniraWidget.ts +87 -0
- package/src/ui/abstraction/AbstractWidgetProvider.ts +18 -0
- package/src/ui/abstraction/interfaces.ts +12 -0
- package/src/ui/adapters/VaniraChatAdapter.ts +42 -0
- package/src/ui/components/AvatarView.ts +81 -0
- package/src/ui/components/ChatWindow.ts +263 -0
- package/src/ui/components/FloatingButton.ts +163 -0
- package/src/ui/components/FloatingWelcomeChips.ts +137 -0
- package/src/ui/components/Panel.ts +120 -0
- package/src/ui/components/VoiceOrb.ts +79 -0
- package/src/ui/components/VoiceOverlay.ts +497 -0
- package/src/ui/components/index.ts +7 -0
- package/src/ui/factory/WidgetFactory.ts +16 -0
- package/src/ui/icons_data.ts +2 -0
- package/src/ui/presets/WidgetPresetRenderer.ts +1802 -0
- package/src/ui/presets/types.ts +16 -0
- package/src/ui/providers/VaniraInternalProvider.ts +1066 -0
- package/src/ui/styles/index.ts +323 -0
- package/src/ui/styles/keyframes.ts +76 -0
- package/src/ui/styles/theme.ts +57 -0
- package/src/ui/styles/widget.css.ts +838 -0
- package/src/ui/utils.ts +37 -0
- package/src/ui/views/AbstractChatView.ts +93 -0
- package/src/ui/views/AbstractVoiceView.ts +57 -0
- package/src/ui/views/AvatarOnlyView.ts +78 -0
- package/src/ui/views/ChatAvatarView.ts +66 -0
- package/src/ui/views/ChatOnlyView.ts +28 -0
- package/src/ui/views/ChatVoiceView.ts +15 -0
- package/src/ui/views/VoiceOnlyView.ts +25 -0
- package/src/ui/views/index.ts +5 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import {Pressable, StyleSheet, Text, View} from 'react-native';
|
|
3
|
+
import {useCallSpeaker} from './useCallSpeaker';
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
compact?: boolean;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function CallSpeakerToggle({compact = false, disabled = false}: Props) {
|
|
11
|
+
const {speakerOn, callAudioActive, toggleSpeaker} = useCallSpeaker();
|
|
12
|
+
|
|
13
|
+
if (!callAudioActive) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<View style={[styles.wrap, compact && styles.wrapCompact]}>
|
|
19
|
+
<Pressable
|
|
20
|
+
style={[
|
|
21
|
+
styles.button,
|
|
22
|
+
speakerOn && styles.buttonActive,
|
|
23
|
+
disabled && styles.buttonDisabled,
|
|
24
|
+
]}
|
|
25
|
+
onPress={toggleSpeaker}
|
|
26
|
+
disabled={disabled}>
|
|
27
|
+
<Text style={styles.icon}>{speakerOn ? '🔊' : '🔈'}</Text>
|
|
28
|
+
<Text style={[styles.label, speakerOn && styles.labelActive]}>
|
|
29
|
+
{speakerOn ? 'Speaker' : 'Earpiece'}
|
|
30
|
+
</Text>
|
|
31
|
+
</Pressable>
|
|
32
|
+
</View>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const styles = StyleSheet.create({
|
|
37
|
+
wrap: {marginBottom: 8},
|
|
38
|
+
wrapCompact: {marginBottom: 0},
|
|
39
|
+
button: {
|
|
40
|
+
flexDirection: 'row',
|
|
41
|
+
alignItems: 'center',
|
|
42
|
+
justifyContent: 'center',
|
|
43
|
+
gap: 8,
|
|
44
|
+
paddingVertical: 10,
|
|
45
|
+
paddingHorizontal: 14,
|
|
46
|
+
borderRadius: 10,
|
|
47
|
+
backgroundColor: '#1e293b',
|
|
48
|
+
borderWidth: 1,
|
|
49
|
+
borderColor: '#334155',
|
|
50
|
+
},
|
|
51
|
+
buttonActive: {
|
|
52
|
+
backgroundColor: '#312e81',
|
|
53
|
+
borderColor: '#4f46e5',
|
|
54
|
+
},
|
|
55
|
+
buttonDisabled: {opacity: 0.5},
|
|
56
|
+
icon: {fontSize: 16},
|
|
57
|
+
label: {color: '#94a3b8', fontWeight: '600', fontSize: 13},
|
|
58
|
+
labelActive: {color: '#e0e7ff'},
|
|
59
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {useCallback, useSyncExternalStore} from 'react';
|
|
2
|
+
import {
|
|
3
|
+
getCallAudioServerSnapshot,
|
|
4
|
+
getCallAudioSnapshot,
|
|
5
|
+
setSpeakerphoneOn,
|
|
6
|
+
subscribeCallAudio,
|
|
7
|
+
toggleSpeakerphone,
|
|
8
|
+
} from '../../adapters/react-native/callAudioRouting';
|
|
9
|
+
|
|
10
|
+
export function useCallSpeaker() {
|
|
11
|
+
const snapshot = useSyncExternalStore(
|
|
12
|
+
subscribeCallAudio,
|
|
13
|
+
getCallAudioSnapshot,
|
|
14
|
+
getCallAudioServerSnapshot,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const toggle = useCallback(() => {
|
|
18
|
+
toggleSpeakerphone();
|
|
19
|
+
}, []);
|
|
20
|
+
|
|
21
|
+
const setSpeaker = useCallback((on: boolean) => {
|
|
22
|
+
setSpeakerphoneOn(on);
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
speakerOn: snapshot.speakerOn,
|
|
27
|
+
callAudioActive: snapshot.active,
|
|
28
|
+
toggleSpeaker: toggle,
|
|
29
|
+
setSpeakerOn: setSpeaker,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {Platform} from 'react-native';
|
|
2
|
+
import {PERMISSIONS, RESULTS, request} from 'react-native-permissions';
|
|
3
|
+
|
|
4
|
+
export async function ensureCameraPermission(): Promise<boolean> {
|
|
5
|
+
const permission =
|
|
6
|
+
Platform.OS === 'ios'
|
|
7
|
+
? PERMISSIONS.IOS.CAMERA
|
|
8
|
+
: Platform.OS === 'android'
|
|
9
|
+
? PERMISSIONS.ANDROID.CAMERA
|
|
10
|
+
: null;
|
|
11
|
+
|
|
12
|
+
if (!permission) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const result = await request(permission);
|
|
17
|
+
return result === RESULTS.GRANTED;
|
|
18
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {mediaDevices} from 'react-native-webrtc';
|
|
2
|
+
import type {FacingMode} from './cameraUtils';
|
|
3
|
+
|
|
4
|
+
export async function openCameraStream(
|
|
5
|
+
facing: FacingMode,
|
|
6
|
+
): Promise<import('react-native-webrtc').MediaStream> {
|
|
7
|
+
return mediaDevices.getUserMedia({
|
|
8
|
+
video: {
|
|
9
|
+
facingMode: facing === 'user' ? 'user' : 'environment',
|
|
10
|
+
},
|
|
11
|
+
audio: false,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function closeCameraStream(
|
|
16
|
+
stream: import('react-native-webrtc').MediaStream | null,
|
|
17
|
+
): void {
|
|
18
|
+
stream?.getTracks().forEach(track => track.stop());
|
|
19
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type FacingMode = 'user' | 'environment';
|
|
2
|
+
|
|
3
|
+
export function parseFacingMode(raw: unknown): FacingMode {
|
|
4
|
+
const value = String(raw ?? 'environment').toLowerCase();
|
|
5
|
+
return value === 'user' || value === 'front' ? 'user' : 'environment';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function parseLivenessDefault(
|
|
9
|
+
clientFields: Record<string, unknown>,
|
|
10
|
+
args: Record<string, unknown>,
|
|
11
|
+
): boolean {
|
|
12
|
+
const raw =
|
|
13
|
+
clientFields.liveness_check ??
|
|
14
|
+
args.liveness_check ??
|
|
15
|
+
false;
|
|
16
|
+
return raw === true || raw === 'true';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildCaptureFileName(): string {
|
|
20
|
+
return `capture-${Date.now()}.jpg`;
|
|
21
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {useCallback, useEffect, useRef, useState} from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RN simplified liveness — timer-based UX matching web states.
|
|
5
|
+
* Web uses canvas pixel-diff; RN defers pixel analysis (see RN_CAMERA_TEST_PLAN).
|
|
6
|
+
*/
|
|
7
|
+
export type LivenessPhase =
|
|
8
|
+
| 'idle'
|
|
9
|
+
| 'calibrating'
|
|
10
|
+
| 'action'
|
|
11
|
+
| 'verified';
|
|
12
|
+
|
|
13
|
+
type Options = {
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
onVerified: () => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const CALIBRATE_MS = 1500;
|
|
19
|
+
const ACTION_MS = 4000;
|
|
20
|
+
|
|
21
|
+
export function useLivenessFlow({enabled, onVerified}: Options) {
|
|
22
|
+
const [phase, setPhase] = useState<LivenessPhase>('idle');
|
|
23
|
+
const [progress, setProgress] = useState(0);
|
|
24
|
+
const [guideText, setGuideText] = useState('Align your face in center');
|
|
25
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
26
|
+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
27
|
+
|
|
28
|
+
const clearTimers = useCallback(() => {
|
|
29
|
+
if (timerRef.current) {
|
|
30
|
+
clearTimeout(timerRef.current);
|
|
31
|
+
timerRef.current = null;
|
|
32
|
+
}
|
|
33
|
+
if (intervalRef.current) {
|
|
34
|
+
clearInterval(intervalRef.current);
|
|
35
|
+
intervalRef.current = null;
|
|
36
|
+
}
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
const start = useCallback(() => {
|
|
40
|
+
clearTimers();
|
|
41
|
+
setPhase('calibrating');
|
|
42
|
+
setProgress(0);
|
|
43
|
+
setGuideText('Hold still to calibrate…');
|
|
44
|
+
|
|
45
|
+
const started = Date.now();
|
|
46
|
+
intervalRef.current = setInterval(() => {
|
|
47
|
+
const elapsed = Date.now() - started;
|
|
48
|
+
const pct = Math.min(30, Math.round((elapsed / CALIBRATE_MS) * 30));
|
|
49
|
+
setProgress(pct);
|
|
50
|
+
}, 100);
|
|
51
|
+
|
|
52
|
+
timerRef.current = setTimeout(() => {
|
|
53
|
+
if (intervalRef.current) {
|
|
54
|
+
clearInterval(intervalRef.current);
|
|
55
|
+
intervalRef.current = null;
|
|
56
|
+
}
|
|
57
|
+
setPhase('action');
|
|
58
|
+
setProgress(30);
|
|
59
|
+
setGuideText('Now blink or turn head slightly');
|
|
60
|
+
|
|
61
|
+
const actionStart = Date.now();
|
|
62
|
+
intervalRef.current = setInterval(() => {
|
|
63
|
+
const elapsed = Date.now() - actionStart;
|
|
64
|
+
const pct = Math.min(100, 30 + Math.round((elapsed / ACTION_MS) * 70));
|
|
65
|
+
setProgress(pct);
|
|
66
|
+
}, 100);
|
|
67
|
+
|
|
68
|
+
timerRef.current = setTimeout(() => {
|
|
69
|
+
clearTimers();
|
|
70
|
+
setPhase('verified');
|
|
71
|
+
setProgress(100);
|
|
72
|
+
setGuideText('✓ Liveness Verified! Tap Capture & Send.');
|
|
73
|
+
onVerified();
|
|
74
|
+
}, ACTION_MS);
|
|
75
|
+
}, CALIBRATE_MS);
|
|
76
|
+
}, [clearTimers, onVerified]);
|
|
77
|
+
|
|
78
|
+
const reset = useCallback(() => {
|
|
79
|
+
clearTimers();
|
|
80
|
+
setPhase('idle');
|
|
81
|
+
setProgress(0);
|
|
82
|
+
setGuideText('Align your face in center');
|
|
83
|
+
}, [clearTimers]);
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!enabled) {
|
|
87
|
+
reset();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
start();
|
|
91
|
+
return clearTimers;
|
|
92
|
+
}, [clearTimers, enabled, reset, start]);
|
|
93
|
+
|
|
94
|
+
return {phase, progress, guideText, reset};
|
|
95
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import React, {useCallback, useEffect, useRef} from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
Modal,
|
|
5
|
+
Pressable,
|
|
6
|
+
ScrollView,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
Text,
|
|
9
|
+
TextInput,
|
|
10
|
+
View,
|
|
11
|
+
} from 'react-native';
|
|
12
|
+
import {
|
|
13
|
+
getChalkboardSnapshot,
|
|
14
|
+
hideChalkboard,
|
|
15
|
+
setChalkboardContent,
|
|
16
|
+
subscribeChalkboard,
|
|
17
|
+
} from './chalkboardSession';
|
|
18
|
+
|
|
19
|
+
type Props = {
|
|
20
|
+
onUserDismiss?: () => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const SERVER_CHALKBOARD_SNAPSHOT = {visible: false, content: '', typing: false};
|
|
24
|
+
|
|
25
|
+
function useChalkboardStore() {
|
|
26
|
+
return React.useSyncExternalStore(
|
|
27
|
+
subscribeChalkboard,
|
|
28
|
+
getChalkboardSnapshot,
|
|
29
|
+
() => SERVER_CHALKBOARD_SNAPSHOT,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function ChalkboardOverlay({onUserDismiss}: Props) {
|
|
34
|
+
const {visible, content, typing} = useChalkboardStore();
|
|
35
|
+
const scrollRef = useRef<ScrollView>(null);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (visible && content.length > 0) {
|
|
39
|
+
scrollRef.current?.scrollToEnd({animated: true});
|
|
40
|
+
}
|
|
41
|
+
}, [content, visible]);
|
|
42
|
+
|
|
43
|
+
const handleClose = useCallback(() => {
|
|
44
|
+
hideChalkboard();
|
|
45
|
+
onUserDismiss?.();
|
|
46
|
+
}, [onUserDismiss]);
|
|
47
|
+
|
|
48
|
+
if (!visible) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Modal
|
|
54
|
+
visible
|
|
55
|
+
transparent
|
|
56
|
+
animationType="fade"
|
|
57
|
+
statusBarTranslucent
|
|
58
|
+
onRequestClose={handleClose}>
|
|
59
|
+
<View style={styles.backdrop}>
|
|
60
|
+
<View style={styles.frame}>
|
|
61
|
+
<Pressable style={styles.closeBtn} onPress={handleClose} hitSlop={8}>
|
|
62
|
+
<Text style={styles.closeText}>✕</Text>
|
|
63
|
+
</Pressable>
|
|
64
|
+
<View style={styles.headerRow}>
|
|
65
|
+
<Text style={styles.title}>Chalkboard</Text>
|
|
66
|
+
{typing ? (
|
|
67
|
+
<View style={styles.typingRow}>
|
|
68
|
+
<ActivityIndicator size="small" color="#a5b4fc" />
|
|
69
|
+
<Text style={styles.typingLabel}>Writing…</Text>
|
|
70
|
+
</View>
|
|
71
|
+
) : null}
|
|
72
|
+
</View>
|
|
73
|
+
<ScrollView
|
|
74
|
+
ref={scrollRef}
|
|
75
|
+
style={styles.scroll}
|
|
76
|
+
contentContainerStyle={styles.scrollContent}
|
|
77
|
+
keyboardShouldPersistTaps="handled">
|
|
78
|
+
<TextInput
|
|
79
|
+
style={styles.textarea}
|
|
80
|
+
multiline
|
|
81
|
+
value={content}
|
|
82
|
+
onChangeText={setChalkboardContent}
|
|
83
|
+
placeholder="Chalkboard active..."
|
|
84
|
+
placeholderTextColor="#6b7280"
|
|
85
|
+
textAlignVertical="top"
|
|
86
|
+
editable={!typing}
|
|
87
|
+
/>
|
|
88
|
+
</ScrollView>
|
|
89
|
+
</View>
|
|
90
|
+
</View>
|
|
91
|
+
</Modal>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const styles = StyleSheet.create({
|
|
96
|
+
backdrop: {
|
|
97
|
+
flex: 1,
|
|
98
|
+
backgroundColor: 'rgba(0,0,0,0.85)',
|
|
99
|
+
justifyContent: 'center',
|
|
100
|
+
padding: 16,
|
|
101
|
+
},
|
|
102
|
+
frame: {
|
|
103
|
+
flex: 1,
|
|
104
|
+
maxHeight: '85%',
|
|
105
|
+
backgroundColor: '#0d0e12',
|
|
106
|
+
borderWidth: 10,
|
|
107
|
+
borderColor: '#5c4033',
|
|
108
|
+
borderRadius: 16,
|
|
109
|
+
padding: 12,
|
|
110
|
+
overflow: 'hidden',
|
|
111
|
+
},
|
|
112
|
+
closeBtn: {
|
|
113
|
+
position: 'absolute',
|
|
114
|
+
top: 12,
|
|
115
|
+
right: 12,
|
|
116
|
+
zIndex: 2,
|
|
117
|
+
width: 32,
|
|
118
|
+
height: 32,
|
|
119
|
+
borderRadius: 16,
|
|
120
|
+
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
121
|
+
alignItems: 'center',
|
|
122
|
+
justifyContent: 'center',
|
|
123
|
+
},
|
|
124
|
+
closeText: {color: '#9ca3af', fontWeight: '700', fontSize: 14},
|
|
125
|
+
headerRow: {
|
|
126
|
+
flexDirection: 'row',
|
|
127
|
+
alignItems: 'center',
|
|
128
|
+
justifyContent: 'space-between',
|
|
129
|
+
marginBottom: 8,
|
|
130
|
+
paddingRight: 40,
|
|
131
|
+
},
|
|
132
|
+
title: {
|
|
133
|
+
color: '#6b7280',
|
|
134
|
+
fontSize: 11,
|
|
135
|
+
fontWeight: '700',
|
|
136
|
+
letterSpacing: 1,
|
|
137
|
+
textTransform: 'uppercase',
|
|
138
|
+
},
|
|
139
|
+
typingRow: {flexDirection: 'row', alignItems: 'center', gap: 6},
|
|
140
|
+
typingLabel: {color: '#a5b4fc', fontSize: 11, fontWeight: '600'},
|
|
141
|
+
scroll: {flex: 1},
|
|
142
|
+
scrollContent: {flexGrow: 1},
|
|
143
|
+
textarea: {
|
|
144
|
+
flex: 1,
|
|
145
|
+
minHeight: 320,
|
|
146
|
+
color: '#fff',
|
|
147
|
+
fontSize: 22,
|
|
148
|
+
lineHeight: 32,
|
|
149
|
+
fontFamily: 'serif',
|
|
150
|
+
textShadowColor: 'rgba(255,255,255,0.25)',
|
|
151
|
+
textShadowOffset: {width: 0, height: 0},
|
|
152
|
+
textShadowRadius: 4,
|
|
153
|
+
padding: 8,
|
|
154
|
+
backgroundColor: '#0a0a0c',
|
|
155
|
+
},
|
|
156
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React, {useEffect, useRef} from 'react';
|
|
2
|
+
import type {PresetContext} from '../types';
|
|
3
|
+
import {
|
|
4
|
+
getChalkboardContent,
|
|
5
|
+
isChalkboardVisible,
|
|
6
|
+
setChalkboardContent,
|
|
7
|
+
setChalkboardTyping,
|
|
8
|
+
showChalkboard,
|
|
9
|
+
} from './chalkboardSession';
|
|
10
|
+
import {eraseLastWords, parseEraseTextArgs} from './textUtils';
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
ctx: PresetContext;
|
|
14
|
+
onEraseComplete: (payload: Record<string, unknown>) => void;
|
|
15
|
+
onDismiss: () => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** vanira_erase_text — mutates chalkboard (web whiteboard fallback). */
|
|
19
|
+
export function EraseTextHandler({ctx, onEraseComplete, onDismiss}: Props) {
|
|
20
|
+
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
|
21
|
+
const cancelledRef = useRef(false);
|
|
22
|
+
const onEraseCompleteRef = useRef(onEraseComplete);
|
|
23
|
+
const onDismissRef = useRef(onDismiss);
|
|
24
|
+
onEraseCompleteRef.current = onEraseComplete;
|
|
25
|
+
onDismissRef.current = onDismiss;
|
|
26
|
+
|
|
27
|
+
const toolCallId = ctx.toolCallId;
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
cancelledRef.current = false;
|
|
31
|
+
|
|
32
|
+
const clearTimers = () => {
|
|
33
|
+
timersRef.current.forEach(t => clearTimeout(t));
|
|
34
|
+
timersRef.current = [];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const finish = (payload: Record<string, unknown>) => {
|
|
38
|
+
setChalkboardTyping(false);
|
|
39
|
+
onEraseCompleteRef.current(payload);
|
|
40
|
+
onDismissRef.current();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const {mode, numWords, delayMs} = parseEraseTextArgs(ctx);
|
|
44
|
+
const hadChalkboard =
|
|
45
|
+
isChalkboardVisible() || getChalkboardContent().length > 0;
|
|
46
|
+
|
|
47
|
+
if (!hadChalkboard) {
|
|
48
|
+
finish({status: 'error', message: 'Target element not found'});
|
|
49
|
+
return () => clearTimers();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
showChalkboard();
|
|
53
|
+
setChalkboardTyping(true);
|
|
54
|
+
|
|
55
|
+
const currentVal = getChalkboardContent();
|
|
56
|
+
const targetVal =
|
|
57
|
+
mode === 'words' ? eraseLastWords(currentVal, numWords) : '';
|
|
58
|
+
|
|
59
|
+
if (delayMs <= 0 || currentVal === targetVal) {
|
|
60
|
+
setChalkboardContent(targetVal);
|
|
61
|
+
finish({status: 'success'});
|
|
62
|
+
return () => {
|
|
63
|
+
cancelledRef.current = true;
|
|
64
|
+
clearTimers();
|
|
65
|
+
setChalkboardTyping(false);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let working = currentVal;
|
|
70
|
+
const deleteNext = () => {
|
|
71
|
+
if (cancelledRef.current) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (working.length > targetVal.length) {
|
|
75
|
+
working = working.slice(0, -1);
|
|
76
|
+
setChalkboardContent(working);
|
|
77
|
+
const t = setTimeout(deleteNext, Math.max(10, delayMs / 2));
|
|
78
|
+
timersRef.current.push(t);
|
|
79
|
+
} else {
|
|
80
|
+
finish({status: 'success'});
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
deleteNext();
|
|
85
|
+
|
|
86
|
+
return () => {
|
|
87
|
+
cancelledRef.current = true;
|
|
88
|
+
clearTimers();
|
|
89
|
+
setChalkboardTyping(false);
|
|
90
|
+
};
|
|
91
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
92
|
+
}, [toolCallId]);
|
|
93
|
+
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import React, {useEffect, useRef} from 'react';
|
|
2
|
+
import type {PresetContext} from '../types';
|
|
3
|
+
import {
|
|
4
|
+
getChalkboardContent,
|
|
5
|
+
setChalkboardContent,
|
|
6
|
+
setChalkboardTyping,
|
|
7
|
+
showChalkboard,
|
|
8
|
+
} from './chalkboardSession';
|
|
9
|
+
import {parseTypeTextArgs} from './textUtils';
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
ctx: PresetContext;
|
|
13
|
+
onTypingComplete: () => void;
|
|
14
|
+
onCancel: (reason?: string) => void;
|
|
15
|
+
onDismiss: () => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* vanira_type_text — RN chalkboard fallback with character-by-character typing.
|
|
20
|
+
* Effect keyed on toolCallId only — unstable parent callbacks must not restart typing.
|
|
21
|
+
*/
|
|
22
|
+
export function TypeTextHandler({
|
|
23
|
+
ctx,
|
|
24
|
+
onTypingComplete,
|
|
25
|
+
onDismiss,
|
|
26
|
+
}: Props) {
|
|
27
|
+
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
|
28
|
+
const cancelledRef = useRef(false);
|
|
29
|
+
const onTypingCompleteRef = useRef(onTypingComplete);
|
|
30
|
+
const onDismissRef = useRef(onDismiss);
|
|
31
|
+
onTypingCompleteRef.current = onTypingComplete;
|
|
32
|
+
onDismissRef.current = onDismiss;
|
|
33
|
+
|
|
34
|
+
const toolCallId = ctx.toolCallId;
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
cancelledRef.current = false;
|
|
38
|
+
|
|
39
|
+
const clearTimers = () => {
|
|
40
|
+
timersRef.current.forEach(t => clearTimeout(t));
|
|
41
|
+
timersRef.current = [];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const finish = () => {
|
|
45
|
+
setChalkboardTyping(false);
|
|
46
|
+
onTypingCompleteRef.current();
|
|
47
|
+
onDismissRef.current();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const {text, delayMs} = parseTypeTextArgs(ctx);
|
|
51
|
+
console.log('[TypeTextHandler] start', {toolCallId, text, delayMs});
|
|
52
|
+
|
|
53
|
+
showChalkboard();
|
|
54
|
+
setChalkboardTyping(true);
|
|
55
|
+
|
|
56
|
+
const initial = getChalkboardContent();
|
|
57
|
+
const textToAppend = initial && text ? `\n${text}` : text;
|
|
58
|
+
|
|
59
|
+
if (!textToAppend) {
|
|
60
|
+
finish();
|
|
61
|
+
return () => {
|
|
62
|
+
cancelledRef.current = true;
|
|
63
|
+
clearTimers();
|
|
64
|
+
setChalkboardTyping(false);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (delayMs <= 0) {
|
|
69
|
+
setChalkboardContent(initial + textToAppend);
|
|
70
|
+
finish();
|
|
71
|
+
return () => {
|
|
72
|
+
cancelledRef.current = true;
|
|
73
|
+
clearTimers();
|
|
74
|
+
setChalkboardTyping(false);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let current = initial;
|
|
79
|
+
let index = 0;
|
|
80
|
+
|
|
81
|
+
const typeNext = () => {
|
|
82
|
+
if (cancelledRef.current) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (index < textToAppend.length) {
|
|
86
|
+
current += textToAppend[index];
|
|
87
|
+
setChalkboardContent(current);
|
|
88
|
+
index += 1;
|
|
89
|
+
const t = setTimeout(typeNext, delayMs);
|
|
90
|
+
timersRef.current.push(t);
|
|
91
|
+
} else {
|
|
92
|
+
finish();
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
typeNext();
|
|
97
|
+
|
|
98
|
+
return () => {
|
|
99
|
+
cancelledRef.current = true;
|
|
100
|
+
clearTimers();
|
|
101
|
+
setChalkboardTyping(false);
|
|
102
|
+
};
|
|
103
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on tool call only
|
|
104
|
+
}, [toolCallId]);
|
|
105
|
+
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Mirrors vanira-sdk VANIRA_BOARD_ABORT_EVENT — RN-safe pub/sub (no window required). */
|
|
2
|
+
|
|
3
|
+
export const VANIRA_BOARD_ABORT_EVENT = 'vanira:board_abort';
|
|
4
|
+
|
|
5
|
+
export type VaniraBoardAbortDetail = {
|
|
6
|
+
reason?: string;
|
|
7
|
+
toolCallId?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type BoardAbortListener = (detail: VaniraBoardAbortDetail) => void;
|
|
11
|
+
|
|
12
|
+
const listeners = new Set<BoardAbortListener>();
|
|
13
|
+
|
|
14
|
+
export function subscribeBoardAbort(listener: BoardAbortListener): () => void {
|
|
15
|
+
listeners.add(listener);
|
|
16
|
+
return () => listeners.delete(listener);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function dispatchBoardAbort(
|
|
20
|
+
reason: string,
|
|
21
|
+
toolCallId?: string,
|
|
22
|
+
): void {
|
|
23
|
+
const detail: VaniraBoardAbortDetail = {reason, toolCallId};
|
|
24
|
+
console.log('[BoardAbort] dispatch:', detail);
|
|
25
|
+
listeners.forEach(listener => listener(detail));
|
|
26
|
+
|
|
27
|
+
if (typeof globalThis !== 'undefined' && typeof globalThis.dispatchEvent === 'function') {
|
|
28
|
+
try {
|
|
29
|
+
globalThis.dispatchEvent(
|
|
30
|
+
new CustomEvent(VANIRA_BOARD_ABORT_EVENT, {detail}),
|
|
31
|
+
);
|
|
32
|
+
} catch {
|
|
33
|
+
/* RN may lack CustomEvent in some builds */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|