Files
calchat/apps/client/src/app/(tabs)/chat.tsx
Linus Waldowsky 868e1ba68d perf: preload events and CalDAV config to avoid empty screens
Add CaldavConfigStore and preloadAppData() to load events (current month)
and CalDAV config into stores before dismissing the auth loading spinner.
This prevents the brief empty flash when first navigating to Calendar or
Settings tabs. Also applies Prettier formatting across codebase.
2026-02-09 18:59:03 +01:00

458 lines
13 KiB
TypeScript

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<React.ComponentRef<typeof FlashList<MessageData>>>(null);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(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 (
<BaseBackground>
<ChatHeader />
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<FlashList
ref={listRef}
data={messages}
renderItem={({ item }) => (
<ChatMessage
side={item.side}
content={item.content}
proposedChanges={item.proposedChanges}
onConfirm={(proposalId, proposal) =>
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 ? <TypingIndicator /> : null
}
/>
<ChatInput onSend={handleSend} />
</KeyboardAvoidingView>
</BaseBackground>
);
};
const ChatHeader = () => {
const { theme } = useThemeStore();
return (
<Header className="flex flex-row items-center">
<View
className="ml-3 w-12 h-12 rounded-3xl border border-solid"
style={{
backgroundColor: theme.placeholderBg,
borderColor: theme.primeFg,
}}
></View>
<Text className="text-lg pl-3" style={{ color: theme.textPrimary }}>
CalChat
</Text>
<View
className="h-2 bg-black"
style={{
shadowColor: theme.shadowColor,
shadowOffset: {
width: 0,
height: 5,
},
shadowOpacity: 0.34,
shadowRadius: 6.27,
elevation: 10,
}}
/>
</Header>
);
};
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 (
<View className="flex flex-row w-full items-end my-2 px-2">
<TextInput
className="flex-1 border border-solid rounded-2xl px-3 py-2 mr-2"
style={{
backgroundColor: theme.messageBorderBg,
color: theme.textPrimary,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
textAlignVertical: "top",
}}
onChangeText={setText}
value={text}
placeholder="Nachricht..."
placeholderTextColor={theme.textMuted}
multiline
/>
<Pressable onPress={handleSend}>
<View
className="w-10 h-10 rounded-full items-center justify-center"
style={{
backgroundColor: theme.placeholderBg,
}}
/>
</Pressable>
</View>
);
};
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 (
<ChatBubble
side={side}
style={{
maxWidth: "80%",
minWidth: hasProposals ? "75%" : undefined,
}}
>
<Text className="p-2" style={{ color: theme.textPrimary }}>
{content}
</Text>
{hasProposals && currentProposal && onConfirm && onReject && onEdit && (
<View>
{/* Event card with optional navigation arrows */}
<View className="flex-row items-center">
{/* Left arrow */}
{hasMultiple && (
<Pressable
onPress={goToPrev}
disabled={!canGoPrev}
className="p-1"
style={{ opacity: canGoPrev ? 1 : 0.3 }}
>
<Ionicons name="chevron-back" size={24} color={theme.primeFg} />
</Pressable>
)}
{/* Event Card */}
<View className="flex-1">
<ProposedEventCard
proposedChange={currentProposal}
onConfirm={(proposal) => onConfirm(proposal.id, proposal)}
onReject={() => onReject(currentProposal.id)}
onEdit={(proposal) => onEdit(proposal.id, proposal)}
/>
</View>
{/* Right arrow */}
{hasMultiple && (
<Pressable
onPress={goToNext}
disabled={!canGoNext}
className="p-1"
style={{ opacity: canGoNext ? 1 : 0.3 }}
>
<Ionicons
name="chevron-forward"
size={24}
color={theme.primeFg}
/>
</Pressable>
)}
</View>
{/* Event counter */}
{hasMultiple && (
<Text
className="text-center text-sm pb-2"
style={{ color: theme.textSecondary || "#666" }}
>
Event {currentIndex + 1} von {proposedChanges.length}
</Text>
)}
</View>
)}
</ChatBubble>
);
};
export default Chat;