Merge branch 'multi_prompt_local_storage'
This commit is contained in:
@@ -27,7 +27,7 @@ export const DEBUG_MODE = true;
|
||||
/**
|
||||
* Base URL for the backend API server
|
||||
*/
|
||||
export const API_BASE_URL = 'http://localhost:8000';
|
||||
export const API_BASE_URL = 'http://192.168.56.103:8000';
|
||||
|
||||
/**
|
||||
* API request timeout in milliseconds
|
||||
@@ -137,6 +137,44 @@ export const AI_CONFIG = {
|
||||
* Mock response delay in milliseconds (for NO_BACKEND_MODE)
|
||||
*/
|
||||
MOCK_RESPONSE_DELAY: 500,
|
||||
|
||||
/**
|
||||
* AI Roles configuration
|
||||
*/
|
||||
ROLES: [
|
||||
{
|
||||
id: 'reflective',
|
||||
name: 'Reflective Assistant',
|
||||
description: 'Helps you dive deep into your thoughts and feelings through meaningful reflection.',
|
||||
systemPrompt: 'You are a helpful journal assistant. Help the user reflect on their thoughts and feelings.',
|
||||
icon: 'journal-outline',
|
||||
iconFamily: 'Ionicons',
|
||||
},
|
||||
{
|
||||
id: 'creative',
|
||||
name: 'Creative Spark',
|
||||
description: 'A partner for brainstorming, creative writing, and exploring new ideas.',
|
||||
systemPrompt: 'You are a creative brainstorming partner. Help the user explore new ideas, write stories, or look at things from a fresh perspective.',
|
||||
icon: 'bulb-outline',
|
||||
iconFamily: 'Ionicons',
|
||||
},
|
||||
{
|
||||
id: 'planner',
|
||||
name: 'Action Planner',
|
||||
description: 'Focused on turning thoughts into actionable plans and organized goals.',
|
||||
systemPrompt: 'You are a productivity coach. Help the user break down their thoughts into actionable steps and clear goals.',
|
||||
icon: 'list-outline',
|
||||
iconFamily: 'Ionicons',
|
||||
},
|
||||
{
|
||||
id: 'empathetic',
|
||||
name: 'Empathetic Guide',
|
||||
description: 'Provides a safe, non-judgmental space for emotional support and empathy.',
|
||||
systemPrompt: 'You are a supportive and empathetic friend. Listen to the user\'s concerns and provide emotional support without judgment.',
|
||||
icon: 'heart-outline',
|
||||
iconFamily: 'Ionicons',
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -9,6 +9,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { User, LoginRequest, RegisterRequest } from '../types';
|
||||
import { authService } from '../services/auth.service';
|
||||
import { storageService } from '../services/storage.service';
|
||||
|
||||
// =============================================================================
|
||||
// Type Definitions
|
||||
@@ -137,24 +138,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
/**
|
||||
* Sign out and clear stored auth
|
||||
* Sign out and clear stored auth and session data
|
||||
*/
|
||||
const signOut = () => {
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
clearAuth();
|
||||
//storageService.clearAllData();
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
token,
|
||||
isLoading,
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
token,
|
||||
isLoading,
|
||||
isInitializing,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut
|
||||
signIn,
|
||||
signUp,
|
||||
signOut
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -31,6 +31,8 @@ import * as ImagePicker from 'expo-image-picker';
|
||||
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
|
||||
import { aiService } from '../services/ai.service';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { AI_CONFIG } from '../config';
|
||||
import { storageService } from '../services/storage.service';
|
||||
|
||||
// =============================================================================
|
||||
// Type Definitions
|
||||
@@ -57,16 +59,21 @@ interface ChatSession {
|
||||
// =============================================================================
|
||||
|
||||
export default function FlowScreen() {
|
||||
const { token, signOut } = useAuth();
|
||||
const { token, user, signOut } = useAuth();
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
|
||||
// Current conversation state
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [newContent, setNewContent] = useState('');
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
|
||||
|
||||
// AI Role state
|
||||
const [selectedRole, setSelectedRole] = useState(AI_CONFIG.ROLES[0]);
|
||||
const [showRoleModal, setShowRoleModal] = useState(false);
|
||||
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
|
||||
|
||||
// History modal state
|
||||
const [showHistoryModal, setShowHistoryModal] = useState(false);
|
||||
const modalSlideAnim = useRef(new Animated.Value(0)).current;
|
||||
@@ -95,23 +102,87 @@ export default function FlowScreen() {
|
||||
updatedAt: new Date('2024-01-16T20:30:00'),
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
// Header date display
|
||||
const today = new Date();
|
||||
const dateStr = today.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
const dateStr = today.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
// Load history on mount
|
||||
useEffect(() => {
|
||||
const loadHistory = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
console.log('[FlowScreen] Loading chat history...');
|
||||
const savedHistory = await storageService.getChatHistory(user.id);
|
||||
if (savedHistory && savedHistory.length > 0) {
|
||||
const formattedHistory = savedHistory.map((session: any) => ({
|
||||
...session,
|
||||
createdAt: new Date(session.createdAt),
|
||||
updatedAt: new Date(session.updatedAt),
|
||||
messages: session.messages.map((msg: any) => ({
|
||||
...msg,
|
||||
createdAt: new Date(msg.createdAt)
|
||||
}))
|
||||
}));
|
||||
setChatHistory(formattedHistory);
|
||||
console.log('[FlowScreen] Chat history loaded:', formattedHistory.length, 'sessions');
|
||||
} else {
|
||||
console.log('[FlowScreen] No chat history found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load history:', error);
|
||||
}
|
||||
};
|
||||
loadHistory();
|
||||
}, [user]);
|
||||
|
||||
// Load messages whenever role changes
|
||||
useEffect(() => {
|
||||
const loadRoleMessages = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const savedMessages = await storageService.getCurrentChat(selectedRole.id, user.id);
|
||||
if (savedMessages) {
|
||||
const formattedMessages = savedMessages.map((msg: any) => ({
|
||||
...msg,
|
||||
createdAt: new Date(msg.createdAt)
|
||||
}));
|
||||
setMessages(formattedMessages);
|
||||
} else {
|
||||
setMessages([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load messages for role ${selectedRole.id}:`, error);
|
||||
setMessages([]);
|
||||
}
|
||||
};
|
||||
|
||||
loadRoleMessages();
|
||||
}, [selectedRole.id, user]);
|
||||
|
||||
// Save current messages for the active role when they change
|
||||
useEffect(() => {
|
||||
if (user && messages.length >= 0) { // Save even if empty to allow clearing
|
||||
storageService.saveCurrentChat(selectedRole.id, messages, user.id);
|
||||
}
|
||||
|
||||
if (messages.length > 0) {
|
||||
setTimeout(() => {
|
||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||
}, 100);
|
||||
}
|
||||
}, [messages]);
|
||||
}, [messages, selectedRole.id, user]);
|
||||
|
||||
// Save history when it changes
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
storageService.saveChatHistory(chatHistory, user.id);
|
||||
}
|
||||
}, [chatHistory, user]);
|
||||
|
||||
// Modal animation control
|
||||
const openHistoryModal = () => {
|
||||
@@ -143,7 +214,7 @@ export default function FlowScreen() {
|
||||
*/
|
||||
const handleSendMessage = async () => {
|
||||
if (!newContent.trim() || isSending) return;
|
||||
|
||||
|
||||
// Check authentication
|
||||
if (!token) {
|
||||
Alert.alert(
|
||||
@@ -153,11 +224,11 @@ export default function FlowScreen() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const userMessage = newContent.trim();
|
||||
setIsSending(true);
|
||||
setNewContent('');
|
||||
|
||||
|
||||
// Add user message immediately
|
||||
const userMsg: ChatMessage = {
|
||||
id: Date.now().toString(),
|
||||
@@ -166,11 +237,11 @@ export default function FlowScreen() {
|
||||
createdAt: new Date(),
|
||||
};
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
|
||||
|
||||
try {
|
||||
// Call AI proxy
|
||||
const aiResponse = await aiService.sendMessage(userMessage, token);
|
||||
|
||||
// Call AI proxy with selected role's system prompt
|
||||
const aiResponse = await aiService.sendMessage(userMessage, token, selectedRole.systemPrompt);
|
||||
|
||||
// Add AI response
|
||||
const aiMsg: ChatMessage = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
@@ -179,20 +250,20 @@ export default function FlowScreen() {
|
||||
createdAt: new Date(),
|
||||
};
|
||||
setMessages(prev => [...prev, aiMsg]);
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('AI request failed:', error);
|
||||
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
|
||||
// Handle authentication errors (401, credentials, unauthorized)
|
||||
const isAuthError =
|
||||
errorMessage.includes('401') ||
|
||||
const isAuthError =
|
||||
errorMessage.includes('401') ||
|
||||
errorMessage.includes('credentials') ||
|
||||
errorMessage.includes('Unauthorized') ||
|
||||
errorMessage.includes('Not authenticated') ||
|
||||
errorMessage.includes('validate');
|
||||
|
||||
|
||||
if (isAuthError) {
|
||||
signOut();
|
||||
Alert.alert(
|
||||
@@ -202,7 +273,7 @@ export default function FlowScreen() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Show error as AI message
|
||||
const errorMsg: ChatMessage = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
@@ -246,7 +317,7 @@ export default function FlowScreen() {
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
const imageAsset = result.assets[0];
|
||||
setSelectedImage(imageAsset.uri);
|
||||
|
||||
|
||||
// Check authentication
|
||||
if (!token) {
|
||||
Alert.alert(
|
||||
@@ -289,9 +360,9 @@ export default function FlowScreen() {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
// Handle authentication errors
|
||||
const isAuthError =
|
||||
errorMessage.includes('401') ||
|
||||
errorMessage.includes('Unauthorized') ||
|
||||
const isAuthError =
|
||||
errorMessage.includes('401') ||
|
||||
errorMessage.includes('Unauthorized') ||
|
||||
errorMessage.includes('credentials') ||
|
||||
errorMessage.includes('validate');
|
||||
|
||||
@@ -334,9 +405,12 @@ export default function FlowScreen() {
|
||||
};
|
||||
setChatHistory(prev => [newSession, ...prev]);
|
||||
}
|
||||
|
||||
// Clear current messages
|
||||
|
||||
// Clear current messages and storage for this role
|
||||
setMessages([]);
|
||||
if (user) {
|
||||
storageService.saveCurrentChat(selectedRole.id, [], user.id);
|
||||
}
|
||||
closeHistoryModal();
|
||||
};
|
||||
|
||||
@@ -355,7 +429,7 @@ export default function FlowScreen() {
|
||||
};
|
||||
setChatHistory(prev => [currentSession, ...prev.filter(s => s.id !== session.id)]);
|
||||
}
|
||||
|
||||
|
||||
// Load selected conversation
|
||||
setMessages(session.messages);
|
||||
closeHistoryModal();
|
||||
@@ -370,8 +444,8 @@ export default function FlowScreen() {
|
||||
'Are you sure you want to delete this conversation?',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: () => setChatHistory(prev => prev.filter(s => s.id !== sessionId))
|
||||
},
|
||||
@@ -400,10 +474,10 @@ export default function FlowScreen() {
|
||||
*/
|
||||
const renderMessage = (message: ChatMessage, index: number) => {
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
|
||||
return (
|
||||
<View
|
||||
key={message.id}
|
||||
<View
|
||||
key={message.id}
|
||||
style={[
|
||||
styles.messageBubble,
|
||||
isUser ? styles.userBubble : styles.aiBubble
|
||||
@@ -420,8 +494,8 @@ export default function FlowScreen() {
|
||||
]}>
|
||||
{/* Show image if present */}
|
||||
{message.imageUri && (
|
||||
<Image
|
||||
source={{ uri: message.imageUri }}
|
||||
<Image
|
||||
source={{ uri: message.imageUri }}
|
||||
style={styles.messageImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
@@ -451,9 +525,9 @@ export default function FlowScreen() {
|
||||
<View style={styles.emptyIcon}>
|
||||
<Feather name="feather" size={48} color={colors.nautical.seafoam} />
|
||||
</View>
|
||||
<Text style={styles.emptyTitle}>Start a conversation</Text>
|
||||
<Text style={styles.emptyTitle}>Chatting with {selectedRole.name}</Text>
|
||||
<Text style={styles.emptySubtitle}>
|
||||
Ask me anything or share your thoughts
|
||||
{selectedRole.description}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -462,7 +536,7 @@ export default function FlowScreen() {
|
||||
* Render history item in modal
|
||||
*/
|
||||
const renderHistoryItem = ({ item }: { item: ChatSession }) => (
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={styles.historyItem}
|
||||
onPress={() => handleLoadHistory(item)}
|
||||
onLongPress={() => handleDeleteHistory(item.id)}
|
||||
@@ -504,9 +578,26 @@ export default function FlowScreen() {
|
||||
<Text style={styles.headerDate}>{dateStr}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
{/* Role Header Dropdown */}
|
||||
<TouchableOpacity
|
||||
style={styles.headerRoleButton}
|
||||
onPress={() => setShowRoleModal(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons
|
||||
name={selectedRole.icon as any}
|
||||
size={16}
|
||||
color={colors.nautical.teal}
|
||||
/>
|
||||
<Text style={styles.headerRoleText} numberOfLines={1}>
|
||||
{selectedRole.name}
|
||||
</Text>
|
||||
<Ionicons name="chevron-down" size={14} color={colors.flow.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* History Button */}
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={styles.historyButton}
|
||||
onPress={openHistoryModal}
|
||||
>
|
||||
@@ -515,7 +606,7 @@ export default function FlowScreen() {
|
||||
</View>
|
||||
|
||||
{/* Chat Messages */}
|
||||
<ScrollView
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={styles.messagesContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -529,7 +620,7 @@ export default function FlowScreen() {
|
||||
) : (
|
||||
messages.map((message, index) => renderMessage(message, index))
|
||||
)}
|
||||
|
||||
|
||||
{/* Loading indicator when sending */}
|
||||
{isSending && (
|
||||
<View style={[styles.messageBubble, styles.aiBubble]}>
|
||||
@@ -541,7 +632,7 @@ export default function FlowScreen() {
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
<View style={{ height: 20 }} />
|
||||
</ScrollView>
|
||||
|
||||
@@ -549,7 +640,7 @@ export default function FlowScreen() {
|
||||
<View style={styles.inputBarContainer}>
|
||||
<View style={styles.inputBar}>
|
||||
{/* Image attachment button */}
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={styles.inputBarButton}
|
||||
onPress={handleAddImage}
|
||||
activeOpacity={0.7}
|
||||
@@ -572,7 +663,7 @@ export default function FlowScreen() {
|
||||
|
||||
{/* Send or Voice button */}
|
||||
{newContent.trim() || isSending ? (
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={[styles.sendButton, isSending && styles.sendButtonDisabled]}
|
||||
onPress={handleSendMessage}
|
||||
activeOpacity={0.8}
|
||||
@@ -590,15 +681,15 @@ export default function FlowScreen() {
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={[styles.inputBarButton, isRecording && styles.recordingButton]}
|
||||
onPress={handleVoiceRecord}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Feather
|
||||
name="mic"
|
||||
size={22}
|
||||
color={isRecording ? '#fff' : colors.flow.textSecondary}
|
||||
<Feather
|
||||
name="mic"
|
||||
size={22}
|
||||
color={isRecording ? '#fff' : colors.flow.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
@@ -607,17 +698,16 @@ export default function FlowScreen() {
|
||||
</SafeAreaView>
|
||||
</LinearGradient>
|
||||
|
||||
{/* History Modal - Background appears instantly, content slides up */}
|
||||
<Modal
|
||||
visible={showHistoryModal}
|
||||
<Modal
|
||||
visible={showHistoryModal}
|
||||
animationType="none"
|
||||
transparent
|
||||
transparent
|
||||
onRequestClose={closeHistoryModal}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={closeHistoryModal}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<TouchableWithoutFeedback onPress={(e) => e.stopPropagation()}>
|
||||
<Animated.View
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.modalContent,
|
||||
{
|
||||
@@ -630,46 +720,129 @@ export default function FlowScreen() {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<View style={styles.modalHandle} />
|
||||
|
||||
{/* Modal Header */}
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Chat History</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.newChatButton}
|
||||
onPress={handleNewChat}
|
||||
>
|
||||
<Ionicons name="add" size={20} color="#fff" />
|
||||
<Text style={styles.newChatText}>New Chat</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* History List */}
|
||||
<FlatList
|
||||
data={chatHistory}
|
||||
renderItem={renderHistoryItem}
|
||||
keyExtractor={item => item.id}
|
||||
style={styles.historyList}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.historyEmpty}>
|
||||
<Ionicons name="chatbubbles-outline" size={48} color={colors.flow.textSecondary} />
|
||||
<Text style={styles.historyEmptyText}>No chat history yet</Text>
|
||||
<View style={styles.modalHandle} />
|
||||
|
||||
{/* Modal Header */}
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Chat History</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.newChatButton}
|
||||
onPress={handleNewChat}
|
||||
>
|
||||
<Ionicons name="add" size={20} color="#fff" />
|
||||
<Text style={styles.newChatText}>New Chat</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Close Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={closeHistoryModal}
|
||||
>
|
||||
<Text style={styles.closeButtonText}>Close</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* History List */}
|
||||
<FlatList
|
||||
data={chatHistory}
|
||||
renderItem={renderHistoryItem}
|
||||
keyExtractor={item => item.id}
|
||||
style={styles.historyList}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.historyEmpty}>
|
||||
<Ionicons name="chatbubbles-outline" size={48} color={colors.flow.textSecondary} />
|
||||
<Text style={styles.historyEmptyText}>No chat history yet</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Close Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={closeHistoryModal}
|
||||
>
|
||||
<Text style={styles.closeButtonText}>Close</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Modal>
|
||||
|
||||
{/* Role Selection Modal */}
|
||||
<Modal
|
||||
visible={showRoleModal}
|
||||
animationType="fade"
|
||||
transparent
|
||||
onRequestClose={() => setShowRoleModal(false)}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={() => setShowRoleModal(false)}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<TouchableWithoutFeedback onPress={(e) => e.stopPropagation()}>
|
||||
<View style={[styles.modalContent, styles.roleModalContent]}>
|
||||
<View style={styles.modalHandle} />
|
||||
<Text style={styles.modalTitle}>Choose AI Assistant</Text>
|
||||
|
||||
<ScrollView style={styles.roleList} showsVerticalScrollIndicator={false}>
|
||||
{AI_CONFIG.ROLES.map((role) => (
|
||||
<View key={role.id} style={styles.roleItemContainer}>
|
||||
<View
|
||||
style={[
|
||||
styles.roleItem,
|
||||
selectedRole.id === role.id && styles.roleItemActive
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.roleSelectionArea}
|
||||
onPress={() => {
|
||||
setSelectedRole(role as any);
|
||||
setShowRoleModal(false);
|
||||
}}
|
||||
>
|
||||
<View style={[
|
||||
styles.roleItemIcon,
|
||||
selectedRole.id === role.id && styles.roleItemIconActive
|
||||
]}>
|
||||
<Ionicons
|
||||
name={role.icon as any}
|
||||
size={20}
|
||||
color={selectedRole.id === role.id ? '#fff' : colors.nautical.teal}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[
|
||||
styles.roleItemName,
|
||||
selectedRole.id === role.id && styles.roleItemNameActive
|
||||
]}>
|
||||
{role.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.infoButton}
|
||||
onPress={() => {
|
||||
setExpandedRoleId(expandedRoleId === role.id ? null : role.id);
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={expandedRoleId === role.id ? "close-circle-outline" : "information-circle-outline"}
|
||||
size={24}
|
||||
color={expandedRoleId === role.id ? colors.nautical.coral : colors.flow.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{expandedRoleId === role.id && (
|
||||
<View style={styles.roleDescription}>
|
||||
<Text style={styles.roleDescriptionText}>{role.description}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={() => setShowRoleModal(false)}
|
||||
>
|
||||
<Text style={styles.closeButtonText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -680,16 +853,16 @@ export default function FlowScreen() {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// Container styles
|
||||
container: {
|
||||
flex: 1
|
||||
container: {
|
||||
flex: 1
|
||||
},
|
||||
gradient: {
|
||||
flex: 1
|
||||
gradient: {
|
||||
flex: 1
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1
|
||||
safeArea: {
|
||||
flex: 1
|
||||
},
|
||||
|
||||
|
||||
// Header styles
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
@@ -697,12 +870,33 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.base,
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.md,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(0,0,0,0.05)',
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
flex: 1,
|
||||
},
|
||||
headerRoleButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.flow.cardBackground,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 6,
|
||||
borderRadius: borderRadius.full,
|
||||
marginHorizontal: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.flow.cardBorder,
|
||||
maxWidth: '40%',
|
||||
},
|
||||
headerRoleText: {
|
||||
fontSize: typography.fontSize.xs,
|
||||
fontWeight: '600',
|
||||
color: colors.flow.text,
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
iconCircle: {
|
||||
width: 44,
|
||||
@@ -731,20 +925,20 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
...shadows.soft,
|
||||
},
|
||||
|
||||
|
||||
// Messages container styles
|
||||
messagesContainer: {
|
||||
flex: 1
|
||||
messagesContainer: {
|
||||
flex: 1
|
||||
},
|
||||
messagesContent: {
|
||||
padding: spacing.base,
|
||||
paddingTop: 0
|
||||
messagesContent: {
|
||||
padding: spacing.base,
|
||||
paddingTop: 0
|
||||
},
|
||||
emptyContent: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
|
||||
// Empty state styles
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
@@ -772,7 +966,97 @@ const styles = StyleSheet.create({
|
||||
color: colors.flow.textSecondary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
|
||||
|
||||
// Role selection styles
|
||||
roleDropdown: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.flow.cardBackground,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginBottom: spacing.md,
|
||||
...shadows.soft,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.flow.cardBorder,
|
||||
},
|
||||
roleIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
roleDropdownText: {
|
||||
fontSize: typography.fontSize.base,
|
||||
fontWeight: '600',
|
||||
color: colors.flow.text,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
roleModalContent: {
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
roleList: {
|
||||
marginTop: spacing.sm,
|
||||
maxHeight: 400,
|
||||
},
|
||||
roleItemContainer: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
roleItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
roleItemActive: {
|
||||
backgroundColor: colors.nautical.paleAqua,
|
||||
borderColor: colors.nautical.lightMint,
|
||||
},
|
||||
roleSelectionArea: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: spacing.md,
|
||||
},
|
||||
roleItemIcon: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: colors.flow.backgroundGradientStart,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
roleItemIconActive: {
|
||||
backgroundColor: colors.nautical.teal,
|
||||
},
|
||||
roleItemName: {
|
||||
fontSize: typography.fontSize.base,
|
||||
fontWeight: '500',
|
||||
color: colors.flow.text,
|
||||
},
|
||||
roleItemNameActive: {
|
||||
fontWeight: '700',
|
||||
color: colors.nautical.teal,
|
||||
},
|
||||
infoButton: {
|
||||
padding: spacing.md,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
roleDescription: {
|
||||
paddingHorizontal: spacing.md + 36 + spacing.md, // icon width + margins
|
||||
paddingBottom: spacing.sm,
|
||||
paddingTop: 0,
|
||||
},
|
||||
roleDescriptionText: {
|
||||
fontSize: typography.fontSize.sm,
|
||||
color: colors.flow.textSecondary,
|
||||
fontStyle: 'italic',
|
||||
lineHeight: 18,
|
||||
},
|
||||
|
||||
// Message bubble styles
|
||||
messageBubble: {
|
||||
flexDirection: 'row',
|
||||
@@ -837,7 +1121,7 @@ const styles = StyleSheet.create({
|
||||
aiMessageTime: {
|
||||
color: colors.flow.textSecondary,
|
||||
},
|
||||
|
||||
|
||||
// Input bar styles
|
||||
inputBarContainer: {
|
||||
paddingHorizontal: spacing.base,
|
||||
@@ -893,7 +1177,7 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
|
||||
// Modal styles
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
@@ -941,7 +1225,7 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
|
||||
|
||||
// History list styles
|
||||
historyList: {
|
||||
flex: 1,
|
||||
|
||||
@@ -222,7 +222,8 @@ export default function MeScreen() {
|
||||
const [showHeritageModal, setShowHeritageModal] = useState(false);
|
||||
const [showThemeModal, setShowThemeModal] = useState(false);
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
const [showSignOutModal, setShowSignOutModal] = useState(false);
|
||||
|
||||
// Heritage / Fleet Legacy states
|
||||
const [heirs, setHeirs] = useState<Heir[]>(initialHeirs);
|
||||
const [showAddHeirModal, setShowAddHeirModal] = useState(false);
|
||||
@@ -296,18 +297,14 @@ export default function MeScreen() {
|
||||
};
|
||||
|
||||
const handleAbandonIsland = () => {
|
||||
Alert.alert(
|
||||
'Sign Out',
|
||||
'Are you sure you want to sign out?',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Sign Out',
|
||||
style: 'destructive',
|
||||
onPress: signOut
|
||||
},
|
||||
]
|
||||
);
|
||||
console.log('[MeScreen] Sign out button clicked');
|
||||
setShowSignOutModal(true);
|
||||
};
|
||||
|
||||
const handleConfirmSignOut = () => {
|
||||
console.log('[MeScreen] User confirmed sign out');
|
||||
setShowSignOutModal(false);
|
||||
signOut();
|
||||
};
|
||||
|
||||
const handleResetVault = async () => {
|
||||
@@ -632,10 +629,10 @@ export default function MeScreen() {
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}>
|
||||
<Ionicons
|
||||
name={isDarkMode ? 'moon' : 'sunny'}
|
||||
size={18}
|
||||
color={colors.me.primary}
|
||||
<Ionicons
|
||||
name={isDarkMode ? 'moon' : 'sunny'}
|
||||
size={18}
|
||||
color={colors.me.primary}
|
||||
/>
|
||||
<Text style={styles.sanctumText}>Dark Mode</Text>
|
||||
</View>
|
||||
@@ -1441,6 +1438,46 @@ export default function MeScreen() {
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* Sign Out Confirmation Modal */}
|
||||
<Modal
|
||||
visible={showSignOutModal}
|
||||
animationType="fade"
|
||||
transparent
|
||||
onRequestClose={() => setShowSignOutModal(false)}
|
||||
>
|
||||
<View style={styles.spiritOverlay}>
|
||||
<View style={styles.signOutModal}>
|
||||
<View style={styles.signOutHeader}>
|
||||
<View style={styles.signOutIcon}>
|
||||
<Feather name="log-out" size={32} color={colors.nautical.coral} />
|
||||
</View>
|
||||
<Text style={styles.signOutTitle}>Sign Out</Text>
|
||||
<Text style={styles.signOutMessage}>
|
||||
Are you sure you want to sign out? You'll need to log in again to access your account.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.signOutButtons}>
|
||||
<TouchableOpacity
|
||||
style={styles.signOutCancelButton}
|
||||
onPress={() => setShowSignOutModal(false)}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={styles.signOutCancelText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.signOutConfirmButton}
|
||||
onPress={handleConfirmSignOut}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={styles.signOutConfirmText}>Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -2379,4 +2416,71 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
// Sign Out Modal Styles
|
||||
signOutModal: {
|
||||
backgroundColor: colors.me.cardBackground,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.xl,
|
||||
marginHorizontal: spacing.xl,
|
||||
maxWidth: 400,
|
||||
width: '100%',
|
||||
...shadows.medium,
|
||||
},
|
||||
signOutHeader: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
signOutIcon: {
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
backgroundColor: `${colors.nautical.coral}15`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.base,
|
||||
},
|
||||
signOutTitle: {
|
||||
fontSize: typography.fontSize.xl,
|
||||
fontWeight: '700',
|
||||
color: colors.me.text,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
signOutMessage: {
|
||||
fontSize: typography.fontSize.base,
|
||||
color: colors.me.textSecondary,
|
||||
textAlign: 'center',
|
||||
lineHeight: typography.fontSize.base * 1.5,
|
||||
},
|
||||
signOutButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
},
|
||||
signOutCancelButton: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.base,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.me.cardBorder,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
signOutCancelText: {
|
||||
fontSize: typography.fontSize.base,
|
||||
fontWeight: '600',
|
||||
color: colors.me.text,
|
||||
},
|
||||
signOutConfirmButton: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.base,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.nautical.coral,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
signOutConfirmText: {
|
||||
fontSize: typography.fontSize.base,
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ export const aiService = {
|
||||
}
|
||||
|
||||
const url = buildApiUrl(API_ENDPOINTS.AI.PROXY);
|
||||
|
||||
|
||||
logApiDebug('AI Request', {
|
||||
url,
|
||||
hasToken: !!token,
|
||||
@@ -114,7 +114,7 @@ export const aiService = {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logApiDebug('AI Error Response', errorText);
|
||||
|
||||
|
||||
let errorDetail = 'AI request failed';
|
||||
try {
|
||||
const errorData = JSON.parse(errorText);
|
||||
@@ -131,7 +131,7 @@ export const aiService = {
|
||||
model: data.model,
|
||||
choicesCount: data.choices?.length,
|
||||
});
|
||||
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('AI proxy error:', error);
|
||||
@@ -143,13 +143,14 @@ export const aiService = {
|
||||
* Simple helper for single message chat
|
||||
* @param content - User message content
|
||||
* @param token - JWT token for authentication
|
||||
* @param systemPrompt - Optional custom system prompt
|
||||
* @returns AI response text
|
||||
*/
|
||||
async sendMessage(content: string, token?: string): Promise<string> {
|
||||
async sendMessage(content: string, token?: string, systemPrompt?: string): Promise<string> {
|
||||
const messages: AIMessage[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content: AI_CONFIG.DEFAULT_SYSTEM_PROMPT,
|
||||
content: systemPrompt || AI_CONFIG.DEFAULT_SYSTEM_PROMPT,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
@@ -179,7 +180,7 @@ export const aiService = {
|
||||
}
|
||||
|
||||
const url = buildApiUrl(API_ENDPOINTS.AI.PROXY);
|
||||
|
||||
|
||||
logApiDebug('AI Image Request', {
|
||||
url,
|
||||
hasToken: !!token,
|
||||
@@ -217,7 +218,7 @@ export const aiService = {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logApiDebug('AI Image Error Response', errorText);
|
||||
|
||||
|
||||
let errorDetail = 'AI image request failed';
|
||||
try {
|
||||
const errorData = JSON.parse(errorText);
|
||||
@@ -233,7 +234,7 @@ export const aiService = {
|
||||
id: data.id,
|
||||
model: data.model,
|
||||
});
|
||||
|
||||
|
||||
return data.choices[0]?.message?.content || 'No response';
|
||||
} catch (error) {
|
||||
console.error('AI image proxy error:', error);
|
||||
|
||||
120
src/services/storage.service.ts
Normal file
120
src/services/storage.service.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Storage Service
|
||||
*
|
||||
* Handles local persistence of chat history and active conversations
|
||||
* using AsyncStorage with user-specific isolation.
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
CHAT_HISTORY: '@sentinel:chat_history',
|
||||
CURRENT_MESSAGES: '@sentinel:current_messages',
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// Service Implementation
|
||||
// =============================================================================
|
||||
|
||||
export const storageService = {
|
||||
/**
|
||||
* Get user-specific storage key
|
||||
*/
|
||||
getUserKey(baseKey: string, userId: string | number): string {
|
||||
return `${baseKey}:user_${userId}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Save the complete list of chat sessions to local storage for a specific user
|
||||
*/
|
||||
async saveChatHistory(history: any[], userId: string | number): Promise<void> {
|
||||
try {
|
||||
const jsonValue = JSON.stringify(history);
|
||||
const key = this.getUserKey(STORAGE_KEYS.CHAT_HISTORY, userId);
|
||||
await AsyncStorage.setItem(key, jsonValue);
|
||||
console.log(`[Storage] Saved chat history for user ${userId}:`, history.length, 'sessions');
|
||||
} catch (e) {
|
||||
console.error('Error saving chat history:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load the list of chat sessions from local storage for a specific user
|
||||
*/
|
||||
async getChatHistory(userId: string | number): Promise<any[]> {
|
||||
try {
|
||||
const key = this.getUserKey(STORAGE_KEYS.CHAT_HISTORY, userId);
|
||||
const jsonValue = await AsyncStorage.getItem(key);
|
||||
const result = jsonValue != null ? JSON.parse(jsonValue) : [];
|
||||
console.log(`[Storage] Loaded chat history for user ${userId}:`, result.length, 'sessions');
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error('Error getting chat history:', e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Save the current active conversation messages for a specific role and user
|
||||
*/
|
||||
async saveCurrentChat(roleId: string, messages: any[], userId: string | number): Promise<void> {
|
||||
try {
|
||||
const jsonValue = JSON.stringify(messages);
|
||||
const key = `${this.getUserKey(STORAGE_KEYS.CURRENT_MESSAGES, userId)}:${roleId}`;
|
||||
await AsyncStorage.setItem(key, jsonValue);
|
||||
console.log(`[Storage] Saved current chat for user ${userId}, role ${roleId}:`, messages.length, 'messages');
|
||||
} catch (e) {
|
||||
console.error(`Error saving current chat for role ${roleId}:`, e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load the current active conversation messages for a specific role and user
|
||||
*/
|
||||
async getCurrentChat(roleId: string, userId: string | number): Promise<any[]> {
|
||||
try {
|
||||
const key = `${this.getUserKey(STORAGE_KEYS.CURRENT_MESSAGES, userId)}:${roleId}`;
|
||||
const jsonValue = await AsyncStorage.getItem(key);
|
||||
const result = jsonValue != null ? JSON.parse(jsonValue) : [];
|
||||
console.log(`[Storage] Loaded current chat for user ${userId}, role ${roleId}:`, result.length, 'messages');
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error(`Error getting current chat for role ${roleId}:`, e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all stored chat data for a specific user
|
||||
*/
|
||||
async clearUserData(userId: string | number): Promise<void> {
|
||||
try {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const userPrefix = `:user_${userId}`;
|
||||
const userKeys = keys.filter(key => key.includes(userPrefix));
|
||||
await AsyncStorage.multiRemove(userKeys);
|
||||
console.log(`[Storage] Cleared all data for user ${userId}:`, userKeys.length, 'keys removed');
|
||||
} catch (e) {
|
||||
console.error('Error clearing user data:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all stored chat data (all users)
|
||||
*/
|
||||
async clearAllData(): Promise<void> {
|
||||
try {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const sentinelKeys = keys.filter(key => key.startsWith('@sentinel:'));
|
||||
await AsyncStorage.multiRemove(sentinelKeys);
|
||||
console.log('[Storage] Cleared all data:', sentinelKeys.length, 'keys removed');
|
||||
} catch (e) {
|
||||
console.error('Error clearing storage data:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user