implement chat messaging with event proposals

- Add functional chat with server communication and test responses
- Add ProposedEventCard component for confirm/reject actions
- Move Constants (Day, Month) from client to shared package
- Add dateHelpers utility for weekday calculations
- Extend Themes.tsx with button and text colors
- Update CLAUDE.md with current implementation status
- Add *.tsbuildinfo to .gitignore
This commit is contained in:
2026-01-04 00:01:26 +01:00
parent e553103470
commit c33508a227
17 changed files with 456 additions and 295 deletions

View File

@@ -1,5 +1,5 @@
import { Animated, Modal, Pressable, Text, View } from "react-native";
import { DAYS, MONTHS, Month } from "../../Constants";
import { DAYS, MONTHS, Month } from "@caldav/shared";
import Header from "../../components/Header";
import React, { useEffect, useMemo, useRef, useState } from "react";
import currentTheme from "../../Themes";

View File

@@ -1,9 +1,12 @@
import { View, Text, TextInput } from "react-native";
import { View, Text, TextInput, Pressable } from "react-native";
import currentTheme from "../../Themes";
import { useState } from "react";
import Header from "../../components/Header";
import BaseBackground from "../../components/BaseBackground";
import { FlashList } from "@shopify/flash-list";
import { ChatService } from "../../services";
import { ProposedEventChange } from "@caldav/shared";
import { ProposedEventCard } from "../../components/ProposedEventCard";
// TODO: better shadows for everything
// (maybe with extra library because of differences between android and ios)
@@ -11,239 +14,91 @@ import { FlashList } from "@shopify/flash-list";
// TODO: create new messages
type BubbleSide = "left" | "right";
type ChatMessageProps = {
side: BubbleSide;
width: number;
height: number;
content: string;
proposedChange?: ProposedEventChange;
respondedAction?: "confirm" | "reject";
onConfirm?: () => void;
onReject?: () => void;
};
type MessageData = {
type MessageData = ChatMessageProps & {
id: string;
side: BubbleSide;
width: number;
height: number;
conversationId?: string;
};
// NOTE: only for testing
const getRandomInt = (min: number, max: number) => {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
type ChatInputProps = {
onSend: (text: string) => void;
};
const randomWidth = () => getRandomInt(100, 400);
const randomHeight = () => getRandomInt(50, 100);
const messages: MessageData[] = [
// {{{
{
id: "1",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "2",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
{
id: "3",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "4",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
{
id: "5",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "6",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
{
id: "7",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "8",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
{
id: "9",
side: "left",
width: randomWidth(),
height: randomHeight(),
},
{
id: "10",
side: "right",
width: randomWidth(),
height: randomHeight(),
},
// {
// id: "11",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "12",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "13",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "14",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "15",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "16",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "17",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "18",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "19",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "20",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "21",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "22",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "23",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "24",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "25",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "26",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "27",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "28",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "29",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "30",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "31",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "32",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "33",
// side: "left",
// width: randomWidth(),
// height: randomHeight(),
// },
// {
// id: "34",
// side: "right",
// width: randomWidth(),
// height: randomHeight(),
// },
//, width: randomWidth, height: getRandomInt(50, 500) }}}
];
const Chat = () => {
const [messages, setMessages] = useState<MessageData[]>([]);
const handleEventResponse = async (
action: "confirm" | "reject",
messageId: string,
conversationId: string
) => {
// Mark message as responded (optimistic update)
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId ? { ...msg, respondedAction: action } : msg
)
);
try {
const response =
action === "confirm"
? await ChatService.confirmEvent(conversationId, messageId)
: await ChatService.rejectEvent(conversationId, messageId);
const botMessage: MessageData = {
id: response.message.id,
side: "left",
content: response.message.content,
conversationId: response.conversationId,
};
setMessages((prev) => [...prev, botMessage]);
} catch (error) {
console.error(`Failed to ${action} event:`, error);
// Revert on error
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId ? { ...msg, respondedAction: undefined } : msg
)
);
}
};
const handleSend = async (text: string) => {
// Show user message immediately
const userMessage: MessageData = {
id: Date.now().toString(),
side: "right",
content: text,
};
setMessages((prev) => [...prev, userMessage]);
try {
// Fetch server response
const response = await ChatService.sendMessage({ content: text });
// Show bot response
const botMessage: MessageData = {
id: response.message.id,
side: "left",
content: response.message.content,
proposedChange: response.message.proposedChange,
conversationId: response.conversationId,
};
setMessages((prev) => [...prev, botMessage]);
} catch (error) {
console.error("Failed to send message:", error);
}
};
return (
<BaseBackground>
<ChatHeader />
@@ -252,18 +107,20 @@ const Chat = () => {
renderItem={({ item }) => (
<ChatMessage
side={item.side}
width={item.width}
height={item.height}
content={item.content}
proposedChange={item.proposedChange}
respondedAction={item.respondedAction}
onConfirm={() =>
handleEventResponse("confirm", item.id, item.conversationId!)
}
onReject={() =>
handleEventResponse("reject", item.id, item.conversationId!)
}
/>
)}
maintainVisibleContentPosition={{
autoscrollToBottomThreshold: 0.2,
startRenderingFromBottom: true,
}}
keyExtractor={(item) => item.id}
// extraData={selectedId} might need this later for re-rendering
/>
<ChatInput />
<ChatInput onSend={handleSend} />
</BaseBackground>
);
};
@@ -297,51 +154,81 @@ const ChatHeader = () => {
);
};
const ChatInput = () => {
const [text, onChangeText] = useState("Nachricht");
const MIN_INPUT_HEIGHT = 40;
const MAX_INPUT_HEIGHT = 150;
const ChatInput = ({ onSend }: ChatInputProps) => {
const [text, setText] = useState("");
const handleSend = () => {
if (text.trim()) {
onSend(text.trim());
setText("");
}
};
return (
<View className="flex flex-row w-full h-8 my-2">
<View className="flex flex-row w-full items-end my-2 px-2">
<TextInput
className="w-4/5 h-full border border-solid rounded-2xl mx-2 px-2"
className="flex-1 border border-solid rounded-2xl px-3 py-2 mr-2"
style={{
backgroundColor: currentTheme.messageBorderBg,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
textAlignVertical: "top",
}}
onChangeText={onChangeText}
onChangeText={setText}
value={text}
placeholder="Nachricht..."
placeholderTextColor="#999"
multiline
/>
<View
className="w-8 h-full rounded-2xl"
style={{
backgroundColor: currentTheme.placeholderBg,
}}
></View>
<Pressable onPress={handleSend}>
<View
className="w-10 h-10 rounded-full items-center justify-center"
style={{
backgroundColor: currentTheme.placeholderBg,
}}
/>
</Pressable>
</View>
);
};
const ChatMessage = (props: ChatMessageProps) => {
const ChatMessage = ({
side,
content,
proposedChange,
respondedAction,
onConfirm,
onReject,
}: ChatMessageProps) => {
const borderColor =
props.side === "left" ? currentTheme.chatBot : currentTheme.primeFg;
side === "left" ? currentTheme.chatBot : currentTheme.primeFg;
const selfSide =
props.side === "left"
side === "left"
? "self-start ml-2 rounded-bl-sm"
: "self-end mr-2 rounded-br-sm";
return (
<View
className={
`bg-white border-2 border-solid rounded-xl my-2 ` + `${selfSide}`
}
className={`bg-white border-2 border-solid rounded-xl my-2 ${selfSide}`}
style={{
width: props.width,
height: props.height,
borderColor: borderColor,
maxWidth: "80%",
elevation: 8,
}}
>
<Text className="p-1">Lorem Ipsum Dolor sit amet</Text>
<Text className="p-2">{content}</Text>
{proposedChange && onConfirm && onReject && (
<ProposedEventCard
proposedChange={proposedChange}
respondedAction={respondedAction}
onConfirm={onConfirm}
onReject={onReject}
/>
)}
</View>
);
};