@alia.onl/sdk 1.0.0 → 3.0.0
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/package.json +37 -3
- package/src/components/AliaChatContent.tsx +313 -0
- package/src/components/AliaChatInput.tsx +174 -27
- package/src/components/AliaChatMessageList.tsx +220 -34
- package/src/components/AliaChatScreen.tsx +132 -0
- package/src/components/AliaChatSheet.tsx +36 -49
- package/src/components/AliaChatSuggestions.tsx +1 -1
- package/src/components/AliaFace.tsx +402 -0
- package/src/components/Markdown.tsx +288 -0
- package/src/components/Reasoning.tsx +154 -0
- package/src/components/ThinkingIndicator.tsx +134 -0
- package/src/components/ui/button.tsx +104 -0
- package/src/components/ui/chat-text-input.tsx +164 -0
- package/src/components/ui/dropdown-menu.tsx +2 -0
- package/src/components/ui/dropdown-menu.web.tsx +355 -0
- package/src/components/ui/prompt-input/actions.tsx +28 -0
- package/src/components/ui/prompt-input/add-menu.tsx +83 -0
- package/src/components/ui/prompt-input/attachments.tsx +183 -0
- package/src/components/ui/prompt-input/autocomplete.tsx +168 -0
- package/src/components/ui/prompt-input/context.tsx +71 -0
- package/src/components/ui/prompt-input/mic-button.tsx +53 -0
- package/src/components/ui/prompt-input/prompt-input.tsx +276 -0
- package/src/components/ui/prompt-input/submit-button.tsx +49 -0
- package/src/components/ui/prompt-input/textarea.tsx +64 -0
- package/src/components/ui/text.tsx +29 -0
- package/src/components/voice/AudioWaveVisualizer.tsx +377 -0
- package/src/components/voice/VoiceControls.tsx +180 -0
- package/src/components/voice/VoiceOverlay.tsx +43 -0
- package/src/hooks/useAliaChat.ts +278 -91
- package/src/hooks/useAudioLevelMonitor.ts +142 -0
- package/src/hooks/useAudioLevels.ts +51 -0
- package/src/hooks/useDocumentPicker.ts +37 -0
- package/src/hooks/useImagePicker.ts +38 -0
- package/src/hooks/useSoundEffects.ts +128 -0
- package/src/hooks/useSpeechToText.ts +182 -0
- package/src/hooks/useTTS.ts +233 -0
- package/src/hooks/useVoiceRoom.ts +420 -0
- package/src/index.ts +71 -6
- package/src/lib/keyboard.native.tsx +6 -0
- package/src/lib/keyboard.tsx +25 -0
- package/src/lib/utils.ts +14 -0
- package/src/optional-deps.d.ts +86 -0
- package/src/theme.ts +25 -2
- package/src/types.ts +63 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { View, Text, Pressable, StyleSheet } from 'react-native';
|
|
3
|
+
import Animated, {
|
|
4
|
+
useAnimatedStyle,
|
|
5
|
+
useSharedValue,
|
|
6
|
+
withRepeat,
|
|
7
|
+
withTiming,
|
|
8
|
+
withSequence,
|
|
9
|
+
cancelAnimation,
|
|
10
|
+
} from 'react-native-reanimated';
|
|
11
|
+
import { useAliaColors } from '../theme';
|
|
12
|
+
|
|
13
|
+
// Context
|
|
14
|
+
type ReasoningContextType = {
|
|
15
|
+
isStreaming: boolean;
|
|
16
|
+
isOpen: boolean;
|
|
17
|
+
setIsOpen: (open: boolean) => void;
|
|
18
|
+
duration: number | undefined;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const ReasoningContext = createContext<ReasoningContextType | null>(null);
|
|
22
|
+
|
|
23
|
+
function useReasoning() {
|
|
24
|
+
const context = useContext(ReasoningContext);
|
|
25
|
+
if (!context) throw new Error('useReasoning must be used within a Reasoning component');
|
|
26
|
+
return context;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Props
|
|
30
|
+
interface ReasoningProps {
|
|
31
|
+
isStreaming?: boolean;
|
|
32
|
+
defaultOpen?: boolean;
|
|
33
|
+
duration?: number;
|
|
34
|
+
children: React.ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ReasoningTriggerProps {
|
|
38
|
+
onPress?: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ReasoningContentProps {
|
|
42
|
+
children: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Reasoning wrapper — manages open/close state with auto-open during streaming
|
|
46
|
+
export function Reasoning({ isStreaming = false, defaultOpen = true, duration: externalDuration, children }: ReasoningProps) {
|
|
47
|
+
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
48
|
+
const [duration, setDuration] = useState<number | undefined>(externalDuration);
|
|
49
|
+
const startTimeRef = useRef<number | null>(null);
|
|
50
|
+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (isStreaming) {
|
|
54
|
+
setIsOpen(true);
|
|
55
|
+
startTimeRef.current = Date.now();
|
|
56
|
+
intervalRef.current = setInterval(() => {
|
|
57
|
+
if (startTimeRef.current) {
|
|
58
|
+
setDuration(Math.floor((Date.now() - startTimeRef.current) / 1000));
|
|
59
|
+
}
|
|
60
|
+
}, 1000);
|
|
61
|
+
} else {
|
|
62
|
+
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
|
|
63
|
+
if (startTimeRef.current !== null) {
|
|
64
|
+
const t = setTimeout(() => setIsOpen(false), 500);
|
|
65
|
+
return () => clearTimeout(t);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
|
|
69
|
+
}, [isStreaming]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => { if (externalDuration !== undefined) setDuration(externalDuration); }, [externalDuration]);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<ReasoningContext.Provider value={{ isStreaming, isOpen, setIsOpen, duration }}>
|
|
75
|
+
<View>{children}</View>
|
|
76
|
+
</ReasoningContext.Provider>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Trigger — shows "Thinking for Xs..." label with pulsing icon
|
|
81
|
+
export function ReasoningTrigger({ onPress }: ReasoningTriggerProps) {
|
|
82
|
+
const { isStreaming, isOpen, setIsOpen, duration } = useReasoning();
|
|
83
|
+
const colors = useAliaColors();
|
|
84
|
+
const pulseOpacity = useSharedValue(1);
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (isStreaming) {
|
|
88
|
+
pulseOpacity.value = withRepeat(withSequence(withTiming(0.4, { duration: 800 }), withTiming(1, { duration: 800 })), -1, false);
|
|
89
|
+
} else {
|
|
90
|
+
cancelAnimation(pulseOpacity);
|
|
91
|
+
pulseOpacity.value = 1;
|
|
92
|
+
}
|
|
93
|
+
}, [isStreaming]);
|
|
94
|
+
|
|
95
|
+
const pulseStyle = useAnimatedStyle(() => ({ opacity: pulseOpacity.value }));
|
|
96
|
+
|
|
97
|
+
const message = isStreaming
|
|
98
|
+
? (duration ? `Thinking for ${duration}s...` : 'Thinking...')
|
|
99
|
+
: (duration ? `Thought for ${duration} seconds` : 'Reasoning');
|
|
100
|
+
|
|
101
|
+
const handlePress = onPress || (() => setIsOpen(!isOpen));
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<Pressable onPress={handlePress} style={styles.trigger}>
|
|
105
|
+
<Animated.View style={isStreaming ? pulseStyle : undefined}>
|
|
106
|
+
<Text style={{ color: '#a855f7', fontSize: 14 }}>{'\u2726'}</Text>
|
|
107
|
+
</Animated.View>
|
|
108
|
+
<Text style={[styles.triggerText, { color: '#a855f7' }]}>{message}</Text>
|
|
109
|
+
<Text style={{ color: '#a855f7', fontSize: 14 }}>{onPress ? '\u203A' : (isOpen ? '\u25BE' : '\u25B8')}</Text>
|
|
110
|
+
</Pressable>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Content — renders thinking text (collapsible)
|
|
115
|
+
export function ReasoningContent({ children }: ReasoningContentProps) {
|
|
116
|
+
const { isOpen, isStreaming } = useReasoning();
|
|
117
|
+
const colors = useAliaColors();
|
|
118
|
+
|
|
119
|
+
if (!isOpen) return null;
|
|
120
|
+
|
|
121
|
+
// Lazy import AliaMarkdown to avoid circular dependency
|
|
122
|
+
const { AliaMarkdown } = require('./Markdown');
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<View style={[styles.contentWrapper, { backgroundColor: '#a855f710', borderColor: '#a855f730' }]}>
|
|
126
|
+
<View style={{ opacity: isStreaming ? 0.8 : 1 }}>
|
|
127
|
+
<AliaMarkdown content={children} />
|
|
128
|
+
</View>
|
|
129
|
+
</View>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const styles = StyleSheet.create({
|
|
134
|
+
trigger: {
|
|
135
|
+
flexDirection: 'row',
|
|
136
|
+
alignItems: 'center',
|
|
137
|
+
gap: 8,
|
|
138
|
+
paddingVertical: 8,
|
|
139
|
+
paddingHorizontal: 12,
|
|
140
|
+
borderRadius: 8,
|
|
141
|
+
},
|
|
142
|
+
triggerText: {
|
|
143
|
+
fontSize: 14,
|
|
144
|
+
fontWeight: '500',
|
|
145
|
+
flex: 1,
|
|
146
|
+
},
|
|
147
|
+
contentWrapper: {
|
|
148
|
+
borderRadius: 8,
|
|
149
|
+
padding: 12,
|
|
150
|
+
borderWidth: 1,
|
|
151
|
+
marginHorizontal: 12,
|
|
152
|
+
marginBottom: 12,
|
|
153
|
+
},
|
|
154
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { View, Text, StyleSheet } from "react-native";
|
|
3
|
+
import Animated, {
|
|
4
|
+
useSharedValue,
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
withRepeat,
|
|
7
|
+
withTiming,
|
|
8
|
+
withSequence,
|
|
9
|
+
Easing,
|
|
10
|
+
} from "react-native-reanimated";
|
|
11
|
+
import { useAliaColors } from "../theme";
|
|
12
|
+
|
|
13
|
+
const thinkingPhrases = [
|
|
14
|
+
"Thinking...",
|
|
15
|
+
"Crafting...",
|
|
16
|
+
"Pondering...",
|
|
17
|
+
"Computing...",
|
|
18
|
+
"Processing...",
|
|
19
|
+
"Analyzing...",
|
|
20
|
+
"Reasoning...",
|
|
21
|
+
"Cooking...",
|
|
22
|
+
"Brewing...",
|
|
23
|
+
"Conjuring...",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const workingPhrases = [
|
|
27
|
+
"Working...",
|
|
28
|
+
"Executing...",
|
|
29
|
+
"Running...",
|
|
30
|
+
"Building...",
|
|
31
|
+
"Creating...",
|
|
32
|
+
"Doing the thing...",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export function ThinkingIndicator({ isWorking = false, statusText }: { isWorking?: boolean; statusText?: string }) {
|
|
36
|
+
const colors = useAliaColors();
|
|
37
|
+
const phrases = isWorking ? workingPhrases : thinkingPhrases;
|
|
38
|
+
const [phraseIndex, setPhraseIndex] = useState(() =>
|
|
39
|
+
Math.floor(Math.random() * phrases.length)
|
|
40
|
+
);
|
|
41
|
+
const [displayText, setDisplayText] = useState("");
|
|
42
|
+
const [isTyping, setIsTyping] = useState(true);
|
|
43
|
+
|
|
44
|
+
// Reset phraseIndex when isWorking changes (arrays have different lengths)
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
setPhraseIndex(Math.floor(Math.random() * phrases.length));
|
|
47
|
+
}, [isWorking]);
|
|
48
|
+
|
|
49
|
+
// Spinning asterisk animation
|
|
50
|
+
const rotation = useSharedValue(0);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
rotation.value = withRepeat(
|
|
53
|
+
withTiming(360, { duration: 2000, easing: Easing.linear }),
|
|
54
|
+
-1
|
|
55
|
+
);
|
|
56
|
+
}, [rotation]);
|
|
57
|
+
|
|
58
|
+
const spinStyle = useAnimatedStyle(() => ({
|
|
59
|
+
transform: [{ rotate: `${rotation.value}deg` }],
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
// Pulsing cursor animation
|
|
63
|
+
const cursorOpacity = useSharedValue(1);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
cursorOpacity.value = withRepeat(
|
|
66
|
+
withSequence(
|
|
67
|
+
withTiming(0.2, { duration: 400 }),
|
|
68
|
+
withTiming(1, { duration: 400 })
|
|
69
|
+
),
|
|
70
|
+
-1
|
|
71
|
+
);
|
|
72
|
+
}, [cursorOpacity]);
|
|
73
|
+
|
|
74
|
+
const cursorStyle = useAnimatedStyle(() => ({
|
|
75
|
+
opacity: cursorOpacity.value,
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
// Typewriter effect — skipped when statusText is provided (real-time status shown directly)
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (statusText) return;
|
|
81
|
+
const phrase = phrases[phraseIndex % phrases.length];
|
|
82
|
+
let charIndex = 0;
|
|
83
|
+
setIsTyping(true);
|
|
84
|
+
setDisplayText("");
|
|
85
|
+
|
|
86
|
+
const typeInterval = setInterval(() => {
|
|
87
|
+
if (charIndex < phrase.length) {
|
|
88
|
+
setDisplayText(phrase.slice(0, charIndex + 1));
|
|
89
|
+
charIndex++;
|
|
90
|
+
} else {
|
|
91
|
+
clearInterval(typeInterval);
|
|
92
|
+
setIsTyping(false);
|
|
93
|
+
|
|
94
|
+
// Wait then switch to next phrase
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
setPhraseIndex((prev) => (prev + 1) % phrases.length);
|
|
97
|
+
}, 1500);
|
|
98
|
+
}
|
|
99
|
+
}, 40);
|
|
100
|
+
|
|
101
|
+
return () => clearInterval(typeInterval);
|
|
102
|
+
}, [phraseIndex, phrases, statusText]);
|
|
103
|
+
|
|
104
|
+
const shownText = statusText || displayText;
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<View style={styles.container}>
|
|
108
|
+
<Animated.View style={spinStyle}>
|
|
109
|
+
<Text style={{ fontSize: 16, color: colors.mutedForeground }}>✱</Text>
|
|
110
|
+
</Animated.View>
|
|
111
|
+
<View style={styles.textRow}>
|
|
112
|
+
<Text style={{ fontSize: 16, color: colors.mutedForeground }}>{shownText}</Text>
|
|
113
|
+
{(statusText || isTyping) && (
|
|
114
|
+
<Animated.View style={cursorStyle}>
|
|
115
|
+
<Text style={{ fontSize: 16, color: colors.mutedForeground }}>|</Text>
|
|
116
|
+
</Animated.View>
|
|
117
|
+
)}
|
|
118
|
+
</View>
|
|
119
|
+
</View>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const styles = StyleSheet.create({
|
|
124
|
+
container: {
|
|
125
|
+
flexDirection: 'row',
|
|
126
|
+
alignItems: 'center',
|
|
127
|
+
gap: 8,
|
|
128
|
+
paddingVertical: 8,
|
|
129
|
+
},
|
|
130
|
+
textRow: {
|
|
131
|
+
flexDirection: 'row',
|
|
132
|
+
alignItems: 'center',
|
|
133
|
+
},
|
|
134
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { Pressable, ActivityIndicator } from "react-native";
|
|
4
|
+
import { TextClassContext } from "./text";
|
|
5
|
+
import { cn } from "../../lib/utils";
|
|
6
|
+
|
|
7
|
+
const buttonVariants = cva(
|
|
8
|
+
"group flex items-center justify-center rounded-xl web:ring-offset-background web:transition-colors web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2",
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: "bg-primary web:hover:opacity-90 active:opacity-90",
|
|
13
|
+
destructive: "bg-destructive web:hover:opacity-90 active:opacity-90",
|
|
14
|
+
outline:
|
|
15
|
+
"border border-input bg-background web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent",
|
|
16
|
+
secondary: "bg-secondary web:hover:opacity-80 active:opacity-80",
|
|
17
|
+
ghost:
|
|
18
|
+
"web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent",
|
|
19
|
+
link: "web:underline-offset-4 web:hover:underline web:focus:underline ",
|
|
20
|
+
pill: "text-primary-foreground rounded-full bg-black web:hover:opacity-90 active:opacity-90",
|
|
21
|
+
},
|
|
22
|
+
size: {
|
|
23
|
+
default: "h-9 px-3.5 py-2 native:h-11 native:px-4",
|
|
24
|
+
sm: "h-8 rounded-lg px-2.5",
|
|
25
|
+
lg: "h-10 rounded-xl px-6 native:h-12",
|
|
26
|
+
icon: "h-9 w-9",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
defaultVariants: {
|
|
30
|
+
variant: "default",
|
|
31
|
+
size: "default",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const buttonTextVariants = cva(
|
|
37
|
+
"web:whitespace-nowrap text-sm native:text-base font-medium text-foreground web:transition-colors",
|
|
38
|
+
{
|
|
39
|
+
variants: {
|
|
40
|
+
variant: {
|
|
41
|
+
default: "text-primary-foreground",
|
|
42
|
+
destructive: "text-destructive-foreground",
|
|
43
|
+
outline: "group-active:text-accent-foreground",
|
|
44
|
+
secondary:
|
|
45
|
+
"text-secondary-foreground group-active:text-secondary-foreground",
|
|
46
|
+
ghost: "group-active:text-accent-foreground",
|
|
47
|
+
link: "text-primary group-active:underline",
|
|
48
|
+
pill: "text-primary-foreground",
|
|
49
|
+
},
|
|
50
|
+
size: {
|
|
51
|
+
default: "",
|
|
52
|
+
sm: "",
|
|
53
|
+
lg: "native:text-lg",
|
|
54
|
+
icon: "",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
defaultVariants: {
|
|
58
|
+
variant: "default",
|
|
59
|
+
size: "default",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
type ButtonProps = React.ComponentPropsWithoutRef<typeof Pressable> &
|
|
65
|
+
VariantProps<typeof buttonVariants> & {
|
|
66
|
+
isLoading?: boolean;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const Button = React.forwardRef<
|
|
70
|
+
React.ElementRef<typeof Pressable>,
|
|
71
|
+
ButtonProps
|
|
72
|
+
>(({ className, variant, size, isLoading, children, ...props }, ref) => {
|
|
73
|
+
return (
|
|
74
|
+
<TextClassContext.Provider
|
|
75
|
+
value={cn(
|
|
76
|
+
props.disabled && "web:pointer-events-none",
|
|
77
|
+
buttonTextVariants({ variant, size }),
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
<Pressable
|
|
81
|
+
className={cn(
|
|
82
|
+
props.disabled && "opacity-50 web:pointer-events-none",
|
|
83
|
+
isLoading && "web:pointer-events-none",
|
|
84
|
+
buttonVariants({ variant, size, className }),
|
|
85
|
+
)}
|
|
86
|
+
ref={ref}
|
|
87
|
+
role="button"
|
|
88
|
+
{...props}
|
|
89
|
+
>
|
|
90
|
+
{isLoading ? (
|
|
91
|
+
<ActivityIndicator
|
|
92
|
+
color={variant === "outline" ? "black" : "white"}
|
|
93
|
+
/>
|
|
94
|
+
) : (
|
|
95
|
+
children
|
|
96
|
+
)}
|
|
97
|
+
</Pressable>
|
|
98
|
+
</TextClassContext.Provider>
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
Button.displayName = "Button";
|
|
102
|
+
|
|
103
|
+
export { Button, buttonTextVariants, buttonVariants };
|
|
104
|
+
export type { ButtonProps };
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
TextInput,
|
|
4
|
+
Platform,
|
|
5
|
+
View,
|
|
6
|
+
type NativeSyntheticEvent,
|
|
7
|
+
type TextInputKeyPressEventData,
|
|
8
|
+
type NativeSyntheticEvent as RNSyntheticEvent,
|
|
9
|
+
type TextInputContentSizeChangeEventData,
|
|
10
|
+
} from "react-native";
|
|
11
|
+
import { cn } from "../../lib/utils";
|
|
12
|
+
|
|
13
|
+
type ChatTextInputProps = React.ComponentPropsWithoutRef<typeof TextInput> & {
|
|
14
|
+
noFocus?: boolean;
|
|
15
|
+
onEnterPress?: () => void;
|
|
16
|
+
onCompletionKey?: (key: string) => boolean;
|
|
17
|
+
maxHeight?: number;
|
|
18
|
+
minHeight?: number;
|
|
19
|
+
onHeightChange?: (height: number) => void;
|
|
20
|
+
disableEnterToSubmit?: boolean;
|
|
21
|
+
disableAutoHeight?: boolean;
|
|
22
|
+
onImagePaste?: (files: File[]) => void;
|
|
23
|
+
fillContainer?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const ChatTextInput = React.forwardRef<TextInput, ChatTextInputProps>(
|
|
27
|
+
({
|
|
28
|
+
className,
|
|
29
|
+
noFocus = false,
|
|
30
|
+
onEnterPress,
|
|
31
|
+
onCompletionKey,
|
|
32
|
+
onKeyPress,
|
|
33
|
+
maxHeight = 200,
|
|
34
|
+
minHeight = 44,
|
|
35
|
+
onContentSizeChange,
|
|
36
|
+
onHeightChange,
|
|
37
|
+
style,
|
|
38
|
+
disableEnterToSubmit = false,
|
|
39
|
+
disableAutoHeight = false,
|
|
40
|
+
onImagePaste,
|
|
41
|
+
fillContainer = false,
|
|
42
|
+
...props
|
|
43
|
+
}, ref) => {
|
|
44
|
+
const inputRef = React.useRef<TextInput>(null);
|
|
45
|
+
const wrapperRef = React.useRef<View>(null);
|
|
46
|
+
|
|
47
|
+
React.useImperativeHandle(ref, () => inputRef.current as TextInput);
|
|
48
|
+
|
|
49
|
+
// Attach paste event listener (web only)
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
if (Platform.OS !== 'web' || !onImagePaste) return;
|
|
52
|
+
|
|
53
|
+
const handlePaste = (e: Event) => {
|
|
54
|
+
const clipboardEvent = e as ClipboardEvent;
|
|
55
|
+
|
|
56
|
+
// @ts-ignore - web-specific API
|
|
57
|
+
const activeElement = document.activeElement;
|
|
58
|
+
// @ts-ignore - web-specific API
|
|
59
|
+
const wrapper = wrapperRef.current;
|
|
60
|
+
|
|
61
|
+
// @ts-ignore - web-specific API
|
|
62
|
+
const isContained = wrapper && wrapper.contains(activeElement);
|
|
63
|
+
|
|
64
|
+
if (!isContained) return;
|
|
65
|
+
|
|
66
|
+
const items = clipboardEvent.clipboardData?.items;
|
|
67
|
+
if (!items) return;
|
|
68
|
+
|
|
69
|
+
const imageFiles: File[] = [];
|
|
70
|
+
for (let i = 0; i < items.length; i++) {
|
|
71
|
+
const item = items[i];
|
|
72
|
+
if (item.type.indexOf('image') !== -1) {
|
|
73
|
+
const file = item.getAsFile();
|
|
74
|
+
if (file) {
|
|
75
|
+
imageFiles.push(file);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (imageFiles.length > 0) {
|
|
81
|
+
clipboardEvent.preventDefault();
|
|
82
|
+
onImagePaste(imageFiles);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// @ts-ignore - web-specific API
|
|
87
|
+
document.addEventListener('paste', handlePaste);
|
|
88
|
+
|
|
89
|
+
return () => {
|
|
90
|
+
// @ts-ignore - web-specific API
|
|
91
|
+
document.removeEventListener('paste', handlePaste);
|
|
92
|
+
};
|
|
93
|
+
}, [onImagePaste]);
|
|
94
|
+
|
|
95
|
+
const handleKeyPress = (
|
|
96
|
+
e: NativeSyntheticEvent<TextInputKeyPressEventData>
|
|
97
|
+
) => {
|
|
98
|
+
onKeyPress?.(e);
|
|
99
|
+
|
|
100
|
+
const key = e.nativeEvent.key;
|
|
101
|
+
|
|
102
|
+
if (onCompletionKey && (key === "ArrowUp" || key === "ArrowDown" || key === "Enter" || key === "Escape")) {
|
|
103
|
+
if (onCompletionKey(key)) {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (key === "Enter" && !disableEnterToSubmit) {
|
|
110
|
+
// @ts-ignore - shiftKey exists on web
|
|
111
|
+
if (Platform.OS !== 'web' || !e.nativeEvent.shiftKey) {
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
onEnterPress?.();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const handleContentSizeChange = (
|
|
119
|
+
e: RNSyntheticEvent<TextInputContentSizeChangeEventData>
|
|
120
|
+
) => {
|
|
121
|
+
onContentSizeChange?.(e);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<View
|
|
126
|
+
ref={wrapperRef}
|
|
127
|
+
style={{ width: '100%', ...(fillContainer && { flex: 1 }) }}
|
|
128
|
+
onLayout={(e) => onHeightChange?.(e.nativeEvent.layout.height)}
|
|
129
|
+
>
|
|
130
|
+
<TextInput
|
|
131
|
+
ref={inputRef}
|
|
132
|
+
className={cn(
|
|
133
|
+
"native:text-md native:leading-[1.25] rounded-xl border border-input bg-background px-3.5 text-base text-foreground file:border-0 file:bg-transparent file:font-medium placeholder:text-muted-foreground web:flex web:w-full web:py-2 lg:text-sm",
|
|
134
|
+
"web:ring-offset-background web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2",
|
|
135
|
+
!fillContainer && !props.multiline && "h-9",
|
|
136
|
+
fillContainer && "h-full",
|
|
137
|
+
noFocus && "web:focus-visible:ring-0 web:focus-visible:ring-offset-0",
|
|
138
|
+
props.editable === false && "opacity-50 web:cursor-not-allowed",
|
|
139
|
+
className
|
|
140
|
+
)}
|
|
141
|
+
placeholderClassName={cn("text-muted-foreground", props.placeholderClassName)}
|
|
142
|
+
onKeyPress={handleKeyPress}
|
|
143
|
+
onContentSizeChange={handleContentSizeChange}
|
|
144
|
+
scrollEnabled={fillContainer || props.multiline}
|
|
145
|
+
style={[
|
|
146
|
+
style,
|
|
147
|
+
!fillContainer && props.multiline && !disableAutoHeight && {
|
|
148
|
+
minHeight,
|
|
149
|
+
maxHeight,
|
|
150
|
+
overflow: 'auto' as any,
|
|
151
|
+
...(Platform.OS === 'web' ? { fieldSizing: 'content' } : {}),
|
|
152
|
+
},
|
|
153
|
+
fillContainer && { flex: 1, height: '100%' },
|
|
154
|
+
]}
|
|
155
|
+
{...props}
|
|
156
|
+
/>
|
|
157
|
+
</View>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
ChatTextInput.displayName = "ChatTextInput";
|
|
163
|
+
|
|
164
|
+
export { ChatTextInput };
|