Initial commit: Prism messenger with Expo + NestJS + GraphQL + PostgreSQL

This commit is contained in:
Bivekich
2025-08-06 02:19:37 +03:00
commit 6fb83334d6
56 changed files with 24295 additions and 0 deletions

View File

@ -0,0 +1,80 @@
import React, { createContext, useState, useContext, useEffect, ReactNode } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { User } from '../types';
interface AuthContextType {
user: User | null;
token: string | null;
isLoading: boolean;
login: (token: string, user: User) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadStoredAuth();
}, []);
const loadStoredAuth = async () => {
try {
const storedToken = await AsyncStorage.getItem('token');
const storedUser = await AsyncStorage.getItem('user');
if (storedToken && storedUser) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
}
} catch (error) {
console.error('Error loading auth:', error);
} finally {
setIsLoading(false);
}
};
const login = async (newToken: string, newUser: User) => {
try {
await AsyncStorage.setItem('token', newToken);
await AsyncStorage.setItem('user', JSON.stringify(newUser));
setToken(newToken);
setUser(newUser);
} catch (error) {
console.error('Error storing auth:', error);
throw error;
}
};
const logout = async () => {
try {
await AsyncStorage.removeItem('token');
await AsyncStorage.removeItem('user');
setToken(null);
setUser(null);
} catch (error) {
console.error('Error clearing auth:', error);
}
};
return (
<AuthContext.Provider value={{ user, token, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
);
};

View File

@ -0,0 +1,90 @@
import { gql } from '@apollo/client';
import { USER_FRAGMENT, MESSAGE_FRAGMENT, CONVERSATION_FRAGMENT } from './queries';
// Auth mutations
export const LOGIN = gql`
mutation Login($username: String!, $password: String!) {
login(username: $username, password: $password) {
access_token
user {
...UserFragment
}
}
}
${USER_FRAGMENT}
`;
export const REGISTER = gql`
mutation Register($username: String!, $email: String!, $password: String!) {
register(username: $username, email: $email, password: $password) {
access_token
user {
...UserFragment
}
}
}
${USER_FRAGMENT}
`;
// User mutations
export const UPDATE_USER = gql`
mutation UpdateUser($bio: String, $avatar: String) {
updateUser(bio: $bio, avatar: $avatar) {
...UserFragment
}
}
${USER_FRAGMENT}
`;
// Conversation mutations
export const CREATE_PRIVATE_CONVERSATION = gql`
mutation CreatePrivateConversation($recipientId: ID!) {
createPrivateConversation(recipientId: $recipientId) {
...ConversationFragment
}
}
${CONVERSATION_FRAGMENT}
`;
export const CREATE_CONVERSATION = gql`
mutation CreateConversation($participantIds: [ID!]!, $name: String) {
createConversation(participantIds: $participantIds, name: $name) {
...ConversationFragment
}
}
${CONVERSATION_FRAGMENT}
`;
// Message mutations
export const SEND_MESSAGE = gql`
mutation SendMessage($conversationId: ID!, $content: String!) {
sendMessage(conversationId: $conversationId, content: $content) {
...MessageFragment
}
}
${MESSAGE_FRAGMENT}
`;
export const UPDATE_MESSAGE = gql`
mutation UpdateMessage($messageId: ID!, $content: String!) {
updateMessage(messageId: $messageId, content: $content) {
...MessageFragment
}
}
${MESSAGE_FRAGMENT}
`;
export const DELETE_MESSAGE = gql`
mutation DeleteMessage($messageId: ID!) {
deleteMessage(messageId: $messageId)
}
`;
export const MARK_MESSAGE_AS_READ = gql`
mutation MarkMessageAsRead($messageId: ID!) {
markMessageAsRead(messageId: $messageId) {
...MessageFragment
}
}
${MESSAGE_FRAGMENT}
`;

View File

@ -0,0 +1,96 @@
import { gql } from '@apollo/client';
// Fragments
export const USER_FRAGMENT = gql`
fragment UserFragment on User {
id
username
email
avatar
bio
isOnline
lastSeen
}
`;
export const MESSAGE_FRAGMENT = gql`
fragment MessageFragment on Message {
id
content
isRead
isEdited
editedAt
createdAt
sender {
...UserFragment
}
}
${USER_FRAGMENT}
`;
export const CONVERSATION_FRAGMENT = gql`
fragment ConversationFragment on Conversation {
id
name
isGroup
createdAt
participants {
...UserFragment
}
}
${USER_FRAGMENT}
`;
// Queries
export const GET_CONVERSATIONS = gql`
query GetConversations {
conversations {
...ConversationFragment
lastMessage {
...MessageFragment
}
}
}
${CONVERSATION_FRAGMENT}
${MESSAGE_FRAGMENT}
`;
export const GET_CONVERSATION = gql`
query GetConversation($id: ID!) {
conversation(id: $id) {
...ConversationFragment
messages {
...MessageFragment
}
}
}
${CONVERSATION_FRAGMENT}
${MESSAGE_FRAGMENT}
`;
export const GET_MESSAGES = gql`
query GetMessages($conversationId: ID!) {
messages(conversationId: $conversationId) {
...MessageFragment
}
}
${MESSAGE_FRAGMENT}
`;
export const GET_USERS = gql`
query GetUsers {
users {
...UserFragment
}
}
${USER_FRAGMENT}
`;
export const GET_ME = gql`
query GetMe {
me {
...UserFragment
}
}
${USER_FRAGMENT}
`;

View File

@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
import { MESSAGE_FRAGMENT } from './queries';
export const MESSAGE_ADDED = gql`
subscription MessageAdded($conversationId: ID!) {
messageAdded(conversationId: $conversationId) {
...MessageFragment
}
}
${MESSAGE_FRAGMENT}
`;

View File

@ -0,0 +1,68 @@
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { useAuth } from '../contexts/AuthContext';
import { LoginScreen } from '../screens/LoginScreen';
import { RegisterScreen } from '../screens/RegisterScreen';
import { ConversationsScreen } from '../screens/ConversationsScreen';
import { ChatScreen } from '../screens/ChatScreen';
import { ActivityIndicator, View } from 'react-native';
const Stack = createNativeStackNavigator();
export const AppNavigator = () => {
const { user, isLoading } = useAuth();
if (isLoading) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
return (
<NavigationContainer>
<Stack.Navigator>
{user ? (
<>
<Stack.Screen
name="Conversations"
component={ConversationsScreen}
options={{
title: 'Чаты',
headerLargeTitle: true,
}}
/>
<Stack.Screen
name="Chat"
component={ChatScreen}
options={({ route }) => ({
title: route.params?.title || 'Чат',
})}
/>
</>
) : (
<>
<Stack.Screen
name="Login"
component={LoginScreen}
options={{
title: 'Вход',
headerShown: false,
}}
/>
<Stack.Screen
name="Register"
component={RegisterScreen}
options={{
title: 'Регистрация',
headerShown: false,
}}
/>
</>
)}
</Stack.Navigator>
</NavigationContainer>
);
};

View File

@ -0,0 +1,263 @@
import React, { useState, useRef, useEffect } from 'react';
import { View, StyleSheet, FlatList, KeyboardAvoidingView, Platform } from 'react-native';
import { TextInput, IconButton, Text, Avatar, Surface, Menu } from 'react-native-paper';
import { useQuery, useMutation, useSubscription } from '@apollo/client';
import { GET_MESSAGES } from '../graphql/queries';
import { SEND_MESSAGE, DELETE_MESSAGE } from '../graphql/mutations';
import { MESSAGE_ADDED } from '../graphql/subscriptions';
import { Message } from '../types';
import { useAuth } from '../contexts/AuthContext';
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
export const ChatScreen = ({ route }: any) => {
const { conversationId, title } = route.params;
const { user } = useAuth();
const [message, setMessage] = useState('');
const [selectedMessage, setSelectedMessage] = useState<string | null>(null);
const [menuVisible, setMenuVisible] = useState(false);
const flatListRef = useRef<FlatList>(null);
const { data, loading, error } = useQuery(GET_MESSAGES, {
variables: { conversationId },
});
const [sendMessage] = useMutation(SEND_MESSAGE, {
update(cache, { data: { sendMessage } }) {
const existing = cache.readQuery({
query: GET_MESSAGES,
variables: { conversationId },
});
if (existing) {
cache.writeQuery({
query: GET_MESSAGES,
variables: { conversationId },
data: {
messages: [...existing.messages, sendMessage],
},
});
}
},
});
const [deleteMessage] = useMutation(DELETE_MESSAGE, {
update(cache, { data: { deleteMessage } }, { variables }) {
if (deleteMessage) {
cache.modify({
fields: {
messages(existingMessages, { readField }) {
return existingMessages.filter(
(messageRef: any) => variables?.messageId !== readField('id', messageRef)
);
},
},
});
}
},
});
useSubscription(MESSAGE_ADDED, {
variables: { conversationId },
onData: ({ data }) => {
if (data?.data?.messageAdded && data.data.messageAdded.sender.id !== user?.id) {
// Сообщение от другого пользователя
flatListRef.current?.scrollToEnd();
}
},
});
const handleSend = async () => {
if (!message.trim()) return;
try {
await sendMessage({
variables: {
conversationId,
content: message.trim(),
},
});
setMessage('');
setTimeout(() => flatListRef.current?.scrollToEnd(), 100);
} catch (error) {
console.error('Error sending message:', error);
}
};
const handleDeleteMessage = async (messageId: string) => {
try {
await deleteMessage({ variables: { messageId } });
setMenuVisible(false);
setSelectedMessage(null);
} catch (error) {
console.error('Error deleting message:', error);
}
};
const renderMessage = ({ item }: { item: Message }) => {
const isOwnMessage = item.sender.id === user?.id;
const messageTime = format(new Date(item.createdAt), 'HH:mm', { locale: ru });
return (
<View style={[styles.messageContainer, isOwnMessage && styles.ownMessageContainer]}>
{!isOwnMessage && (
<Avatar.Text
size={36}
label={item.sender.username.charAt(0).toUpperCase()}
style={styles.avatar}
/>
)}
<Surface
style={[styles.messageBubble, isOwnMessage && styles.ownMessageBubble]}
elevation={1}
>
{!isOwnMessage && (
<Text variant="labelSmall" style={styles.senderName}>
{item.sender.username}
</Text>
)}
<Text style={[styles.messageText, isOwnMessage && styles.ownMessageText]}>
{item.content}
</Text>
<Text variant="bodySmall" style={[styles.messageTime, isOwnMessage && styles.ownMessageTime]}>
{messageTime}
{item.isEdited && ' • изменено'}
</Text>
{isOwnMessage && (
<Menu
visible={menuVisible && selectedMessage === item.id}
onDismiss={() => {
setMenuVisible(false);
setSelectedMessage(null);
}}
anchor={
<IconButton
icon="dots-vertical"
size={16}
onPress={() => {
setSelectedMessage(item.id);
setMenuVisible(true);
}}
style={styles.menuButton}
/>
}
>
<Menu.Item onPress={() => handleDeleteMessage(item.id)} title="Удалить" />
</Menu>
)}
</Surface>
</View>
);
};
if (loading && !data) {
return (
<View style={styles.centerContainer}>
<Text>Загрузка сообщений...</Text>
</View>
);
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={90}
>
<FlatList
ref={flatListRef}
data={data?.messages || []}
renderItem={renderMessage}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.messagesList}
onContentSizeChange={() => flatListRef.current?.scrollToEnd()}
/>
<View style={styles.inputContainer}>
<TextInput
value={message}
onChangeText={setMessage}
placeholder="Введите сообщение..."
mode="outlined"
style={styles.input}
multiline
maxLength={1000}
right={
<TextInput.Icon
icon="send"
onPress={handleSend}
disabled={!message.trim()}
/>
}
/>
</View>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
messagesList: {
padding: 16,
paddingBottom: 8,
},
messageContainer: {
flexDirection: 'row',
marginBottom: 12,
alignItems: 'flex-end',
},
ownMessageContainer: {
justifyContent: 'flex-end',
},
avatar: {
marginRight: 8,
},
messageBubble: {
maxWidth: '75%',
padding: 12,
borderRadius: 16,
backgroundColor: '#fff',
},
ownMessageBubble: {
backgroundColor: '#2196F3',
},
senderName: {
color: '#666',
marginBottom: 4,
},
messageText: {
color: '#000',
},
ownMessageText: {
color: '#fff',
},
messageTime: {
color: '#666',
marginTop: 4,
fontSize: 11,
},
ownMessageTime: {
color: 'rgba(255, 255, 255, 0.7)',
},
menuButton: {
position: 'absolute',
top: -8,
right: -8,
},
inputContainer: {
padding: 8,
backgroundColor: '#fff',
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
},
input: {
maxHeight: 100,
},
});

View File

@ -0,0 +1,151 @@
import React from 'react';
import { View, StyleSheet, FlatList, TouchableOpacity } from 'react-native';
import { List, Avatar, Text, FAB, Divider, Badge } from 'react-native-paper';
import { useQuery } from '@apollo/client';
import { GET_CONVERSATIONS } from '../graphql/queries';
import { Conversation } from '../types';
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
export const ConversationsScreen = ({ navigation }: any) => {
const { data, loading, error, refetch } = useQuery(GET_CONVERSATIONS, {
pollInterval: 5000, // Обновляем каждые 5 секунд
});
const renderConversation = ({ item }: { item: Conversation }) => {
const otherParticipant = item.participants.find(p => p.id !== data?.me?.id);
const displayName = item.isGroup ? item.name : otherParticipant?.username;
const lastMessageTime = item.lastMessage
? format(new Date(item.lastMessage.createdAt), 'HH:mm', { locale: ru })
: '';
return (
<TouchableOpacity
onPress={() => navigation.navigate('Chat', { conversationId: item.id, title: displayName })}
>
<List.Item
title={displayName || 'Без имени'}
description={item.lastMessage?.content || 'Нет сообщений'}
left={() => (
<View>
<Avatar.Text
size={50}
label={displayName?.charAt(0).toUpperCase() || '?'}
/>
{otherParticipant?.isOnline && (
<Badge style={styles.onlineBadge} size={12} />
)}
</View>
)}
right={() => (
<View style={styles.rightContent}>
<Text variant="bodySmall" style={styles.time}>
{lastMessageTime}
</Text>
{/* Здесь можно добавить счетчик непрочитанных сообщений */}
</View>
)}
style={styles.listItem}
/>
<Divider />
</TouchableOpacity>
);
};
if (loading && !data) {
return (
<View style={styles.centerContainer}>
<Text>Загрузка...</Text>
</View>
);
}
if (error) {
return (
<View style={styles.centerContainer}>
<Text>Ошибка загрузки чатов</Text>
</View>
);
}
return (
<View style={styles.container}>
<FlatList
data={data?.conversations || []}
renderItem={renderConversation}
keyExtractor={(item) => item.id}
onRefresh={refetch}
refreshing={loading}
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text variant="bodyLarge" style={styles.emptyText}>
У вас пока нет чатов
</Text>
<Text variant="bodyMedium" style={styles.emptySubtext}>
Начните новый чат, нажав на кнопку внизу
</Text>
</View>
}
/>
<FAB
icon="plus"
style={styles.fab}
onPress={() => navigation.navigate('NewChat')}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#ffffff',
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
listContent: {
flexGrow: 1,
},
listItem: {
paddingVertical: 8,
},
rightContent: {
alignItems: 'flex-end',
justifyContent: 'center',
},
time: {
color: '#666',
},
onlineBadge: {
position: 'absolute',
bottom: 0,
right: 0,
backgroundColor: '#4CAF50',
},
fab: {
position: 'absolute',
margin: 16,
right: 0,
bottom: 0,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 40,
paddingTop: 100,
},
emptyText: {
textAlign: 'center',
marginBottom: 8,
color: '#666',
},
emptySubtext: {
textAlign: 'center',
color: '#999',
},
});

View File

@ -0,0 +1,122 @@
import React, { useState } from 'react';
import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
import { TextInput, Button, Text, Headline, HelperText } from 'react-native-paper';
import { useMutation } from '@apollo/client';
import { LOGIN } from '../graphql/mutations';
import { useAuth } from '../contexts/AuthContext';
export const LoginScreen = ({ navigation }: any) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const { login } = useAuth();
const [loginMutation, { loading, error }] = useMutation(LOGIN, {
onCompleted: async (data) => {
await login(data.login.access_token, data.login.user);
},
onError: (error) => {
console.error('Login error:', error);
},
});
const handleLogin = () => {
if (!username || !password) return;
loginMutation({ variables: { username, password } });
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.content}>
<Headline style={styles.title}>Вход в Prism</Headline>
<TextInput
label="Имя пользователя"
value={username}
onChangeText={setUsername}
mode="outlined"
style={styles.input}
autoCapitalize="none"
disabled={loading}
/>
<TextInput
label="Пароль"
value={password}
onChangeText={setPassword}
mode="outlined"
style={styles.input}
secureTextEntry={!showPassword}
disabled={loading}
right={
<TextInput.Icon
icon={showPassword ? 'eye-off' : 'eye'}
onPress={() => setShowPassword(!showPassword)}
/>
}
/>
{error && (
<HelperText type="error" visible={true}>
{error.message}
</HelperText>
)}
<Button
mode="contained"
onPress={handleLogin}
loading={loading}
disabled={loading || !username || !password}
style={styles.button}
>
Войти
</Button>
<Button
mode="text"
onPress={() => navigation.navigate('Register')}
disabled={loading}
style={styles.linkButton}
>
Нет аккаунта? Зарегистрироваться
</Button>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
scrollContent: {
flexGrow: 1,
justifyContent: 'center',
},
content: {
padding: 20,
maxWidth: 400,
width: '100%',
alignSelf: 'center',
},
title: {
textAlign: 'center',
marginBottom: 30,
},
input: {
marginBottom: 15,
},
button: {
marginTop: 10,
marginBottom: 10,
},
linkButton: {
marginTop: 10,
},
});

View File

@ -0,0 +1,154 @@
import React, { useState } from 'react';
import { View, StyleSheet, KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
import { TextInput, Button, Text, Headline, HelperText } from 'react-native-paper';
import { useMutation } from '@apollo/client';
import { REGISTER } from '../graphql/mutations';
import { useAuth } from '../contexts/AuthContext';
export const RegisterScreen = ({ navigation }: any) => {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const { login } = useAuth();
const [registerMutation, { loading, error }] = useMutation(REGISTER, {
onCompleted: async (data) => {
await login(data.register.access_token, data.register.user);
},
onError: (error) => {
console.error('Register error:', error);
},
});
const handleRegister = () => {
if (!username || !email || !password || password !== confirmPassword) return;
registerMutation({ variables: { username, email, password } });
};
const passwordsMatch = password === confirmPassword || confirmPassword === '';
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.content}>
<Headline style={styles.title}>Регистрация в Prism</Headline>
<TextInput
label="Имя пользователя"
value={username}
onChangeText={setUsername}
mode="outlined"
style={styles.input}
autoCapitalize="none"
disabled={loading}
/>
<TextInput
label="Email"
value={email}
onChangeText={setEmail}
mode="outlined"
style={styles.input}
keyboardType="email-address"
autoCapitalize="none"
disabled={loading}
/>
<TextInput
label="Пароль"
value={password}
onChangeText={setPassword}
mode="outlined"
style={styles.input}
secureTextEntry={!showPassword}
disabled={loading}
right={
<TextInput.Icon
icon={showPassword ? 'eye-off' : 'eye'}
onPress={() => setShowPassword(!showPassword)}
/>
}
/>
<TextInput
label="Подтвердите пароль"
value={confirmPassword}
onChangeText={setConfirmPassword}
mode="outlined"
style={styles.input}
secureTextEntry={!showPassword}
disabled={loading}
error={!passwordsMatch}
/>
{!passwordsMatch && (
<HelperText type="error" visible={true}>
Пароли не совпадают
</HelperText>
)}
{error && (
<HelperText type="error" visible={true}>
{error.message}
</HelperText>
)}
<Button
mode="contained"
onPress={handleRegister}
loading={loading}
disabled={loading || !username || !email || !password || !passwordsMatch}
style={styles.button}
>
Зарегистрироваться
</Button>
<Button
mode="text"
onPress={() => navigation.navigate('Login')}
disabled={loading}
style={styles.linkButton}
>
Уже есть аккаунт? Войти
</Button>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
scrollContent: {
flexGrow: 1,
justifyContent: 'center',
},
content: {
padding: 20,
maxWidth: 400,
width: '100%',
alignSelf: 'center',
},
title: {
textAlign: 'center',
marginBottom: 30,
},
input: {
marginBottom: 15,
},
button: {
marginTop: 10,
marginBottom: 10,
},
linkButton: {
marginTop: 10,
},
});

View File

@ -0,0 +1,27 @@
import { ApolloClient, InMemoryCache, createHttpLink, ApolloLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import AsyncStorage from '@react-native-async-storage/async-storage';
const httpLink = createHttpLink({
uri: 'http://localhost:3000/graphql', // Для эмулятора Android используйте 10.0.2.2 вместо localhost
});
const authLink = setContext(async (_, { headers }) => {
const token = await AsyncStorage.getItem('token');
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
export const apolloClient = new ApolloClient({
link: ApolloLink.from([authLink, httpLink]),
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
},
},
});

View File

@ -0,0 +1,39 @@
export interface User {
id: string;
username: string;
email: string;
avatar?: string;
bio?: string;
isOnline: boolean;
lastSeen?: Date;
createdAt: Date;
updatedAt: Date;
}
export interface Message {
id: string;
content: string;
sender: User;
conversation: Conversation;
isRead: boolean;
isEdited: boolean;
editedAt?: Date;
createdAt: Date;
updatedAt: Date;
}
export interface Conversation {
id: string;
name?: string;
isGroup: boolean;
participants: User[];
messages: Message[];
lastMessage?: Message;
createdAt: Date;
updatedAt: Date;
}
export interface AuthResponse {
access_token: string;
user: User;
}