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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
28
apps/client/src/components/ChatBubble.tsx
Normal file
28
apps/client/src/components/ChatBubble.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
apps/client/src/components/TypingIndicator.tsx
Normal file
30
apps/client/src/components/TypingIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user