From 1dbca79edd4432eaf9994a41f75112fa32780453 Mon Sep 17 00:00:00 2001 From: Linus Waldowsky Date: Mon, 12 Jan 2026 22:49:21 +0100 Subject: [PATCH] feat: add typing indicator with ChatBubble component - Add ChatBubble component for reusable chat bubble styling - Add TypingIndicator component with animated dots (. .. ...) - Show typing indicator after 500ms delay while waiting for AI response - Refactor ChatMessage to use ChatBubble component - Add isWaitingForResponse state to ChatStore --- CLAUDE.md | 9 ++++- apps/client/src/app/(tabs)/chat.tsx | 39 ++++++++++++------- apps/client/src/components/ChatBubble.tsx | 28 +++++++++++++ .../client/src/components/TypingIndicator.tsx | 30 ++++++++++++++ apps/client/src/stores/ChatStore.ts | 6 +++ 5 files changed, 97 insertions(+), 15 deletions(-) create mode 100644 apps/client/src/components/ChatBubble.tsx create mode 100644 apps/client/src/components/TypingIndicator.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 458d0a0..7d20d74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,6 +83,8 @@ src/ │ ├── BaseBackground.tsx # Common screen wrapper │ ├── Header.tsx # Header component with logout button │ ├── AuthButton.tsx # Reusable button for auth screens (with shadow) +│ ├── ChatBubble.tsx # Reusable chat bubble component (used by ChatMessage & TypingIndicator) +│ ├── TypingIndicator.tsx # Animated typing indicator (. .. ...) shown while waiting for AI response │ ├── EventCardBase.tsx # Shared event card layout with icons (used by EventCard & ProposedEventCard) │ ├── EventCard.tsx # Calendar event card (uses EventCardBase + edit/delete buttons) │ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal @@ -101,7 +103,7 @@ src/ ├── index.ts # Re-exports all stores ├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser() │ # Uses expo-secure-store (native) / localStorage (web) - ├── ChatStore.ts # messages[], addMessage(), addMessages(), updateMessage(), clearMessages(), chatMessageToMessageData() + ├── ChatStore.ts # messages[], isWaitingForResponse, addMessage(), addMessages(), updateMessage(), clearMessages(), setWaitingForResponse(), chatMessageToMessageData() └── EventsStore.ts # events[], setEvents(), addEvent(), updateEvent(), deleteEvent() ``` @@ -390,6 +392,7 @@ NODE_ENV=development # development = pretty logs, production = JSON - **Multiple event proposals**: AI can propose multiple events in one response - Arrow navigation between proposals with "Event X von Y" counter - Each proposal individually confirmable/rejectable + - **Typing indicator**: Animated dots (. .. ...) shown after 500ms delay while waiting for AI response - Messages persisted to database via ChatService and loaded on mount - Tracks conversationId for message continuity across sessions - ChatStore with addMessages() for bulk loading, chatMessageToMessageData() helper @@ -403,7 +406,9 @@ NODE_ENV=development # development = pretty logs, production = JSON - `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals (supports create/update/delete actions) - `Themes.tsx`: Centralized color definitions including textPrimary, borderPrimary, eventIndicator, secondaryBg - `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[] -- `ChatStore`: Zustand store with addMessage(), addMessages(), updateMessage(), clearMessages() - loads from server on mount and persists across tab switches +- `ChatStore`: Zustand store with addMessage(), addMessages(), updateMessage(), clearMessages(), isWaitingForResponse/setWaitingForResponse() for typing indicator - loads from server on mount and persists across tab switches +- `ChatBubble`: Reusable chat bubble component with Tailwind styling, used by ChatMessage and TypingIndicator +- `TypingIndicator`: Animated typing indicator component showing `. → .. → ...` loop while waiting for AI response - Event Detail and Note screens exist as skeletons ## Building diff --git a/apps/client/src/app/(tabs)/chat.tsx b/apps/client/src/app/(tabs)/chat.tsx index 97e24c9..90e7ed6 100644 --- a/apps/client/src/app/(tabs)/chat.tsx +++ b/apps/client/src/app/(tabs)/chat.tsx @@ -21,6 +21,8 @@ import { import { ProposedEventChange } 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) @@ -40,10 +42,20 @@ type ChatInputProps = { onSend: (text: string) => void; }; +const TYPING_INDICATOR_DELAY_MS = 500; + const Chat = () => { - const { messages, addMessage, addMessages, updateMessage } = useChatStore(); + const { + messages, + addMessage, + addMessages, + updateMessage, + isWaitingForResponse, + setWaitingForResponse, + } = useChatStore(); const listRef = useRef>>(null); + const typingTimeoutRef = useRef>(undefined); const [currentConversationId, setCurrentConversationId] = useState< string | undefined >(); @@ -145,6 +157,11 @@ const Chat = () => { addMessage(userMessage); scrollToEnd(); + // Show typing indicator after delay + typingTimeoutRef.current = setTimeout(() => { + setWaitingForResponse(true); + }, TYPING_INDICATOR_DELAY_MS); + try { // Fetch server response (include conversationId for existing conversations) const response = await ChatService.sendMessage({ @@ -169,6 +186,10 @@ const Chat = () => { scrollToEnd(); } catch (error) { console.error("Failed to send message:", error); + } finally { + // Hide typing indicator + clearTimeout(typingTimeoutRef.current); + setWaitingForResponse(false); } }; @@ -204,6 +225,7 @@ const Chat = () => { keyExtractor={(item) => item.id} keyboardDismissMode="interactive" keyboardShouldPersistTaps="handled" + ListFooterComponent={isWaitingForResponse ? : null} /> @@ -290,13 +312,6 @@ const ChatMessage = ({ }: ChatMessageProps) => { const [currentIndex, setCurrentIndex] = useState(0); - const borderColor = - side === "left" ? currentTheme.chatBot : currentTheme.primeFg; - const selfSide = - side === "left" - ? "self-start ml-2 rounded-bl-sm" - : "self-end mr-2 rounded-br-sm"; - const hasProposals = proposedChanges && proposedChanges.length > 0; const hasMultiple = proposedChanges && proposedChanges.length > 1; const currentProposal = proposedChanges?.[currentIndex]; @@ -311,13 +326,11 @@ const ChatMessage = ({ const canGoNext = currentIndex < (proposedChanges?.length || 1) - 1; return ( - {content} @@ -379,7 +392,7 @@ const ChatMessage = ({ )} )} - + ); }; diff --git a/apps/client/src/components/ChatBubble.tsx b/apps/client/src/components/ChatBubble.tsx new file mode 100644 index 0000000..9a9ffd7 --- /dev/null +++ b/apps/client/src/components/ChatBubble.tsx @@ -0,0 +1,28 @@ +import { View, ViewStyle } from "react-native"; +import colors from "../Themes"; + +type BubbleSide = "left" | "right"; + +type ChatBubbleProps = { + side: BubbleSide; + children: React.ReactNode; + className?: string; + style?: ViewStyle; +}; + +export function ChatBubble({ side, children, className = "", style }: ChatBubbleProps) { + const borderColor = side === "left" ? colors.chatBot : colors.primeFg; + const sideClass = + side === "left" + ? "self-start ml-2 rounded-bl-sm" + : "self-end mr-2 rounded-br-sm"; + + return ( + + {children} + + ); +} diff --git a/apps/client/src/components/TypingIndicator.tsx b/apps/client/src/components/TypingIndicator.tsx new file mode 100644 index 0000000..86ed90a --- /dev/null +++ b/apps/client/src/components/TypingIndicator.tsx @@ -0,0 +1,30 @@ +import { useEffect, useState } from "react"; +import { Text } from "react-native"; +import colors from "../Themes"; +import { ChatBubble } from "./ChatBubble"; + +const DOTS = [".", "..", "..."]; +const INTERVAL_MS = 400; + +export default function TypingIndicator() { + const [dotIndex, setDotIndex] = useState(0); + + useEffect(() => { + const interval = setInterval(() => { + setDotIndex((prev) => (prev + 1) % DOTS.length); + }, INTERVAL_MS); + + return () => clearInterval(interval); + }, []); + + return ( + + + {DOTS[dotIndex]} + + + ); +} diff --git a/apps/client/src/stores/ChatStore.ts b/apps/client/src/stores/ChatStore.ts index f199726..ff3b879 100644 --- a/apps/client/src/stores/ChatStore.ts +++ b/apps/client/src/stores/ChatStore.ts @@ -13,14 +13,17 @@ export type MessageData = { interface ChatState { messages: MessageData[]; + isWaitingForResponse: boolean; addMessages: (messages: MessageData[]) => void; addMessage: (message: MessageData) => void; updateMessage: (id: string, updates: Partial) => void; clearMessages: () => void; + setWaitingForResponse: (waiting: boolean) => void; } export const useChatStore = create((set) => ({ messages: [], + isWaitingForResponse: false, addMessages(messages) { set((state) => ({ messages: [...state.messages, ...messages] })); }, @@ -37,6 +40,9 @@ export const useChatStore = create((set) => ({ clearMessages: () => { set({ messages: [] }); }, + setWaitingForResponse: (waiting: boolean) => { + set({ isWaitingForResponse: waiting }); + }, })); // Helper to convert server ChatMessage to client MessageData