Files
contexta_mb/src/components/ChatInterface.tsx
belviskhoremk 9e663bdc8b Initial commit
2026-05-08 13:01:47 +00:00

307 lines
8.9 KiB
TypeScript

import React, { useState, useRef, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
Platform,
Animated,
} from 'react-native';
import { COLORS, FONT_SIZE, SPACING, RADIUS, TEXT, SHADOWS } from '../theme';
import { useTheme } from '../theme';
import { chatAPI } from '../services/api';
interface ChatInterfaceProps {
chatbotId: string;
welcomeMessage?: string;
primaryColor?: string;
}
interface LocalMessage {
id: string;
role: 'user' | 'assistant';
content: string;
pending?: boolean;
time?: string;
}
function getTime() {
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export function ChatInterface({ chatbotId, welcomeMessage, primaryColor }: ChatInterfaceProps) {
const { theme, isDark } = useTheme();
const accentColor = primaryColor ?? COLORS.primary;
const flatRef = useRef<FlatList>(null);
const [messages, setMessages] = useState<LocalMessage[]>(() =>
welcomeMessage
? [{ id: 'welcome', role: 'assistant', content: welcomeMessage, time: getTime() }]
: [],
);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [sessionId, setSessionId] = useState<string | undefined>();
const scrollToBottom = useCallback(() => {
setTimeout(() => flatRef.current?.scrollToEnd({ animated: true }), 80);
}, []);
const sendMessage = async () => {
const text = input.trim();
if (!text || sending) return;
const userMsg: LocalMessage = { id: Date.now().toString(), role: 'user', content: text, time: getTime() };
const loadingMsg: LocalMessage = { id: 'loading', role: 'assistant', content: '', pending: true };
setMessages(prev => [...prev, userMsg, loadingMsg]);
setInput('');
setSending(true);
scrollToBottom();
try {
const data = await chatAPI.sendMessage(chatbotId, text, sessionId);
if (!sessionId) setSessionId(data.session_id);
setMessages(prev => [
...prev.filter(m => m.id !== 'loading'),
{ id: (Date.now() + 1).toString(), role: 'assistant', content: data.response, time: getTime() },
]);
} catch {
setMessages(prev => [
...prev.filter(m => m.id !== 'loading'),
{ id: (Date.now() + 1).toString(), role: 'assistant', content: "Sorry, I couldn't process your message. Please try again.", time: getTime() },
]);
} finally {
setSending(false);
scrollToBottom();
}
};
const renderMessage = ({ item }: { item: LocalMessage }) => {
const isUser = item.role === 'user';
return (
<MessageBubble
message={item}
isUser={isUser}
accentColor={accentColor}
theme={theme}
isDark={isDark}
/>
);
};
return (
<KeyboardAvoidingView
style={[styles.container, { backgroundColor: theme.bg }]}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}>
<FlatList
ref={flatRef}
data={messages}
keyExtractor={item => item.id}
renderItem={renderMessage}
contentContainerStyle={styles.messageList}
showsVerticalScrollIndicator={false}
onContentSizeChange={scrollToBottom}
/>
{/* Input bar */}
<View style={[styles.inputBar, { backgroundColor: theme.surface, borderTopColor: theme.border }]}>
<View style={[styles.inputWrapper, { backgroundColor: theme.bg, borderColor: theme.border }]}>
<TextInput
style={[styles.textInput, { color: theme.text }]}
value={input}
onChangeText={setInput}
placeholder="Message..."
placeholderTextColor={theme.placeholder}
multiline
maxLength={2000}
onSubmitEditing={sendMessage}
blurOnSubmit={false}
/>
</View>
<TouchableOpacity
style={[
styles.sendBtn,
{ backgroundColor: accentColor },
(sending || !input.trim()) && styles.sendBtnDisabled,
SHADOWS.primary,
]}
onPress={sendMessage}
disabled={sending || !input.trim()}>
<Text style={styles.sendIcon}></Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
function MessageBubble({
message,
isUser,
accentColor,
theme,
isDark,
}: {
message: LocalMessage;
isUser: boolean;
accentColor: string;
theme: any;
isDark: boolean;
}) {
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(8)).current;
React.useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, { toValue: 1, duration: 250, useNativeDriver: true }),
Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, speed: 20, bounciness: 2 }),
]).start();
}, []);
return (
<Animated.View
style={[
styles.msgRow,
isUser && styles.msgRowUser,
{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] },
]}>
{!isUser && (
<View style={[styles.avatar, { backgroundColor: accentColor }]}>
<Text style={styles.avatarText}>AI</Text>
</View>
)}
<View style={styles.bubbleWrapper}>
<View
style={[
styles.bubble,
isUser
? [styles.bubbleUser, { backgroundColor: accentColor }]
: [styles.bubbleBot, { backgroundColor: theme.surface, borderColor: theme.border }],
]}>
{message.pending ? (
<TypingIndicator />
) : (
<Text style={[styles.bubbleText, { color: isUser ? COLORS.white : theme.text }]}>
{message.content}
</Text>
)}
</View>
{message.time && !message.pending ? (
<Text style={[styles.timestamp, { color: theme.textMuted, alignSelf: isUser ? 'flex-end' : 'flex-start' }]}>
{message.time}
</Text>
) : null}
</View>
</Animated.View>
);
}
function TypingIndicator() {
const dot1 = useRef(new Animated.Value(0.3)).current;
const dot2 = useRef(new Animated.Value(0.3)).current;
const dot3 = useRef(new Animated.Value(0.3)).current;
React.useEffect(() => {
const pulse = (dot: Animated.Value, delay: number) =>
Animated.loop(
Animated.sequence([
Animated.delay(delay),
Animated.timing(dot, { toValue: 1, duration: 300, useNativeDriver: true }),
Animated.timing(dot, { toValue: 0.3, duration: 300, useNativeDriver: true }),
Animated.delay(600 - delay),
]),
);
const a1 = pulse(dot1, 0);
const a2 = pulse(dot2, 200);
const a3 = pulse(dot3, 400);
a1.start(); a2.start(); a3.start();
return () => { a1.stop(); a2.stop(); a3.stop(); };
}, []);
return (
<View style={typingStyles.row}>
{[dot1, dot2, dot3].map((dot, i) => (
<Animated.View key={i} style={[typingStyles.dot, { opacity: dot }]} />
))}
</View>
);
}
const typingStyles = StyleSheet.create({
row: { flexDirection: 'row', gap: 5, paddingVertical: 4, paddingHorizontal: 2 },
dot: { width: 8, height: 8, borderRadius: 4, backgroundColor: COLORS.primary },
});
const styles = StyleSheet.create({
container: { flex: 1 },
messageList: {
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.lg,
gap: SPACING.md,
paddingBottom: SPACING.xl,
},
msgRow: { flexDirection: 'row', gap: SPACING.sm, alignItems: 'flex-end', maxWidth: '100%' },
msgRowUser: { flexDirection: 'row-reverse' },
avatar: {
width: 32,
height: 32,
borderRadius: RADIUS.full,
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
avatarText: { color: COLORS.white, fontSize: 10, fontWeight: '800' },
bubbleWrapper: { flex: 1, gap: 4 },
bubble: { maxWidth: '80%', borderRadius: RADIUS.xl, padding: SPACING.md },
bubbleUser: { alignSelf: 'flex-end', borderBottomRightRadius: RADIUS.xs },
bubbleBot: {
alignSelf: 'flex-start',
borderWidth: 1,
borderBottomLeftRadius: RADIUS.xs,
},
bubbleText: { fontSize: FONT_SIZE.md, lineHeight: 22 },
timestamp: { fontSize: 10, paddingHorizontal: 2 },
inputBar: {
flexDirection: 'row',
alignItems: 'flex-end',
paddingHorizontal: SPACING.md,
paddingVertical: SPACING.md,
borderTopWidth: 1,
gap: SPACING.sm,
},
inputWrapper: {
flex: 1,
borderRadius: RADIUS.xl,
borderWidth: 1.5,
paddingHorizontal: SPACING.md,
paddingVertical: Platform.OS === 'ios' ? SPACING.sm : 0,
maxHeight: 120,
},
textInput: {
fontSize: FONT_SIZE.md,
minHeight: 40,
paddingTop: Platform.OS === 'android' ? SPACING.sm : 0,
},
sendBtn: {
width: 46,
height: 46,
borderRadius: RADIUS.full,
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
sendBtnDisabled: { opacity: 0.45 },
sendIcon: { color: COLORS.white, fontSize: 20, fontWeight: '700', marginTop: -2 },
});