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.
458 lines
13 KiB
TypeScript
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;
|