ai-town-clone / components /chat /ChatScreen.tsx
Muthukumarank's picture
Add components/chat/ChatScreen.tsx
4ecfeef verified
Raw
History Blame Contribute Delete
6.46 kB
/**
* AI Town — Chat Interface with AI Characters
* ==============================================
* RAG-powered conversation with persistent memory.
*/
import React, { useState, useRef, useEffect } from "react";
import { View, Text, TextInput, TouchableOpacity, FlatList, KeyboardAvoidingView, Platform, ActivityIndicator } from "react-native";
import { useAction, useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
import { Id } from "@/convex/_generated/dataModel";
interface ChatScreenProps {
characterId: Id<"characters">;
userId: string;
onBack: () => void;
}
export function ChatScreen({ characterId, userId, onBack }: ChatScreenProps) {
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const flatListRef = useRef<FlatList>(null);
// Realtime data from Convex
const character = useQuery(api.characters.get, { characterId });
const chatHistory = useQuery(api.characters.getUserChat, { userId, characterId });
const chatWithCharacter = useAction(api.characters.chatWithCharacter);
const messages = chatHistory?.messages || [];
const sendMessage = async () => {
if (!input.trim() || isLoading) return;
const userMessage = input.trim();
setInput("");
setIsLoading(true);
try {
const result = await chatWithCharacter({
userId,
characterId,
message: userMessage,
});
// Response is automatically saved and will appear via realtime query
} catch (error) {
console.error("Chat error:", error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
// Scroll to bottom on new message
if (messages.length > 0) {
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100);
}
}, [messages.length]);
if (!character) {
return (
<View className="flex-1 bg-gray-900 items-center justify-center">
<ActivityIndicator color="#60a5fa" />
</View>
);
}
return (
<KeyboardAvoidingView
className="flex-1 bg-gray-900"
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
{/* Header */}
<View className="flex-row items-center px-4 py-3 border-b border-gray-800">
<TouchableOpacity onPress={onBack} className="mr-3">
<Text className="text-blue-400 text-lg">← Back</Text>
</TouchableOpacity>
<View className="w-10 h-10 rounded-full bg-purple-600 items-center justify-center mr-3">
<Text className="text-lg">{character.avatar}</Text>
</View>
<View className="flex-1">
<Text className="text-white font-bold">{character.name}</Text>
<Text className="text-gray-400 text-xs">
{character.mood} • {character.currentAction}
</Text>
</View>
</View>
{/* Messages */}
<FlatList
ref={flatListRef}
data={messages}
keyExtractor={(_, i) => i.toString()}
contentContainerStyle={{ padding: 16, paddingBottom: 8 }}
renderItem={({ item }) => (
<View
className={`mb-3 max-w-[80%] ${
item.role === "user" ? "self-end" : "self-start"
}`}
>
<View
className={`px-4 py-2.5 rounded-2xl ${
item.role === "user"
? "bg-blue-600 rounded-br-sm"
: "bg-gray-800 rounded-bl-sm border border-gray-700"
}`}
>
<Text className={`text-sm ${item.role === "user" ? "text-white" : "text-gray-200"}`}>
{item.content}
</Text>
</View>
<Text className="text-[10px] text-gray-600 mt-1 px-1">
{item.role === "user" ? "You" : character.name} •{" "}
{new Date(item.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</Text>
</View>
)}
ListEmptyComponent={
<View className="items-center py-20">
<Text className="text-4xl mb-4">{character.avatar}</Text>
<Text className="text-white font-bold text-lg mb-1">
Chat with {character.name}
</Text>
<Text className="text-gray-400 text-sm text-center px-8">
{character.name} is a {character.occupation}. They're currently{" "}
{character.currentAction}. Start a conversation!
</Text>
<View className="flex-row flex-wrap gap-2 mt-4 px-4 justify-center">
{["Hello!", "What are you up to?", "Tell me about yourself", "How's your day?"].map(
(suggestion, i) => (
<TouchableOpacity
key={i}
onPress={() => setInput(suggestion)}
className="bg-gray-800 px-3 py-1.5 rounded-full border border-gray-700"
>
<Text className="text-gray-300 text-xs">{suggestion}</Text>
</TouchableOpacity>
)
)}
</View>
</View>
}
/>
{/* Typing indicator */}
{isLoading && (
<View className="px-4 pb-2">
<View className="bg-gray-800 self-start px-4 py-2 rounded-2xl rounded-bl-sm flex-row items-center gap-2">
<ActivityIndicator size="small" color="#9ca3af" />
<Text className="text-gray-400 text-sm">{character.name} is thinking...</Text>
</View>
</View>
)}
{/* Input */}
<View className="flex-row items-center px-4 py-3 border-t border-gray-800 gap-2">
<TextInput
value={input}
onChangeText={setInput}
placeholder={`Say something to ${character.name}...`}
placeholderTextColor="#6b7280"
className="flex-1 bg-gray-800 text-white px-4 py-2.5 rounded-full text-sm border border-gray-700"
onSubmitEditing={sendMessage}
returnKeyType="send"
editable={!isLoading}
/>
<TouchableOpacity
onPress={sendMessage}
disabled={!input.trim() || isLoading}
className={`w-10 h-10 rounded-full items-center justify-center ${
input.trim() && !isLoading ? "bg-blue-600" : "bg-gray-700"
}`}
>
<Text className="text-white text-lg"></Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}