A React Native app for the ultimate thinking partner.
at main 13 kB view raw
1/** 2 * MessageInputEnhanced Component 3 * 4 * Full-featured message input with rainbow animations, empty state, 5 * image upload, file upload, and sophisticated send button states. 6 * 7 * Features: 8 * - Rainbow border/shadow animation when focused, streaming, or chat empty 9 * - Empty state intro (rainbow "co" + welcome text) when no messages 10 * - Image upload with preview and remove functionality 11 * - File upload support (optional, via onFileUpload prop) 12 * - Absolute positioned buttons (file, image, send) overlaying input 13 * - Arrow-up send icon with ActivityIndicator when sending 14 * - Dynamic send button styling (white/black based on content + theme) 15 * - Focus state management with callbacks 16 * - Safe area support for proper bottom padding 17 * 18 * This component achieves 100% feature parity with the original input 19 * while maintaining clean, modular, testable code. 20 */ 21 22import React, { useState, useCallback, useRef } from 'react'; 23import { 24 View, 25 Text, 26 TextInput, 27 TouchableOpacity, 28 StyleSheet, 29 Platform, 30 Image, 31 Alert, 32 Animated, 33 ActivityIndicator, 34} from 'react-native'; 35import { Ionicons } from '@expo/vector-icons'; 36import * as ImagePicker from 'expo-image-picker'; 37import { useSafeAreaInsets } from 'react-native-safe-area-context'; 38 39import { useRainbowAnimation, RAINBOW_COLORS } from '../hooks/useRainbowAnimation'; 40import EmptyStateIntro from './EmptyStateIntro'; 41import { pickFile } from '../utils/fileUpload'; 42import type { Theme } from '../theme'; 43 44interface MessageInputEnhancedProps { 45 onSend: (text: string) => void; 46 isSendingMessage: boolean; 47 theme: Theme; 48 colorScheme: 'light' | 'dark'; 49 50 // Empty state 51 hasMessages: boolean; 52 isLoadingMessages?: boolean; 53 54 // Rainbow animation triggers 55 isStreaming: boolean; 56 hasExpandedReasoning: boolean; 57 58 // Image management (external state) 59 selectedImages: Array<{ uri: string; base64: string; mediaType: string }>; 60 onAddImage: (image: { uri: string; base64: string; mediaType: string }) => void; 61 onRemoveImage: (index: number) => void; 62 63 // Optional file upload handler 64 onFileUpload?: (file: File) => Promise<void>; 65 66 // Optional callbacks 67 onFocusChange?: (focused: boolean) => void; 68 disabled?: boolean; 69} 70 71export const MessageInputEnhanced: React.FC<MessageInputEnhancedProps> = ({ 72 onSend, 73 isSendingMessage, 74 theme, 75 colorScheme, 76 hasMessages, 77 isLoadingMessages = false, 78 isStreaming, 79 hasExpandedReasoning, 80 selectedImages, 81 onAddImage, 82 onRemoveImage, 83 onFileUpload, 84 onFocusChange, 85 disabled = false, 86}) => { 87 const insets = useSafeAreaInsets(); 88 const inputRef = useRef<TextInput>(null); 89 90 // State 91 const [inputText, setInputText] = useState(''); 92 const [isInputFocused, setIsInputFocused] = useState(false); 93 const [isUploadingFile, setIsUploadingFile] = useState(false); 94 95 // Rainbow animation 96 const { rainbowAnimValue } = useRainbowAnimation({ 97 isStreaming, 98 isInputFocused, 99 hasExpandedReasoning, 100 hasMessages, 101 }); 102 103 // Computed values 104 const hasContent = inputText.trim().length > 0 || selectedImages.length > 0; 105 const isDark = colorScheme === 'dark'; 106 107 // Image picker 108 const handlePickImage = async () => { 109 try { 110 const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); 111 if (status !== 'granted') { 112 Alert.alert('Permission Required', 'Please allow access to your photo library.'); 113 return; 114 } 115 116 const result = await ImagePicker.launchImageLibraryAsync({ 117 mediaTypes: ['images'], 118 allowsMultipleSelection: false, 119 quality: 0.8, 120 base64: true, 121 }); 122 123 if (!result.canceled && result.assets && result.assets.length > 0) { 124 const asset = result.assets[0]; 125 if (asset.base64) { 126 // Check size: 5MB limit 127 const MAX_SIZE = 5 * 1024 * 1024; 128 if (asset.base64.length > MAX_SIZE) { 129 const sizeMB = (asset.base64.length / 1024 / 1024).toFixed(2); 130 Alert.alert('Image Too Large', `This image is ${sizeMB}MB. Maximum allowed is 5MB.`); 131 return; 132 } 133 134 const mediaType = 135 asset.uri.match(/\.(jpg|jpeg)$/i) 136 ? 'image/jpeg' 137 : asset.uri.match(/\.png$/i) 138 ? 'image/png' 139 : asset.uri.match(/\.gif$/i) 140 ? 'image/gif' 141 : asset.uri.match(/\.webp$/i) 142 ? 'image/webp' 143 : 'image/jpeg'; 144 145 onAddImage({ 146 uri: asset.uri, 147 base64: asset.base64, 148 mediaType, 149 }); 150 } 151 } 152 } catch (error) { 153 console.error('Error picking image:', error); 154 Alert.alert('Error', 'Failed to pick image'); 155 } 156 }; 157 158 // File picker 159 const handlePickFile = async () => { 160 if (!onFileUpload) { 161 Alert.alert('Not Available', 'File upload is not yet configured.'); 162 return; 163 } 164 165 try { 166 setIsUploadingFile(true); 167 const result = await pickFile(); 168 169 if (result) { 170 await onFileUpload(result.file); 171 } 172 } catch (error: any) { 173 console.error('File upload error:', error); 174 Alert.alert('Upload Failed', error.message || 'Failed to upload file'); 175 } finally { 176 setIsUploadingFile(false); 177 } 178 }; 179 180 // Send message 181 const handleSend = useCallback(() => { 182 if (hasContent && !disabled && !isSendingMessage) { 183 onSend(inputText.trim()); 184 setInputText(''); 185 inputRef.current?.clear(); 186 } 187 }, [inputText, hasContent, disabled, isSendingMessage, onSend]); 188 189 // Focus handlers 190 const handleFocus = useCallback(() => { 191 setIsInputFocused(true); 192 onFocusChange?.(true); 193 }, [onFocusChange]); 194 195 const handleBlur = useCallback(() => { 196 setIsInputFocused(false); 197 onFocusChange?.(false); 198 }, [onFocusChange]); 199 200 // Input wrapper style (base + rainbow when focused) 201 const inputWrapperStyle = { 202 borderRadius: 24, 203 borderWidth: 2, 204 borderColor: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)', 205 shadowColor: '#000', 206 shadowOffset: { width: 0, height: 2 }, 207 shadowOpacity: 0.1, 208 shadowRadius: 8, 209 elevation: 2, 210 }; 211 212 // Send button style (white/black based on content, transparent when empty) 213 const sendButtonStyle = { 214 backgroundColor: 215 !hasContent || isSendingMessage ? 'transparent' : isDark ? '#FFFFFF' : '#000000', 216 }; 217 218 // Send icon color (inverted from button background) 219 const sendIconColor = !hasContent ? '#444444' : isDark ? '#000000' : '#FFFFFF'; 220 221 return ( 222 <View 223 style={[ 224 styles.inputContainer, 225 { 226 paddingBottom: Math.max(insets.bottom, 16), 227 }, 228 !hasMessages && styles.inputContainerCentered, 229 ]} 230 > 231 <View style={styles.inputCentered}> 232 {/* Loading state - shown while messages are loading */} 233 {isLoadingMessages && ( 234 <View style={styles.loadingContainer}> 235 <ActivityIndicator size="large" color={theme.colors.interactive.primary} /> 236 <Text style={[styles.loadingText, { color: theme.colors.text.secondary }]}> 237 Loading messages... 238 </Text> 239 </View> 240 )} 241 242 {/* Empty state intro - shown above input when chat is empty (but not loading) */} 243 {!hasMessages && !isLoadingMessages && <EmptyStateIntro theme={theme} />} 244 245 {/* Image preview section */} 246 {selectedImages.length > 0 && ( 247 <View style={styles.imagePreviewContainer}> 248 {selectedImages.map((img, index) => ( 249 <View key={index} style={styles.imagePreviewWrapper}> 250 <Image source={{ uri: img.uri }} style={styles.imagePreview} /> 251 <TouchableOpacity 252 onPress={() => onRemoveImage(index)} 253 style={styles.removeImageButton} 254 > 255 <Ionicons name="close-circle" size={24} color="#fff" /> 256 </TouchableOpacity> 257 </View> 258 ))} 259 </View> 260 )} 261 262 {/* Input wrapper with rainbow border/shadow when focused */} 263 <Animated.View 264 style={[ 265 styles.inputWrapper, 266 inputWrapperStyle, 267 isInputFocused && { 268 borderColor: rainbowAnimValue.interpolate({ 269 inputRange: [0, 0.2, 0.4, 0.6, 0.8, 1], 270 outputRange: RAINBOW_COLORS, 271 }), 272 shadowColor: rainbowAnimValue.interpolate({ 273 inputRange: [0, 0.2, 0.4, 0.6, 0.8, 1], 274 outputRange: RAINBOW_COLORS, 275 }), 276 shadowOpacity: 0.4, 277 shadowRadius: 16, 278 }, 279 ]} 280 > 281 {/* File button (absolute positioned) */} 282 {onFileUpload && ( 283 <TouchableOpacity 284 onPress={handlePickFile} 285 style={styles.fileButton} 286 disabled={disabled || isSendingMessage || isUploadingFile} 287 > 288 {isUploadingFile ? ( 289 <ActivityIndicator size="small" color="#666666" /> 290 ) : ( 291 <Ionicons 292 name="attach-outline" 293 size={20} 294 color={disabled ? '#333333' : '#666666'} 295 /> 296 )} 297 </TouchableOpacity> 298 )} 299 300 {/* Image button (absolute positioned) */} 301 <TouchableOpacity 302 onPress={handlePickImage} 303 style={[styles.imageButton, !onFileUpload && styles.imageButtonNoFile]} 304 disabled={disabled || isSendingMessage} 305 > 306 <Ionicons 307 name="image-outline" 308 size={20} 309 color={disabled ? '#333333' : '#666666'} 310 /> 311 </TouchableOpacity> 312 313 {/* Text input (full width) */} 314 <TextInput 315 ref={inputRef} 316 style={[ 317 styles.textInput, 318 { 319 color: theme.colors.text.primary, 320 backgroundColor: isDark ? '#242424' : '#FFFFFF', 321 }, 322 ]} 323 placeholder="What's on your mind?" 324 placeholderTextColor={theme.colors.text.tertiary} 325 value={inputText} 326 onChangeText={setInputText} 327 onFocus={handleFocus} 328 onBlur={handleBlur} 329 multiline 330 maxLength={15000} 331 editable={!disabled && !isSendingMessage} 332 onSubmitEditing={handleSend} 333 /> 334 335 {/* Send button (absolute positioned) */} 336 <TouchableOpacity 337 onPress={handleSend} 338 style={[styles.sendButton, sendButtonStyle]} 339 disabled={disabled || !hasContent || isSendingMessage} 340 > 341 {isSendingMessage ? ( 342 <ActivityIndicator size="small" color={isDark ? '#fff' : '#000'} /> 343 ) : ( 344 <Ionicons name="arrow-up" size={20} color={sendIconColor} /> 345 )} 346 </TouchableOpacity> 347 </Animated.View> 348 </View> 349 </View> 350 ); 351}; 352 353const styles = StyleSheet.create({ 354 inputContainer: { 355 width: '100%', 356 paddingHorizontal: 18, 357 alignItems: 'center', 358 }, 359 inputContainerCentered: { 360 justifyContent: 'center', 361 }, 362 inputCentered: { 363 position: 'relative', 364 maxWidth: 700, 365 width: '100%', 366 }, 367 loadingContainer: { 368 alignItems: 'center', 369 justifyContent: 'center', 370 paddingVertical: 40, 371 }, 372 loadingText: { 373 marginTop: 16, 374 fontSize: 14, 375 fontFamily: 'Lexend_400Regular', 376 }, 377 imagePreviewContainer: { 378 flexDirection: 'row', 379 flexWrap: 'wrap', 380 marginBottom: 12, 381 }, 382 imagePreviewWrapper: { 383 marginRight: 8, 384 marginBottom: 8, 385 position: 'relative', 386 }, 387 imagePreview: { 388 width: 80, 389 height: 80, 390 borderRadius: 8, 391 }, 392 removeImageButton: { 393 position: 'absolute', 394 top: -8, 395 right: -8, 396 backgroundColor: 'rgba(0, 0, 0, 0.6)', 397 borderRadius: 12, 398 }, 399 inputWrapper: { 400 position: 'relative', 401 flexDirection: 'row', 402 alignItems: 'flex-end', 403 }, 404 fileButton: { 405 position: 'absolute', 406 right: 88, 407 bottom: 8, 408 width: 32, 409 height: 32, 410 borderRadius: 16, 411 justifyContent: 'center', 412 alignItems: 'center', 413 zIndex: 1, 414 }, 415 imageButton: { 416 position: 'absolute', 417 right: 52, 418 bottom: 8, 419 width: 32, 420 height: 32, 421 borderRadius: 16, 422 justifyContent: 'center', 423 alignItems: 'center', 424 zIndex: 1, 425 }, 426 imageButtonNoFile: { 427 right: 88, // Move to file button position when no file upload 428 }, 429 textInput: { 430 width: '100%', 431 minHeight: 40, 432 maxHeight: 120, 433 paddingLeft: 18, 434 paddingRight: 130, // Space for buttons 435 paddingTop: 8, 436 paddingBottom: 8, 437 borderRadius: 24, 438 fontSize: 16, 439 lineHeight: 24, 440 borderWidth: 0, 441 fontFamily: 'Lexend_400Regular', 442 ...Platform.select({ 443 web: { 444 // @ts-ignore - web-only properties 445 outline: 'none', 446 outlineStyle: 'none', 447 WebkitAppearance: 'none', 448 MozAppearance: 'none', 449 resize: 'none', 450 overflowY: 'auto', 451 }, 452 }), 453 }, 454 sendButton: { 455 position: 'absolute', 456 right: 10, 457 bottom: 8, 458 width: 32, 459 height: 32, 460 borderRadius: 16, 461 justifyContent: 'center', 462 alignItems: 'center', 463 }, 464}); 465 466export default MessageInputEnhanced;