feat: improve chat keyboard handling and MonthSelector memory efficiency
- Add KeyboardAvoidingView with platform-specific behavior to chat screen - Implement auto-scroll to end on new messages and keyboard show - Configure keyboardDismissMode and keyboardShouldPersistTaps for better UX - Lazy-load MonthSelector data only when modal opens, clear on close - Add .env to gitignore
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
docs/praesi_2_context.md
|
docs/praesi_2_context.md
|
||||||
docs/*.png
|
docs/*.png
|
||||||
|
.env
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
|
|||||||
- Tab navigation (Chat, Calendar) implemented with basic UI
|
- Tab navigation (Chat, Calendar) implemented with basic UI
|
||||||
- Calendar screen fully functional:
|
- Calendar screen fully functional:
|
||||||
- Month navigation with grid display and Ionicons (chevron-back/forward)
|
- Month navigation with grid display and Ionicons (chevron-back/forward)
|
||||||
- MonthSelector dropdown with infinite scroll (dynamically loads months)
|
- MonthSelector dropdown with infinite scroll (dynamically loads months, lazy-loaded when modal opens, cleared on close for memory efficiency)
|
||||||
- Events loaded from API via EventService.getByDateRange()
|
- Events loaded from API via EventService.getByDateRange()
|
||||||
- Orange dot indicator for days with events
|
- Orange dot indicator for days with events
|
||||||
- Tap-to-open modal overlay showing EventCards for selected day
|
- Tap-to-open modal overlay showing EventCards for selected day
|
||||||
@@ -304,6 +304,9 @@ MONGODB_URI=mongodb://root:mongoose@localhost:27017/calchat?authSource=admin
|
|||||||
- Uses `useFocusEffect` for automatic reload on tab focus
|
- Uses `useFocusEffect` for automatic reload on tab focus
|
||||||
- Chat screen functional with FlashList, message sending, and event confirm/reject
|
- Chat screen functional with FlashList, message sending, and event confirm/reject
|
||||||
- Messages persisted via ChatStore (survives tab switches)
|
- Messages persisted via ChatStore (survives tab switches)
|
||||||
|
- KeyboardAvoidingView for proper keyboard handling (iOS padding, Android height)
|
||||||
|
- Auto-scroll to end on new messages and keyboard show
|
||||||
|
- keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled"
|
||||||
- `ApiClient`: get(), post(), put(), delete() implemented
|
- `ApiClient`: get(), post(), put(), delete() implemented
|
||||||
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete() - fully implemented
|
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete() - fully implemented
|
||||||
- `ChatService`: sendMessage(), confirmEvent(convId, msgId, action, event?, eventId?, updates?), rejectEvent() - supports create/update/delete actions
|
- `ChatService`: sendMessage(), confirmEvent(convId, msgId, action, event?, eventId?, updates?), rejectEvent() - supports create/update/delete actions
|
||||||
|
|||||||
@@ -323,16 +323,7 @@ const MonthSelector = ({
|
|||||||
const listRef = useRef<React.ComponentRef<typeof FlashList<MonthItem>>>(null);
|
const listRef = useRef<React.ComponentRef<typeof FlashList<MonthItem>>>(null);
|
||||||
const INITIAL_RANGE = 12; // 12 months before and after current
|
const INITIAL_RANGE = 12; // 12 months before and after current
|
||||||
|
|
||||||
const [monthSelectorData, setMonthSelectorData] = useState<MonthItem[]>(() =>
|
const [monthSelectorData, setMonthSelectorData] = useState<MonthItem[]>([]);
|
||||||
generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reset data when current month changes
|
|
||||||
useEffect(() => {
|
|
||||||
setMonthSelectorData(
|
|
||||||
generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE),
|
|
||||||
);
|
|
||||||
}, [currentYear, currentMonthIndex]);
|
|
||||||
|
|
||||||
const appendMonths = (direction: "start" | "end", count: number) => {
|
const appendMonths = (direction: "start" | "end", count: number) => {
|
||||||
setMonthSelectorData((prevData) => {
|
setMonthSelectorData((prevData) => {
|
||||||
@@ -378,6 +369,10 @@ const MonthSelector = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (modalVisible) {
|
if (modalVisible) {
|
||||||
|
// Generate fresh data centered on current month
|
||||||
|
setMonthSelectorData(
|
||||||
|
generateMonths(currentYear, currentMonthIndex, INITIAL_RANGE),
|
||||||
|
);
|
||||||
Animated.timing(heightAnim, {
|
Animated.timing(heightAnim, {
|
||||||
toValue: 200,
|
toValue: 200,
|
||||||
duration: 200,
|
duration: 200,
|
||||||
@@ -385,8 +380,10 @@ const MonthSelector = ({
|
|||||||
}).start();
|
}).start();
|
||||||
} else {
|
} else {
|
||||||
heightAnim.setValue(0);
|
heightAnim.setValue(0);
|
||||||
|
// Clear data when closing
|
||||||
|
setMonthSelectorData([]);
|
||||||
}
|
}
|
||||||
}, [modalVisible, heightAnim]);
|
}, [modalVisible, heightAnim, currentYear, currentMonthIndex]);
|
||||||
|
|
||||||
const renderItem = ({ item }: { item: MonthItem }) => (
|
const renderItem = ({ item }: { item: MonthItem }) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { View, Text, TextInput, Pressable } from "react-native";
|
import { View, Text, TextInput, Pressable, KeyboardAvoidingView, Platform, Keyboard } from "react-native";
|
||||||
import currentTheme from "../../Themes";
|
import currentTheme from "../../Themes";
|
||||||
import { useState } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import Header from "../../components/Header";
|
import Header from "../../components/Header";
|
||||||
import BaseBackground from "../../components/BaseBackground";
|
import BaseBackground from "../../components/BaseBackground";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
@@ -31,6 +31,18 @@ type ChatInputProps = {
|
|||||||
|
|
||||||
const Chat = () => {
|
const Chat = () => {
|
||||||
const { messages, addMessage, updateMessage } = useChatStore();
|
const { messages, addMessage, updateMessage } = useChatStore();
|
||||||
|
const listRef = useRef<FlashList<MessageData>>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const keyboardDidShow = Keyboard.addListener("keyboardDidShow", scrollToEnd);
|
||||||
|
return () => keyboardDidShow.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollToEnd = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
listRef.current?.scrollToEnd({ animated: true });
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
const handleEventResponse = async (
|
const handleEventResponse = async (
|
||||||
action: "confirm" | "reject",
|
action: "confirm" | "reject",
|
||||||
@@ -61,6 +73,7 @@ const Chat = () => {
|
|||||||
conversationId: response.conversationId,
|
conversationId: response.conversationId,
|
||||||
};
|
};
|
||||||
addMessage(botMessage);
|
addMessage(botMessage);
|
||||||
|
scrollToEnd();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to ${action} event:`, error);
|
console.error(`Failed to ${action} event:`, error);
|
||||||
// Revert on error
|
// Revert on error
|
||||||
@@ -76,6 +89,7 @@ const Chat = () => {
|
|||||||
content: text,
|
content: text,
|
||||||
};
|
};
|
||||||
addMessage(userMessage);
|
addMessage(userMessage);
|
||||||
|
scrollToEnd();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch server response
|
// Fetch server response
|
||||||
@@ -90,6 +104,7 @@ const Chat = () => {
|
|||||||
conversationId: response.conversationId,
|
conversationId: response.conversationId,
|
||||||
};
|
};
|
||||||
addMessage(botMessage);
|
addMessage(botMessage);
|
||||||
|
scrollToEnd();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send message:", error);
|
console.error("Failed to send message:", error);
|
||||||
}
|
}
|
||||||
@@ -98,30 +113,38 @@ const Chat = () => {
|
|||||||
return (
|
return (
|
||||||
<BaseBackground>
|
<BaseBackground>
|
||||||
<ChatHeader />
|
<ChatHeader />
|
||||||
<FlashList
|
<KeyboardAvoidingView
|
||||||
data={messages}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
renderItem={({ item }) => (
|
style={{ flex: 1 }}
|
||||||
<ChatMessage
|
>
|
||||||
side={item.side}
|
<FlashList
|
||||||
content={item.content}
|
ref={listRef}
|
||||||
proposedChange={item.proposedChange}
|
data={messages}
|
||||||
respondedAction={item.respondedAction}
|
renderItem={({ item }) => (
|
||||||
onConfirm={() =>
|
<ChatMessage
|
||||||
handleEventResponse(
|
side={item.side}
|
||||||
"confirm",
|
content={item.content}
|
||||||
item.id,
|
proposedChange={item.proposedChange}
|
||||||
item.conversationId!,
|
respondedAction={item.respondedAction}
|
||||||
item.proposedChange,
|
onConfirm={() =>
|
||||||
)
|
handleEventResponse(
|
||||||
}
|
"confirm",
|
||||||
onReject={() =>
|
item.id,
|
||||||
handleEventResponse("reject", item.id, item.conversationId!)
|
item.conversationId!,
|
||||||
}
|
item.proposedChange,
|
||||||
/>
|
)
|
||||||
)}
|
}
|
||||||
keyExtractor={(item) => item.id}
|
onReject={() =>
|
||||||
/>
|
handleEventResponse("reject", item.id, item.conversationId!)
|
||||||
<ChatInput onSend={handleSend} />
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
keyboardDismissMode="interactive"
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
/>
|
||||||
|
<ChatInput onSend={handleSend} />
|
||||||
|
</KeyboardAvoidingView>
|
||||||
</BaseBackground>
|
</BaseBackground>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user