import { View, Text, TextInput, Pressable, KeyboardAvoidingView, Platform, Keyboard, } from "react-native"; import { useThemeStore } from "../../stores/ThemeStore"; import React, { useState, useRef, useEffect, useCallback } from "react"; import { useFocusEffect, router } from "expo-router"; import Header from "../../components/Header"; import BaseBackground from "../../components/BaseBackground"; import { FlashList } from "@shopify/flash-list"; import { ChatService } from "../../services"; import { useChatStore, useAuthStore, chatMessageToMessageData, MessageData, } from "../../stores"; import { ProposedEventChange, RespondedAction } from "@calchat/shared"; import { ProposedEventCard } from "../../components/ProposedEventCard"; import { Ionicons } from "@expo/vector-icons"; import TypingIndicator from "../../components/TypingIndicator"; import { ChatBubble } from "../../components/ChatBubble"; // TODO: better shadows for everything // (maybe with extra library because of differences between android and ios) // TODO: max width for messages type BubbleSide = "left" | "right"; type ChatMessageProps = { side: BubbleSide; content: string; proposedChanges?: ProposedEventChange[]; onConfirm?: (proposalId: string, proposal: ProposedEventChange) => void; onReject?: (proposalId: string) => void; onEdit?: (proposalId: string, proposal: ProposedEventChange) => void; }; type ChatInputProps = { onSend: (text: string) => void; }; const TYPING_INDICATOR_DELAY_MS = 500; const Chat = () => { const { isAuthenticated, isLoading: isAuthLoading } = useAuthStore(); const { messages, addMessage, addMessages, updateMessage, isWaitingForResponse, setWaitingForResponse, } = useChatStore(); const listRef = useRef>>(null); const typingTimeoutRef = useRef>(undefined); const [currentConversationId, setCurrentConversationId] = useState< string | undefined >(); const [hasLoadedMessages, setHasLoadedMessages] = useState(false); useEffect(() => { const keyboardDidShow = Keyboard.addListener( "keyboardDidShow", scrollToEnd, ); return () => keyboardDidShow.remove(); }, []); // Load existing messages from database only once (on initial mount) // Skip on subsequent focus events to preserve local edits (e.g., edited proposals) useFocusEffect( useCallback(() => { if (isAuthLoading || !isAuthenticated || hasLoadedMessages) return; const fetchMessages = async () => { try { const conversationSummaries = await ChatService.getConversations(); if (conversationSummaries.length > 0) { const conversationId = conversationSummaries[0].id; setCurrentConversationId(conversationId); const serverMessages = await ChatService.getConversation(conversationId); const clientMessages = serverMessages.map(chatMessageToMessageData); addMessages(clientMessages); scrollToEnd(); } } catch (error) { console.error("Failed to load messages:", error); } finally { setHasLoadedMessages(true); } }; fetchMessages(); }, [isAuthLoading, isAuthenticated, hasLoadedMessages]), ); const scrollToEnd = () => { setTimeout(() => { listRef.current?.scrollToEnd({ animated: true }); }, 100); }; const handleEventResponse = async ( action: RespondedAction, messageId: string, conversationId: string, proposalId: string, proposedChange?: ProposedEventChange, ) => { // Mark proposal as responded (optimistic update) const message = messages.find((m) => m.id === messageId); if (message?.proposedChanges) { const updatedProposals = message.proposedChanges.map((p) => p.id === proposalId ? { ...p, respondedAction: action } : p, ); updateMessage(messageId, { proposedChanges: updatedProposals }); } try { const response = action === "confirm" && proposedChange ? await ChatService.confirmEvent( conversationId, messageId, proposalId, proposedChange.action, proposedChange.event, proposedChange.eventId, proposedChange.updates, proposedChange.deleteMode, proposedChange.occurrenceDate, ) : await ChatService.rejectEvent( conversationId, messageId, proposalId, ); const botMessage: MessageData = { id: response.message.id, side: "left", content: response.message.content, conversationId: response.conversationId, }; addMessage(botMessage); scrollToEnd(); } catch (error) { console.error(`Failed to ${action} event:`, error); // Revert on error if (message?.proposedChanges) { const revertedProposals = message.proposedChanges.map((p) => p.id === proposalId ? { ...p, respondedAction: undefined } : p, ); updateMessage(messageId, { proposedChanges: revertedProposals }); } } }; const handleEditProposal = ( messageId: string, conversationId: string, proposalId: string, proposal: ProposedEventChange, ) => { router.push({ pathname: "/editEvent", params: { mode: "chat", eventData: JSON.stringify(proposal.event), proposalContext: JSON.stringify({ messageId, proposalId, conversationId, }), }, }); }; const handleSend = async (text: string) => { // Show user message immediately const userMessage: MessageData = { id: Date.now().toString(), side: "right", content: text, conversationId: currentConversationId, }; addMessage(userMessage); scrollToEnd(); // Show typing indicator after delay typingTimeoutRef.current = setTimeout(() => { setWaitingForResponse(true); scrollToEnd(); }, TYPING_INDICATOR_DELAY_MS); try { // Fetch server response (include conversationId for existing conversations) const response = await ChatService.sendMessage({ content: text, conversationId: currentConversationId, }); // Track conversation ID for subsequent messages if (!currentConversationId) { setCurrentConversationId(response.conversationId); } // Show bot response const botMessage: MessageData = { id: response.message.id, side: "left", content: response.message.content, proposedChanges: response.message.proposedChanges, conversationId: response.conversationId, }; addMessage(botMessage); scrollToEnd(); } catch (error) { console.error("Failed to send message:", error); } finally { // Hide typing indicator clearTimeout(typingTimeoutRef.current); setWaitingForResponse(false); } }; return ( ( handleEventResponse( "confirm", item.id, item.conversationId!, proposalId, proposal, ) } onReject={(proposalId) => handleEventResponse( "reject", item.id, item.conversationId!, proposalId, ) } onEdit={(proposalId, proposal) => handleEditProposal( item.id, item.conversationId!, proposalId, proposal, ) } /> )} keyExtractor={(item) => item.id} keyboardDismissMode="interactive" keyboardShouldPersistTaps="handled" ListFooterComponent={ isWaitingForResponse ? : null } /> ); }; const ChatHeader = () => { const { theme } = useThemeStore(); return (
CalChat
); }; const MIN_INPUT_HEIGHT = 40; const MAX_INPUT_HEIGHT = 150; const ChatInput = ({ onSend }: ChatInputProps) => { const { theme } = useThemeStore(); const [text, setText] = useState(""); const handleSend = () => { if (text.trim()) { onSend(text.trim()); setText(""); } }; return ( ); }; const ChatMessage = ({ side, content, proposedChanges, onConfirm, onReject, onEdit, }: ChatMessageProps) => { const { theme } = useThemeStore(); const [currentIndex, setCurrentIndex] = useState(0); const hasProposals = proposedChanges && proposedChanges.length > 0; const hasMultiple = proposedChanges && proposedChanges.length > 1; const currentProposal = proposedChanges?.[currentIndex]; const goToPrev = () => setCurrentIndex((i) => Math.max(0, i - 1)); const goToNext = () => setCurrentIndex((i) => Math.min((proposedChanges?.length || 1) - 1, i + 1)); const canGoPrev = currentIndex > 0; const canGoNext = currentIndex < (proposedChanges?.length || 1) - 1; return ( {content} {hasProposals && currentProposal && onConfirm && onReject && onEdit && ( {/* Event card with optional navigation arrows */} {/* Left arrow */} {hasMultiple && ( )} {/* Event Card */} onConfirm(proposal.id, proposal)} onReject={() => onReject(currentProposal.id)} onEdit={(proposal) => onEdit(proposal.id, proposal)} /> {/* Right arrow */} {hasMultiple && ( )} {/* Event counter */} {hasMultiple && ( Event {currentIndex + 1} von {proposedChanges.length} )} )} ); }; export default Chat;