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>
);
};