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
This commit is contained in:
2026-01-12 22:49:21 +01:00
parent 489c0271c9
commit 1dbca79edd
5 changed files with 97 additions and 15 deletions

View File

@@ -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<React.ComponentRef<typeof FlashList<MessageData>>>(null);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(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 ? <TypingIndicator /> : null}
/>
<ChatInput onSend={handleSend} />
</KeyboardAvoidingView>
@@ -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 (
<View
className={`bg-white border-2 border-solid rounded-xl my-2 ${selfSide}`}
<ChatBubble
side={side}
style={{
borderColor: borderColor,
maxWidth: "80%",
minWidth: hasProposals ? "75%" : undefined,
elevation: 8,
}}
>
<Text className="p-2">{content}</Text>
@@ -379,7 +392,7 @@ const ChatMessage = ({
)}
</View>
)}
</View>
</ChatBubble>
);
};

View File

@@ -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 (
<View
className={`bg-white border-2 border-solid rounded-xl my-2 ${sideClass} ${className}`}
style={[{ borderColor, elevation: 8 }, style]}
>
{children}
</View>
);
}

View File

@@ -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 (
<ChatBubble side="left" className="px-4 py-2">
<Text
className="text-lg font-bold tracking-widest"
style={{ color: colors.textMuted }}
>
{DOTS[dotIndex]}
</Text>
</ChatBubble>
);
}

View File

@@ -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<MessageData>) => void;
clearMessages: () => void;
setWaitingForResponse: (waiting: boolean) => void;
}
export const useChatStore = create<ChatState>((set) => ({
messages: [],
isWaitingForResponse: false,
addMessages(messages) {
set((state) => ({ messages: [...state.messages, ...messages] }));
},
@@ -37,6 +40,9 @@ export const useChatStore = create<ChatState>((set) => ({
clearMessages: () => {
set({ messages: [] });
},
setWaitingForResponse: (waiting: boolean) => {
set({ isWaitingForResponse: waiting });
},
}));
// Helper to convert server ChatMessage to client MessageData