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:
@@ -83,6 +83,8 @@ src/
|
|||||||
│ ├── BaseBackground.tsx # Common screen wrapper
|
│ ├── BaseBackground.tsx # Common screen wrapper
|
||||||
│ ├── Header.tsx # Header component with logout button
|
│ ├── Header.tsx # Header component with logout button
|
||||||
│ ├── AuthButton.tsx # Reusable button for auth screens (with shadow)
|
│ ├── 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)
|
│ ├── EventCardBase.tsx # Shared event card layout with icons (used by EventCard & ProposedEventCard)
|
||||||
│ ├── EventCard.tsx # Calendar event card (uses EventCardBase + edit/delete buttons)
|
│ ├── EventCard.tsx # Calendar event card (uses EventCardBase + edit/delete buttons)
|
||||||
│ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal
|
│ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal
|
||||||
@@ -101,7 +103,7 @@ src/
|
|||||||
├── index.ts # Re-exports all stores
|
├── index.ts # Re-exports all stores
|
||||||
├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser()
|
├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser()
|
||||||
│ # Uses expo-secure-store (native) / localStorage (web)
|
│ # 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()
|
└── 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
|
- **Multiple event proposals**: AI can propose multiple events in one response
|
||||||
- Arrow navigation between proposals with "Event X von Y" counter
|
- Arrow navigation between proposals with "Event X von Y" counter
|
||||||
- Each proposal individually confirmable/rejectable
|
- 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
|
- Messages persisted to database via ChatService and loaded on mount
|
||||||
- Tracks conversationId for message continuity across sessions
|
- Tracks conversationId for message continuity across sessions
|
||||||
- ChatStore with addMessages() for bulk loading, chatMessageToMessageData() helper
|
- 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)
|
- `ProposedEventCard`: Uses EventCardBase + confirm/reject buttons for chat proposals (supports create/update/delete actions)
|
||||||
- `Themes.tsx`: Centralized color definitions including textPrimary, borderPrimary, eventIndicator, secondaryBg
|
- `Themes.tsx`: Centralized color definitions including textPrimary, borderPrimary, eventIndicator, secondaryBg
|
||||||
- `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[]
|
- `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
|
- Event Detail and Note screens exist as skeletons
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
import { ProposedEventChange } from "@calchat/shared";
|
import { ProposedEventChange } from "@calchat/shared";
|
||||||
import { ProposedEventCard } from "../../components/ProposedEventCard";
|
import { ProposedEventCard } from "../../components/ProposedEventCard";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import TypingIndicator from "../../components/TypingIndicator";
|
||||||
|
import { ChatBubble } from "../../components/ChatBubble";
|
||||||
|
|
||||||
// TODO: better shadows for everything
|
// TODO: better shadows for everything
|
||||||
// (maybe with extra library because of differences between android and ios)
|
// (maybe with extra library because of differences between android and ios)
|
||||||
@@ -40,10 +42,20 @@ type ChatInputProps = {
|
|||||||
onSend: (text: string) => void;
|
onSend: (text: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TYPING_INDICATOR_DELAY_MS = 500;
|
||||||
|
|
||||||
const Chat = () => {
|
const Chat = () => {
|
||||||
const { messages, addMessage, addMessages, updateMessage } = useChatStore();
|
const {
|
||||||
|
messages,
|
||||||
|
addMessage,
|
||||||
|
addMessages,
|
||||||
|
updateMessage,
|
||||||
|
isWaitingForResponse,
|
||||||
|
setWaitingForResponse,
|
||||||
|
} = useChatStore();
|
||||||
const listRef =
|
const listRef =
|
||||||
useRef<React.ComponentRef<typeof FlashList<MessageData>>>(null);
|
useRef<React.ComponentRef<typeof FlashList<MessageData>>>(null);
|
||||||
|
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
const [currentConversationId, setCurrentConversationId] = useState<
|
const [currentConversationId, setCurrentConversationId] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>();
|
>();
|
||||||
@@ -145,6 +157,11 @@ const Chat = () => {
|
|||||||
addMessage(userMessage);
|
addMessage(userMessage);
|
||||||
scrollToEnd();
|
scrollToEnd();
|
||||||
|
|
||||||
|
// Show typing indicator after delay
|
||||||
|
typingTimeoutRef.current = setTimeout(() => {
|
||||||
|
setWaitingForResponse(true);
|
||||||
|
}, TYPING_INDICATOR_DELAY_MS);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch server response (include conversationId for existing conversations)
|
// Fetch server response (include conversationId for existing conversations)
|
||||||
const response = await ChatService.sendMessage({
|
const response = await ChatService.sendMessage({
|
||||||
@@ -169,6 +186,10 @@ const Chat = () => {
|
|||||||
scrollToEnd();
|
scrollToEnd();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send message:", 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}
|
keyExtractor={(item) => item.id}
|
||||||
keyboardDismissMode="interactive"
|
keyboardDismissMode="interactive"
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
|
ListFooterComponent={isWaitingForResponse ? <TypingIndicator /> : null}
|
||||||
/>
|
/>
|
||||||
<ChatInput onSend={handleSend} />
|
<ChatInput onSend={handleSend} />
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
@@ -290,13 +312,6 @@ const ChatMessage = ({
|
|||||||
}: ChatMessageProps) => {
|
}: ChatMessageProps) => {
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
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 hasProposals = proposedChanges && proposedChanges.length > 0;
|
||||||
const hasMultiple = proposedChanges && proposedChanges.length > 1;
|
const hasMultiple = proposedChanges && proposedChanges.length > 1;
|
||||||
const currentProposal = proposedChanges?.[currentIndex];
|
const currentProposal = proposedChanges?.[currentIndex];
|
||||||
@@ -311,13 +326,11 @@ const ChatMessage = ({
|
|||||||
const canGoNext = currentIndex < (proposedChanges?.length || 1) - 1;
|
const canGoNext = currentIndex < (proposedChanges?.length || 1) - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<ChatBubble
|
||||||
className={`bg-white border-2 border-solid rounded-xl my-2 ${selfSide}`}
|
side={side}
|
||||||
style={{
|
style={{
|
||||||
borderColor: borderColor,
|
|
||||||
maxWidth: "80%",
|
maxWidth: "80%",
|
||||||
minWidth: hasProposals ? "75%" : undefined,
|
minWidth: hasProposals ? "75%" : undefined,
|
||||||
elevation: 8,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className="p-2">{content}</Text>
|
<Text className="p-2">{content}</Text>
|
||||||
@@ -379,7 +392,7 @@ const ChatMessage = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</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 {
|
interface ChatState {
|
||||||
messages: MessageData[];
|
messages: MessageData[];
|
||||||
|
isWaitingForResponse: boolean;
|
||||||
addMessages: (messages: MessageData[]) => void;
|
addMessages: (messages: MessageData[]) => void;
|
||||||
addMessage: (message: MessageData) => void;
|
addMessage: (message: MessageData) => void;
|
||||||
updateMessage: (id: string, updates: Partial<MessageData>) => void;
|
updateMessage: (id: string, updates: Partial<MessageData>) => void;
|
||||||
clearMessages: () => void;
|
clearMessages: () => void;
|
||||||
|
setWaitingForResponse: (waiting: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useChatStore = create<ChatState>((set) => ({
|
export const useChatStore = create<ChatState>((set) => ({
|
||||||
messages: [],
|
messages: [],
|
||||||
|
isWaitingForResponse: false,
|
||||||
addMessages(messages) {
|
addMessages(messages) {
|
||||||
set((state) => ({ messages: [...state.messages, ...messages] }));
|
set((state) => ({ messages: [...state.messages, ...messages] }));
|
||||||
},
|
},
|
||||||
@@ -37,6 +40,9 @@ export const useChatStore = create<ChatState>((set) => ({
|
|||||||
clearMessages: () => {
|
clearMessages: () => {
|
||||||
set({ messages: [] });
|
set({ messages: [] });
|
||||||
},
|
},
|
||||||
|
setWaitingForResponse: (waiting: boolean) => {
|
||||||
|
set({ isWaitingForResponse: waiting });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Helper to convert server ChatMessage to client MessageData
|
// Helper to convert server ChatMessage to client MessageData
|
||||||
|
|||||||
Reference in New Issue
Block a user