438 lines
13 KiB
TypeScript
438 lines
13 KiB
TypeScript
import { useState, useEffect, useCallback } from "react";
|
|
import {
|
|
Modal,
|
|
View,
|
|
Text,
|
|
Pressable,
|
|
ScrollView,
|
|
Alert,
|
|
KeyboardAvoidingView,
|
|
Platform,
|
|
} from "react-native";
|
|
import { SafeAreaView } from "react-native-safe-area-context";
|
|
import { MessageSquare, X, Plus, Trash2 } from "lucide-react-native";
|
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
import { StyleSheet } from "react-native-unistyles";
|
|
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
|
|
|
const STORAGE_KEY = "@paseo:voice-conversation-id";
|
|
|
|
interface Conversation {
|
|
id: string;
|
|
lastUpdated: string;
|
|
messageCount: number;
|
|
}
|
|
|
|
interface ConversationSelectorProps {
|
|
currentVoiceConversationId: string | null;
|
|
onSelectVoiceConversation: (voiceConversationId: string | null) => void;
|
|
client: DaemonClientV2 | null;
|
|
}
|
|
|
|
export function ConversationSelector({
|
|
currentVoiceConversationId,
|
|
onSelectVoiceConversation,
|
|
client,
|
|
}: ConversationSelectorProps) {
|
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
const fetchConversations = useCallback(async () => {
|
|
setIsLoading(true);
|
|
|
|
if (!client) {
|
|
setIsLoading(false);
|
|
Alert.alert("Error", "Daemon unavailable.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
console.log("[ConversationSelector] Fetching conversations");
|
|
const response = await client.listVoiceConversations();
|
|
setConversations(response.conversations);
|
|
} catch (error) {
|
|
console.error(
|
|
"[ConversationSelector] Failed to list conversations:",
|
|
error
|
|
);
|
|
Alert.alert("Error", "Failed to load conversations.");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [client]);
|
|
|
|
// Fetch conversations when modal opens
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
void fetchConversations();
|
|
}
|
|
}, [fetchConversations, isOpen]);
|
|
|
|
const handleDeleteConversation = useCallback(
|
|
(id: string) => {
|
|
Alert.alert(
|
|
"Delete Conversation",
|
|
"Are you sure you want to delete this conversation?",
|
|
[
|
|
{ text: "Cancel", style: "cancel" },
|
|
{
|
|
text: "Delete",
|
|
style: "destructive",
|
|
onPress: () => {
|
|
if (!client) {
|
|
Alert.alert("Error", "Daemon unavailable.");
|
|
return;
|
|
}
|
|
|
|
console.log("[ConversationSelector] Deleting conversation:", id);
|
|
void (async () => {
|
|
try {
|
|
const response = await client.deleteVoiceConversation(id);
|
|
if (response.success) {
|
|
await fetchConversations();
|
|
if (response.voiceConversationId === currentVoiceConversationId) {
|
|
await handleNewConversation();
|
|
}
|
|
} else {
|
|
Alert.alert(
|
|
"Error",
|
|
`Failed to delete conversation: ${response.error}`
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error(
|
|
"[ConversationSelector] Failed to delete conversation:",
|
|
error
|
|
);
|
|
Alert.alert("Error", "Failed to delete conversation.");
|
|
}
|
|
})();
|
|
},
|
|
},
|
|
]
|
|
);
|
|
},
|
|
[currentVoiceConversationId, fetchConversations, handleNewConversation, client]
|
|
);
|
|
|
|
const handleClearAll = useCallback(() => {
|
|
Alert.alert(
|
|
"Clear All Conversations",
|
|
"Are you sure you want to delete all conversations?",
|
|
[
|
|
{ text: "Cancel", style: "cancel" },
|
|
{
|
|
text: "Clear All",
|
|
style: "destructive",
|
|
onPress: () => {
|
|
if (!client) {
|
|
Alert.alert("Error", "Daemon unavailable.");
|
|
return;
|
|
}
|
|
|
|
console.log("[ConversationSelector] Clearing all conversations");
|
|
void (async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
for (const conv of conversations) {
|
|
await client.deleteVoiceConversation(conv.id);
|
|
}
|
|
setConversations([]);
|
|
await handleNewConversation();
|
|
setIsOpen(false);
|
|
} catch (error) {
|
|
console.error(
|
|
"[ConversationSelector] Failed to clear conversations:",
|
|
error
|
|
);
|
|
Alert.alert("Error", "Failed to clear conversations.");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
})();
|
|
},
|
|
},
|
|
]
|
|
);
|
|
}, [conversations, handleNewConversation, client]);
|
|
|
|
async function handleSelectConversation(id: string) {
|
|
try {
|
|
// Save to AsyncStorage for persistence
|
|
await AsyncStorage.setItem(STORAGE_KEY, id);
|
|
onSelectVoiceConversation(id);
|
|
setIsOpen(false);
|
|
} catch (error) {
|
|
console.error(
|
|
"[ConversationSelector] Failed to save conversation ID:",
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
async function handleNewConversation() {
|
|
try {
|
|
// Clear saved conversation ID
|
|
await AsyncStorage.removeItem(STORAGE_KEY);
|
|
onSelectVoiceConversation(null);
|
|
setIsOpen(false);
|
|
} catch (error) {
|
|
console.error(
|
|
"[ConversationSelector] Failed to clear conversation ID:",
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
function formatDate(dateString: string): string {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMs / 3600000);
|
|
const diffDays = Math.floor(diffMs / 86400000);
|
|
|
|
if (diffMins < 1) return "Just now";
|
|
if (diffMins < 60) return `${diffMins}m ago`;
|
|
if (diffHours < 24) return `${diffHours}h ago`;
|
|
if (diffDays < 7) return `${diffDays}d ago`;
|
|
return date.toLocaleDateString();
|
|
}
|
|
|
|
return (
|
|
<View>
|
|
<Pressable onPress={() => setIsOpen(true)} style={styles.triggerButton}>
|
|
<MessageSquare size={20} color="white" />
|
|
</Pressable>
|
|
|
|
<Modal
|
|
visible={isOpen}
|
|
animationType="slide"
|
|
transparent={true}
|
|
onRequestClose={() => setIsOpen(false)}
|
|
>
|
|
<View style={styles.modalOverlay}>
|
|
<Pressable
|
|
style={styles.modalBackdrop}
|
|
onPress={() => setIsOpen(false)}
|
|
/>
|
|
<View style={styles.modalContent}>
|
|
<View style={styles.modalInner}>
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<Text style={styles.headerTitle}>
|
|
Conversations{" "}
|
|
{conversations.length > 0 && `(${conversations.length})`}
|
|
</Text>
|
|
<Pressable onPress={() => setIsOpen(false)}>
|
|
<X size={24} color="white" />
|
|
</Pressable>
|
|
</View>
|
|
|
|
{/* New Conversation Button */}
|
|
<View style={styles.newButtonContainer}>
|
|
<Pressable
|
|
onPress={handleNewConversation}
|
|
style={styles.newButton}
|
|
>
|
|
<Plus size={20} color="white" />
|
|
<Text style={styles.newButtonText}>New Conversation</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
{/* Conversations List */}
|
|
<ScrollView
|
|
style={styles.scrollView}
|
|
contentContainerStyle={styles.scrollContent}
|
|
keyboardShouldPersistTaps="handled"
|
|
keyboardDismissMode="on-drag"
|
|
>
|
|
{isLoading && (
|
|
<View style={styles.loadingContainer}>
|
|
<Text style={styles.loadingText}>Loading...</Text>
|
|
</View>
|
|
)}
|
|
|
|
{!isLoading && conversations.length === 0 && (
|
|
<View style={styles.emptyContainer}>
|
|
<Text style={styles.emptyText}>No saved conversations</Text>
|
|
</View>
|
|
)}
|
|
|
|
{!isLoading &&
|
|
conversations.map((conversation) => (
|
|
<View
|
|
key={conversation.id}
|
|
style={[
|
|
styles.conversationItem,
|
|
conversation.id === currentVoiceConversationId &&
|
|
styles.conversationItemActive,
|
|
]}
|
|
>
|
|
<Pressable
|
|
style={styles.conversationContent}
|
|
onPress={() =>
|
|
handleSelectConversation(conversation.id)
|
|
}
|
|
>
|
|
<Text style={styles.conversationTitle}>
|
|
{conversation.messageCount} messages
|
|
</Text>
|
|
<Text style={styles.conversationDate}>
|
|
{formatDate(conversation.lastUpdated)}
|
|
</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={() =>
|
|
handleDeleteConversation(conversation.id)
|
|
}
|
|
style={styles.deleteButton}
|
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
>
|
|
<Trash2 size={20} color="#ef4444" />
|
|
</Pressable>
|
|
</View>
|
|
))}
|
|
|
|
{!isLoading && conversations.length > 0 && (
|
|
<View style={styles.clearAllContainer}>
|
|
<Pressable
|
|
onPress={handleClearAll}
|
|
style={styles.clearAllButton}
|
|
>
|
|
<Trash2 size={18} color="#ef4444" />
|
|
<Text style={styles.clearAllText}>Clear All</Text>
|
|
</Pressable>
|
|
</View>
|
|
)}
|
|
</ScrollView>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
triggerButton: {
|
|
backgroundColor: theme.colors.surface2,
|
|
padding: theme.spacing[3],
|
|
borderRadius: theme.borderRadius.lg,
|
|
},
|
|
modalOverlay: {
|
|
flex: 1,
|
|
backgroundColor: "rgba(0,0,0,0.5)",
|
|
},
|
|
modalBackdrop: {
|
|
flex: 1,
|
|
},
|
|
modalContent: {
|
|
backgroundColor: theme.colors.surface2,
|
|
borderTopLeftRadius: theme.spacing[6],
|
|
borderTopRightRadius: theme.spacing[6],
|
|
height: "80%",
|
|
},
|
|
modalInner: {
|
|
flex: 1,
|
|
},
|
|
header: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
padding: theme.spacing[6],
|
|
borderBottomWidth: theme.borderWidth[1],
|
|
borderBottomColor: theme.colors.border,
|
|
},
|
|
headerTitle: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.lg,
|
|
fontWeight: theme.fontWeight.semibold,
|
|
},
|
|
newButtonContainer: {
|
|
padding: theme.spacing[4],
|
|
borderBottomWidth: theme.borderWidth[1],
|
|
borderBottomColor: theme.colors.border,
|
|
},
|
|
newButton: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: theme.spacing[2],
|
|
backgroundColor: theme.colors.surface2,
|
|
paddingVertical: theme.spacing[3],
|
|
borderRadius: theme.borderRadius.lg,
|
|
},
|
|
newButtonText: {
|
|
color: theme.colors.foreground,
|
|
fontWeight: theme.fontWeight.semibold,
|
|
},
|
|
scrollView: {
|
|
flex: 1,
|
|
},
|
|
scrollContent: {
|
|
paddingBottom: theme.spacing[4] * 5,
|
|
},
|
|
loadingContainer: {
|
|
padding: theme.spacing[8],
|
|
alignItems: "center",
|
|
},
|
|
loadingText: {
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
emptyContainer: {
|
|
padding: theme.spacing[8],
|
|
alignItems: "center",
|
|
},
|
|
emptyText: {
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
conversationItem: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
padding: theme.spacing[4],
|
|
borderBottomWidth: theme.borderWidth[1],
|
|
borderBottomColor: theme.colors.border,
|
|
backgroundColor: "transparent",
|
|
},
|
|
conversationItemActive: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
conversationContent: {
|
|
flex: 1,
|
|
},
|
|
conversationTitle: {
|
|
color: theme.colors.foreground,
|
|
fontWeight: theme.fontWeight.semibold,
|
|
fontSize: theme.fontSize.base,
|
|
},
|
|
conversationDate: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
marginTop: theme.spacing[1],
|
|
},
|
|
deleteButton: {
|
|
padding: theme.spacing[2],
|
|
},
|
|
clearAllContainer: {
|
|
padding: theme.spacing[4],
|
|
},
|
|
clearAllButton: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: theme.spacing[2],
|
|
backgroundColor: "rgba(127, 29, 29, 0.2)",
|
|
paddingVertical: theme.spacing[3],
|
|
borderRadius: theme.borderRadius.lg,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.palette.red[900],
|
|
},
|
|
clearAllText: {
|
|
color: theme.colors.destructive,
|
|
fontWeight: theme.fontWeight.semibold,
|
|
},
|
|
}));
|