/** * MessageInputEnhanced Component * * Full-featured message input with rainbow animations, empty state, * image upload, file upload, and sophisticated send button states. * * Features: * - Rainbow border/shadow animation when focused, streaming, or chat empty * - Empty state intro (rainbow "co" + welcome text) when no messages * - Image upload with preview and remove functionality * - File upload support (optional, via onFileUpload prop) * - Absolute positioned buttons (file, image, send) overlaying input * - Arrow-up send icon with ActivityIndicator when sending * - Dynamic send button styling (white/black based on content + theme) * - Focus state management with callbacks * - Safe area support for proper bottom padding * * This component achieves 100% feature parity with the original input * while maintaining clean, modular, testable code. */ import React, { useState, useCallback, useRef } from 'react'; import { View, Text, TextInput, TouchableOpacity, StyleSheet, Platform, Image, Alert, Animated, ActivityIndicator, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useRainbowAnimation, RAINBOW_COLORS } from '../hooks/useRainbowAnimation'; import EmptyStateIntro from './EmptyStateIntro'; import { pickFile } from '../utils/fileUpload'; import type { Theme } from '../theme'; interface MessageInputEnhancedProps { onSend: (text: string) => void; isSendingMessage: boolean; theme: Theme; colorScheme: 'light' | 'dark'; // Empty state hasMessages: boolean; isLoadingMessages?: boolean; // Rainbow animation triggers isStreaming: boolean; hasExpandedReasoning: boolean; // Image management (external state) selectedImages: Array<{ uri: string; base64: string; mediaType: string }>; onAddImage: (image: { uri: string; base64: string; mediaType: string }) => void; onRemoveImage: (index: number) => void; // Optional file upload handler onFileUpload?: (file: File) => Promise; // Optional callbacks onFocusChange?: (focused: boolean) => void; disabled?: boolean; } export const MessageInputEnhanced: React.FC = ({ onSend, isSendingMessage, theme, colorScheme, hasMessages, isLoadingMessages = false, isStreaming, hasExpandedReasoning, selectedImages, onAddImage, onRemoveImage, onFileUpload, onFocusChange, disabled = false, }) => { const insets = useSafeAreaInsets(); const inputRef = useRef(null); // State const [inputText, setInputText] = useState(''); const [isInputFocused, setIsInputFocused] = useState(false); const [isUploadingFile, setIsUploadingFile] = useState(false); // Rainbow animation const { rainbowAnimValue } = useRainbowAnimation({ isStreaming, isInputFocused, hasExpandedReasoning, hasMessages, }); // Computed values const hasContent = inputText.trim().length > 0 || selectedImages.length > 0; const isDark = colorScheme === 'dark'; // Image picker const handlePickImage = async () => { try { const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (status !== 'granted') { Alert.alert('Permission Required', 'Please allow access to your photo library.'); return; } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], allowsMultipleSelection: false, quality: 0.8, base64: true, }); if (!result.canceled && result.assets && result.assets.length > 0) { const asset = result.assets[0]; if (asset.base64) { // Check size: 5MB limit const MAX_SIZE = 5 * 1024 * 1024; if (asset.base64.length > MAX_SIZE) { const sizeMB = (asset.base64.length / 1024 / 1024).toFixed(2); Alert.alert('Image Too Large', `This image is ${sizeMB}MB. Maximum allowed is 5MB.`); return; } const mediaType = asset.uri.match(/\.(jpg|jpeg)$/i) ? 'image/jpeg' : asset.uri.match(/\.png$/i) ? 'image/png' : asset.uri.match(/\.gif$/i) ? 'image/gif' : asset.uri.match(/\.webp$/i) ? 'image/webp' : 'image/jpeg'; onAddImage({ uri: asset.uri, base64: asset.base64, mediaType, }); } } } catch (error) { console.error('Error picking image:', error); Alert.alert('Error', 'Failed to pick image'); } }; // File picker const handlePickFile = async () => { if (!onFileUpload) { Alert.alert('Not Available', 'File upload is not yet configured.'); return; } try { setIsUploadingFile(true); const result = await pickFile(); if (result) { await onFileUpload(result.file); } } catch (error: any) { console.error('File upload error:', error); Alert.alert('Upload Failed', error.message || 'Failed to upload file'); } finally { setIsUploadingFile(false); } }; // Send message const handleSend = useCallback(() => { if (hasContent && !disabled && !isSendingMessage) { onSend(inputText.trim()); setInputText(''); inputRef.current?.clear(); } }, [inputText, hasContent, disabled, isSendingMessage, onSend]); // Focus handlers const handleFocus = useCallback(() => { setIsInputFocused(true); onFocusChange?.(true); }, [onFocusChange]); const handleBlur = useCallback(() => { setIsInputFocused(false); onFocusChange?.(false); }, [onFocusChange]); // Input wrapper style (base + rainbow when focused) const inputWrapperStyle = { borderRadius: 24, borderWidth: 2, borderColor: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 8, elevation: 2, }; // Send button style (white/black based on content, transparent when empty) const sendButtonStyle = { backgroundColor: !hasContent || isSendingMessage ? 'transparent' : isDark ? '#FFFFFF' : '#000000', }; // Send icon color (inverted from button background) const sendIconColor = !hasContent ? '#444444' : isDark ? '#000000' : '#FFFFFF'; return ( {/* Loading state - shown while messages are loading */} {isLoadingMessages && ( Loading messages... )} {/* Empty state intro - shown above input when chat is empty (but not loading) */} {!hasMessages && !isLoadingMessages && } {/* Image preview section */} {selectedImages.length > 0 && ( {selectedImages.map((img, index) => ( onRemoveImage(index)} style={styles.removeImageButton} > ))} )} {/* Input wrapper with rainbow border/shadow when focused */} {/* File button (absolute positioned) */} {onFileUpload && ( {isUploadingFile ? ( ) : ( )} )} {/* Image button (absolute positioned) */} {/* Text input (full width) */} {/* Send button (absolute positioned) */} {isSendingMessage ? ( ) : ( )} ); }; const styles = StyleSheet.create({ inputContainer: { width: '100%', paddingHorizontal: 18, alignItems: 'center', }, inputContainerCentered: { justifyContent: 'center', }, inputCentered: { position: 'relative', maxWidth: 700, width: '100%', }, loadingContainer: { alignItems: 'center', justifyContent: 'center', paddingVertical: 40, }, loadingText: { marginTop: 16, fontSize: 14, fontFamily: 'Lexend_400Regular', }, imagePreviewContainer: { flexDirection: 'row', flexWrap: 'wrap', marginBottom: 12, }, imagePreviewWrapper: { marginRight: 8, marginBottom: 8, position: 'relative', }, imagePreview: { width: 80, height: 80, borderRadius: 8, }, removeImageButton: { position: 'absolute', top: -8, right: -8, backgroundColor: 'rgba(0, 0, 0, 0.6)', borderRadius: 12, }, inputWrapper: { position: 'relative', flexDirection: 'row', alignItems: 'flex-end', }, fileButton: { position: 'absolute', right: 88, bottom: 8, width: 32, height: 32, borderRadius: 16, justifyContent: 'center', alignItems: 'center', zIndex: 1, }, imageButton: { position: 'absolute', right: 52, bottom: 8, width: 32, height: 32, borderRadius: 16, justifyContent: 'center', alignItems: 'center', zIndex: 1, }, imageButtonNoFile: { right: 88, // Move to file button position when no file upload }, textInput: { width: '100%', minHeight: 40, maxHeight: 120, paddingLeft: 18, paddingRight: 130, // Space for buttons paddingTop: 8, paddingBottom: 8, borderRadius: 24, fontSize: 16, lineHeight: 24, borderWidth: 0, fontFamily: 'Lexend_400Regular', ...Platform.select({ web: { // @ts-ignore - web-only properties outline: 'none', outlineStyle: 'none', WebkitAppearance: 'none', MozAppearance: 'none', resize: 'none', overflowY: 'auto', }, }), }, sendButton: { position: 'absolute', right: 10, bottom: 8, width: 32, height: 32, borderRadius: 16, justifyContent: 'center', alignItems: 'center', }, }); export default MessageInputEnhanced;