Initial commit: Prism messenger with Expo + NestJS + GraphQL + PostgreSQL
This commit is contained in:
80
frontend/src/contexts/AuthContext.tsx
Normal file
80
frontend/src/contexts/AuthContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
90
frontend/src/graphql/mutations.ts
Normal file
90
frontend/src/graphql/mutations.ts
Normal 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}
|
||||
`;
|
96
frontend/src/graphql/queries.ts
Normal file
96
frontend/src/graphql/queries.ts
Normal 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}
|
||||
`;
|
11
frontend/src/graphql/subscriptions.ts
Normal file
11
frontend/src/graphql/subscriptions.ts
Normal 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}
|
||||
`;
|
68
frontend/src/navigation/AppNavigator.tsx
Normal file
68
frontend/src/navigation/AppNavigator.tsx
Normal 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>
|
||||
);
|
||||
};
|
263
frontend/src/screens/ChatScreen.tsx
Normal file
263
frontend/src/screens/ChatScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
151
frontend/src/screens/ConversationsScreen.tsx
Normal file
151
frontend/src/screens/ConversationsScreen.tsx
Normal 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',
|
||||
},
|
||||
});
|
122
frontend/src/screens/LoginScreen.tsx
Normal file
122
frontend/src/screens/LoginScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
154
frontend/src/screens/RegisterScreen.tsx
Normal file
154
frontend/src/screens/RegisterScreen.tsx
Normal 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,
|
||||
},
|
||||
});
|
27
frontend/src/services/apollo-client.ts
Normal file
27
frontend/src/services/apollo-client.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
39
frontend/src/types/index.ts
Normal file
39
frontend/src/types/index.ts
Normal 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;
|
||||
}
|
Reference in New Issue
Block a user